From a2f051c25b65fb66971df4adddd8d3612c777059 Mon Sep 17 00:00:00 2001 From: Seb Aebischer Date: Wed, 17 Aug 2022 09:54:36 +0100 Subject: [PATCH 01/39] Set up TypeScript --- .eslintrc.yml | 6 +- .nycrc.yml | 6 + babel.config.js | 13 - package.json | 33 +- tsconfig-base.json | 30 + tsconfig-build.json | 10 + tsconfig.json | 11 + yarn.lock | 1433 ++++++++----------------------------------- 8 files changed, 338 insertions(+), 1204 deletions(-) create mode 100644 .nycrc.yml delete mode 100644 babel.config.js create mode 100644 tsconfig-base.json create mode 100644 tsconfig-build.json create mode 100644 tsconfig.json diff --git a/.eslintrc.yml b/.eslintrc.yml index 1a98544e..79e22cda 100644 --- a/.eslintrc.yml +++ b/.eslintrc.yml @@ -1,10 +1,8 @@ root: true extends: - - '@comicrelief/eslint-config' - - '@comicrelief/eslint-config/mixins/jsdoc' - -parser: '@babel/eslint-parser' + - '@comicrelief/eslint-config/mixins/base' + - '@comicrelief/eslint-config/mixins/ts' ignorePatterns: - node_modules diff --git a/.nycrc.yml b/.nycrc.yml new file mode 100644 index 00000000..8fb1de64 --- /dev/null +++ b/.nycrc.yml @@ -0,0 +1,6 @@ +extends: '@istanbuljs/nyc-config-typescript' + +all: true +check-coverage: true +include: + - src/** diff --git a/babel.config.js b/babel.config.js deleted file mode 100644 index b57ac9e8..00000000 --- a/babel.config.js +++ /dev/null @@ -1,13 +0,0 @@ -module.exports = { - presets: [ - [ - '@babel/preset-env', - { - targets: { - node: 'current', - }, - }, - ], - ], - plugins: ['@babel/plugin-syntax-flow', '@babel/plugin-transform-flow-strip-types'], -}; diff --git a/package.json b/package.json index 08eae247..7d422b37 100644 --- a/package.json +++ b/package.json @@ -3,36 +3,33 @@ "version": "0.0.0-see.readme.for.semantic.release.process", "description": "Lambda wrapper for all Comic Relief Serverless Projects", "main": "dist/index.js", + "author": "Adam Clark", + "license": "ISC", "scripts": { + "prepare": "yarn clean && yarn build", + "build": "tsc -p tsconfig-build.json", + "clean": "rm -rf dist", "lint": "eslint src tests", - "test": "jest --coverage", - "build": "babel src --out-dir dist --copy-files", - "prepublish": "yarn build" + "test": "jest", + "coverage": "yarn test --coverage" }, - "author": "Adam Clark", - "license": "ISC", "devDependencies": { - "@babel/cli": "^7.18.10", - "@babel/core": "^7.18.10", - "@babel/eslint-parser": "^7.18.9", - "@babel/node": "^7.18.10", - "@babel/plugin-syntax-flow": "^7.18.6", - "@babel/plugin-transform-flow-strip-types": "^7.18.9", - "@babel/plugin-transform-react-jsx": "^7.18.10", - "@babel/preset-env": "^7.18.10", "@comicrelief/eslint-config": "^2.0.3", + "@istanbuljs/nyc-config-typescript": "^1.0.2", "@types/jest": "^28.1.6", + "@types/node": "14", + "@typescript-eslint/eslint-plugin": "^5.33.0", + "@typescript-eslint/parser": "^5.33.0", "aws-sdk": "^2.1194.0", - "babel-jest": "^28.1.3", "eslint": "^8.22.0", - "eslint-plugin-flowtype": "^8.0.3", "eslint-plugin-import": "^2.25.2", "eslint-plugin-jsdoc": "^39.3.2", - "eslint-plugin-sonarjs": "^0.13.0", - "eslint-plugin-unicorn": "^42.0.0", "jest": "^28.1.3", "nyc": "^15.1.0", - "semantic-release": "^19.0.3" + "semantic-release": "^19.0.3", + "ts-node": "^10.9.1", + "tsconfig-paths": "^4.1.0", + "typescript": "^4.7.4" }, "peerDependencies": { "aws-sdk": "^2.831.0" diff --git a/tsconfig-base.json b/tsconfig-base.json new file mode 100644 index 00000000..70011e4e --- /dev/null +++ b/tsconfig-base.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + /* Visit https://aka.ms/tsconfig.json to read more about this file */ + "strict": true, + "target": "es2020", + "module": "commonjs", + "esModuleInterop": true, + "lib": ["es2020"], + "sourceMap": true, + "declaration": true, + "declarationMap": true, + "outDir": "dist", + "baseUrl": ".", + "paths": { + "@/*": ["./*"], + }, + /* Additional Checks */ + "noUnusedLocals": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + }, + "ts-node": { + "files": true, + "require": [ + "tsconfig-paths/register", + ], + }, +} diff --git a/tsconfig-build.json b/tsconfig-build.json new file mode 100644 index 00000000..8c4c9811 --- /dev/null +++ b/tsconfig-build.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig-base.json", + "compilerOptions": { + "rootDir": "src", + "types": ["node"], + }, + "include": [ + "src/**/*.ts" + ], +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000..17d11e6b --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig-base.json", + "compilerOptions": { + "rootDir": ".", + }, + "include": [ + "src/**/*.ts", + "tests/**/*.ts", + "*.d.ts", + ], +} diff --git a/yarn.lock b/yarn.lock index e8b95ca8..b06fcbc4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -649,22 +649,6 @@ "@aws-sdk/util-buffer-from" "3.55.0" tslib "^2.3.1" -"@babel/cli@^7.18.10": - version "7.18.10" - resolved "https://registry.yarnpkg.com/@babel/cli/-/cli-7.18.10.tgz#4211adfc45ffa7d4f3cee6b60bb92e9fe68fe56a" - integrity sha512-dLvWH+ZDFAkd2jPBSghrsFBuXrREvFwjpDycXbmUoeochqKYe4zNSLEJYErpLg8dvxvZYe79/MkN461XCwpnGw== - dependencies: - "@jridgewell/trace-mapping" "^0.3.8" - commander "^4.0.1" - convert-source-map "^1.1.0" - fs-readdir-recursive "^1.1.0" - glob "^7.2.0" - make-dir "^2.1.0" - slash "^2.0.0" - optionalDependencies: - "@nicolo-ribaudo/chokidar-2" "2.1.8-no-fsevents.3" - chokidar "^3.4.0" - "@babel/code-frame@^7.0.0", "@babel/code-frame@^7.12.13", "@babel/code-frame@^7.18.6": version "7.18.6" resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.18.6.tgz#3b25d38c89600baa2dcc219edfa88a74eb2c427a" @@ -672,12 +656,12 @@ dependencies: "@babel/highlight" "^7.18.6" -"@babel/compat-data@^7.17.7", "@babel/compat-data@^7.18.8": +"@babel/compat-data@^7.18.8": version "7.18.8" resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.18.8.tgz#2483f565faca607b8535590e84e7de323f27764d" integrity sha512-HSmX4WZPPK3FUxYp7g2T6EyO8j96HlZJlxmKPSh6KAcqwyDrfx7hKjXpAW/0FhFfTJsR0Yt4lAjLI2coMptIHQ== -"@babel/core@^7.11.6", "@babel/core@^7.12.3", "@babel/core@^7.18.10", "@babel/core@^7.7.5": +"@babel/core@^7.11.6", "@babel/core@^7.12.3", "@babel/core@^7.7.5": version "7.18.10" resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.18.10.tgz#39ad504991d77f1f3da91be0b8b949a5bc466fb8" integrity sha512-JQM6k6ENcBFKVtWvLavlvi/mPcpYZ3+R+2EySDEMSMbp7Mn4FexlbbJVrx2R7Ijhr01T8gyqrOaABWIOgxeUyw== @@ -698,15 +682,6 @@ json5 "^2.2.1" semver "^6.3.0" -"@babel/eslint-parser@^7.18.9": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/eslint-parser/-/eslint-parser-7.18.9.tgz#255a63796819a97b7578751bb08ab9f2a375a031" - integrity sha512-KzSGpMBggz4fKbRbWLNyPVTuQr6cmCcBhOyXTw/fieOVaw5oYAwcAj4a7UKcDYCPxQq+CG1NCDZH9e2JTXquiQ== - dependencies: - eslint-scope "^5.1.1" - eslint-visitor-keys "^2.1.0" - semver "^6.3.0" - "@babel/generator@^7.18.10", "@babel/generator@^7.7.2": version "7.18.12" resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.18.12.tgz#fa58daa303757bd6f5e4bbca91b342040463d9f4" @@ -716,22 +691,7 @@ "@jridgewell/gen-mapping" "^0.3.2" jsesc "^2.5.1" -"@babel/helper-annotate-as-pure@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.18.6.tgz#eaa49f6f80d5a33f9a5dd2276e6d6e451be0a6bb" - integrity sha512-duORpUiYrEpzKIop6iNbjnwKLAKnJ47csTyRACyEmWj0QdUrm5aqNJGHSSEQSUAvNW0ojX0dOmK9dZduvkfeXA== - dependencies: - "@babel/types" "^7.18.6" - -"@babel/helper-builder-binary-assignment-operator-visitor@^7.18.6": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.18.9.tgz#acd4edfd7a566d1d51ea975dff38fd52906981bb" - integrity sha512-yFQ0YCHoIqarl8BCRwBL8ulYUaZpz3bNsA7oFepAzee+8/+ImtADXNOmO5vJvsPff3qi+hvpkY/NYBTrBQgdNw== - dependencies: - "@babel/helper-explode-assignable-expression" "^7.18.6" - "@babel/types" "^7.18.9" - -"@babel/helper-compilation-targets@^7.17.7", "@babel/helper-compilation-targets@^7.18.9": +"@babel/helper-compilation-targets@^7.18.9": version "7.18.9" resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.18.9.tgz#69e64f57b524cde3e5ff6cc5a9f4a387ee5563bf" integrity sha512-tzLCyVmqUiFlcFoAPLA/gL9TeYrF61VLNtb+hvkuVaB5SUjW7jcfrglBIX1vUIoT7CLP3bBlIMeyEsIl2eFQNg== @@ -741,51 +701,11 @@ browserslist "^4.20.2" semver "^6.3.0" -"@babel/helper-create-class-features-plugin@^7.18.6": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.18.9.tgz#d802ee16a64a9e824fcbf0a2ffc92f19d58550ce" - integrity sha512-WvypNAYaVh23QcjpMR24CwZY2Nz6hqdOcFdPbNpV56hL5H6KiFheO7Xm1aPdlLQ7d5emYZX7VZwPp9x3z+2opw== - dependencies: - "@babel/helper-annotate-as-pure" "^7.18.6" - "@babel/helper-environment-visitor" "^7.18.9" - "@babel/helper-function-name" "^7.18.9" - "@babel/helper-member-expression-to-functions" "^7.18.9" - "@babel/helper-optimise-call-expression" "^7.18.6" - "@babel/helper-replace-supers" "^7.18.9" - "@babel/helper-split-export-declaration" "^7.18.6" - -"@babel/helper-create-regexp-features-plugin@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.18.6.tgz#3e35f4e04acbbf25f1b3534a657610a000543d3c" - integrity sha512-7LcpH1wnQLGrI+4v+nPp+zUvIkF9x0ddv1Hkdue10tg3gmRnLy97DXh4STiOf1qeIInyD69Qv5kKSZzKD8B/7A== - dependencies: - "@babel/helper-annotate-as-pure" "^7.18.6" - regexpu-core "^5.1.0" - -"@babel/helper-define-polyfill-provider@^0.3.2": - version "0.3.2" - resolved "https://registry.yarnpkg.com/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.3.2.tgz#bd10d0aca18e8ce012755395b05a79f45eca5073" - integrity sha512-r9QJJ+uDWrd+94BSPcP6/de67ygLtvVy6cK4luE6MOuDsZIdoaPBnfSpbO/+LTifjPckbKXRuI9BB/Z2/y3iTg== - dependencies: - "@babel/helper-compilation-targets" "^7.17.7" - "@babel/helper-plugin-utils" "^7.16.7" - debug "^4.1.1" - lodash.debounce "^4.0.8" - resolve "^1.14.2" - semver "^6.1.2" - "@babel/helper-environment-visitor@^7.18.9": version "7.18.9" resolved "https://registry.yarnpkg.com/@babel/helper-environment-visitor/-/helper-environment-visitor-7.18.9.tgz#0c0cee9b35d2ca190478756865bb3528422f51be" integrity sha512-3r/aACDJ3fhQ/EVgFy0hpj8oHyHpQc+LPtJoY9SzTThAsStm4Ptegq92vqKoE3vD706ZVFWITnMnxucw+S9Ipg== -"@babel/helper-explode-assignable-expression@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/helper-explode-assignable-expression/-/helper-explode-assignable-expression-7.18.6.tgz#41f8228ef0a6f1a036b8dfdfec7ce94f9a6bc096" - integrity sha512-eyAYAsQmB80jNfg4baAtLeWAQHfHFiR483rzFK+BhETlGZaQC9bsfrugfXDCbRHLQbIA7U5NxhhOxN7p/dWIcg== - dependencies: - "@babel/types" "^7.18.6" - "@babel/helper-function-name@^7.18.9": version "7.18.9" resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.18.9.tgz#940e6084a55dee867d33b4e487da2676365e86b0" @@ -801,13 +721,6 @@ dependencies: "@babel/types" "^7.18.6" -"@babel/helper-member-expression-to-functions@^7.18.9": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.18.9.tgz#1531661e8375af843ad37ac692c132841e2fd815" - integrity sha512-RxifAh2ZoVU67PyKIO4AMi1wTenGfMR/O/ae0CCRqwgBAt5v7xjdtRw7UoSbsreKrQn5t7r89eruK/9JjYHuDg== - dependencies: - "@babel/types" "^7.18.9" - "@babel/helper-module-imports@^7.18.6": version "7.18.6" resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.18.6.tgz#1e3ebdbbd08aad1437b428c50204db13c5a3ca6e" @@ -815,7 +728,7 @@ dependencies: "@babel/types" "^7.18.6" -"@babel/helper-module-transforms@^7.18.6", "@babel/helper-module-transforms@^7.18.9": +"@babel/helper-module-transforms@^7.18.9": version "7.18.9" resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.18.9.tgz#5a1079c005135ed627442df31a42887e80fcb712" integrity sha512-KYNqY0ICwfv19b31XzvmI/mfcylOzbLtowkw+mfvGPAQ3kfCnMLYbED3YecL5tPd8nAYFQFAd6JHp2LxZk/J1g== @@ -829,39 +742,11 @@ "@babel/traverse" "^7.18.9" "@babel/types" "^7.18.9" -"@babel/helper-optimise-call-expression@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.18.6.tgz#9369aa943ee7da47edab2cb4e838acf09d290ffe" - integrity sha512-HP59oD9/fEHQkdcbgFCnbmgH5vIQTJbxh2yf+CdM89/glUNnuzr87Q8GIjGEnOktTROemO0Pe0iPAYbqZuOUiA== - dependencies: - "@babel/types" "^7.18.6" - -"@babel/helper-plugin-utils@^7.0.0", "@babel/helper-plugin-utils@^7.10.4", "@babel/helper-plugin-utils@^7.12.13", "@babel/helper-plugin-utils@^7.14.5", "@babel/helper-plugin-utils@^7.16.7", "@babel/helper-plugin-utils@^7.18.6", "@babel/helper-plugin-utils@^7.18.9", "@babel/helper-plugin-utils@^7.8.0", "@babel/helper-plugin-utils@^7.8.3": +"@babel/helper-plugin-utils@^7.0.0", "@babel/helper-plugin-utils@^7.10.4", "@babel/helper-plugin-utils@^7.12.13", "@babel/helper-plugin-utils@^7.14.5", "@babel/helper-plugin-utils@^7.18.6", "@babel/helper-plugin-utils@^7.8.0": version "7.18.9" resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.18.9.tgz#4b8aea3b069d8cb8a72cdfe28ddf5ceca695ef2f" integrity sha512-aBXPT3bmtLryXaoJLyYPXPlSD4p1ld9aYeR+sJNOZjJJGiOpb+fKfh3NkcCu7J54nUJwCERPBExCCpyCOHnu/w== -"@babel/helper-remap-async-to-generator@^7.18.6", "@babel/helper-remap-async-to-generator@^7.18.9": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.18.9.tgz#997458a0e3357080e54e1d79ec347f8a8cd28519" - integrity sha512-dI7q50YKd8BAv3VEfgg7PS7yD3Rtbi2J1XMXaalXO0W0164hYLnh8zpjRS0mte9MfVp/tltvr/cfdXPvJr1opA== - dependencies: - "@babel/helper-annotate-as-pure" "^7.18.6" - "@babel/helper-environment-visitor" "^7.18.9" - "@babel/helper-wrap-function" "^7.18.9" - "@babel/types" "^7.18.9" - -"@babel/helper-replace-supers@^7.18.6", "@babel/helper-replace-supers@^7.18.9": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/helper-replace-supers/-/helper-replace-supers-7.18.9.tgz#1092e002feca980fbbb0bd4d51b74a65c6a500e6" - integrity sha512-dNsWibVI4lNT6HiuOIBr1oyxo40HvIVmbwPUm3XZ7wMh4k2WxrxTqZwSqw/eEmXDS9np0ey5M2bz9tBmO9c+YQ== - dependencies: - "@babel/helper-environment-visitor" "^7.18.9" - "@babel/helper-member-expression-to-functions" "^7.18.9" - "@babel/helper-optimise-call-expression" "^7.18.6" - "@babel/traverse" "^7.18.9" - "@babel/types" "^7.18.9" - "@babel/helper-simple-access@^7.18.6": version "7.18.6" resolved "https://registry.yarnpkg.com/@babel/helper-simple-access/-/helper-simple-access-7.18.6.tgz#d6d8f51f4ac2978068df934b569f08f29788c7ea" @@ -869,13 +754,6 @@ dependencies: "@babel/types" "^7.18.6" -"@babel/helper-skip-transparent-expression-wrappers@^7.18.9": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.18.9.tgz#778d87b3a758d90b471e7b9918f34a9a02eb5818" - integrity sha512-imytd2gHi3cJPsybLRbmFrF7u5BIEuI2cNheyKi3/iOBC63kNn3q8Crn2xVuESli0aM4KYsyEqKyS7lFL8YVtw== - dependencies: - "@babel/types" "^7.18.9" - "@babel/helper-split-export-declaration@^7.18.6": version "7.18.6" resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.18.6.tgz#7367949bc75b20c6d5a5d4a97bba2824ae8ef075" @@ -888,7 +766,7 @@ resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.18.10.tgz#181f22d28ebe1b3857fa575f5c290b1aaf659b56" integrity sha512-XtIfWmeNY3i4t7t4D2t02q50HvqHybPqW2ki1kosnvWCwuCMeo81Jf0gwr85jy/neUdg5XDdeFE/80DXiO+njw== -"@babel/helper-validator-identifier@^7.15.7", "@babel/helper-validator-identifier@^7.18.6": +"@babel/helper-validator-identifier@^7.18.6": version "7.18.6" resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.18.6.tgz#9c97e30d31b2b8c72a1d08984f2ca9b574d7a076" integrity sha512-MmetCkz9ej86nJQV+sFCxoGGrUbU3q02kgLciwkrt9QqEB7cP39oKEY0PakknEO0Gu20SskMRi+AYZ3b1TpN9g== @@ -898,16 +776,6 @@ resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.18.6.tgz#bf0d2b5a509b1f336099e4ff36e1a63aa5db4db8" integrity sha512-XO7gESt5ouv/LRJdrVjkShckw6STTaB7l9BrpBaAHDeF5YZT+01PCwmR0SJHnkW6i8OwW/EVWRShfi4j2x+KQw== -"@babel/helper-wrap-function@^7.18.9": - version "7.18.11" - resolved "https://registry.yarnpkg.com/@babel/helper-wrap-function/-/helper-wrap-function-7.18.11.tgz#bff23ace436e3f6aefb61f85ffae2291c80ed1fb" - integrity sha512-oBUlbv+rjZLh2Ks9SKi4aL7eKaAXBWleHzU89mP0G6BMUlRxSckk9tSIkgDGydhgFxHuGSlBQZfnaD47oBEB7w== - dependencies: - "@babel/helper-function-name" "^7.18.9" - "@babel/template" "^7.18.10" - "@babel/traverse" "^7.18.11" - "@babel/types" "^7.18.10" - "@babel/helpers@^7.18.9": version "7.18.9" resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.18.9.tgz#4bef3b893f253a1eced04516824ede94dcfe7ff9" @@ -926,168 +794,11 @@ chalk "^2.0.0" js-tokens "^4.0.0" -"@babel/node@^7.18.10": - version "7.18.10" - resolved "https://registry.yarnpkg.com/@babel/node/-/node-7.18.10.tgz#ab2be57785346b5bf0721c3d17572402419d9d8a" - integrity sha512-VbqzK6QXfQVi4Bpk6J7XqHXKFNbG2j3rdIdx68+/14GDU7jXDOSyUU/cwqCM1fDwCdxp37pNV/ToSCXsNChcyA== - dependencies: - "@babel/register" "^7.18.9" - commander "^4.0.1" - core-js "^3.22.1" - node-environment-flags "^1.0.5" - regenerator-runtime "^0.13.4" - v8flags "^3.1.1" - "@babel/parser@^7.1.0", "@babel/parser@^7.14.7", "@babel/parser@^7.18.10", "@babel/parser@^7.18.11": version "7.18.11" resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.18.11.tgz#68bb07ab3d380affa9a3f96728df07969645d2d9" integrity sha512-9JKn5vN+hDt0Hdqn1PiJ2guflwP+B6Ga8qbDuoF0PzzVhrzsKIJo8yGqVk6CmMHiMei9w1C1Bp9IMJSIK+HPIQ== -"@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.18.6.tgz#da5b8f9a580acdfbe53494dba45ea389fb09a4d2" - integrity sha512-Dgxsyg54Fx1d4Nge8UnvTrED63vrwOdPmyvPzlNN/boaliRP54pm3pGzZD1SJUwrBA+Cs/xdG8kXX6Mn/RfISQ== - dependencies: - "@babel/helper-plugin-utils" "^7.18.6" - -"@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@^7.18.9": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.18.9.tgz#a11af19aa373d68d561f08e0a57242350ed0ec50" - integrity sha512-AHrP9jadvH7qlOj6PINbgSuphjQUAK7AOT7DPjBo9EHoLhQTnnK5u45e1Hd4DbSQEO9nqPWtQ89r+XEOWFScKg== - dependencies: - "@babel/helper-plugin-utils" "^7.18.9" - "@babel/helper-skip-transparent-expression-wrappers" "^7.18.9" - "@babel/plugin-proposal-optional-chaining" "^7.18.9" - -"@babel/plugin-proposal-async-generator-functions@^7.18.10": - version "7.18.10" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.18.10.tgz#85ea478c98b0095c3e4102bff3b67d306ed24952" - integrity sha512-1mFuY2TOsR1hxbjCo4QL+qlIjV07p4H4EUYw2J/WCqsvFV6V9X9z9YhXbWndc/4fw+hYGlDT7egYxliMp5O6Ew== - dependencies: - "@babel/helper-environment-visitor" "^7.18.9" - "@babel/helper-plugin-utils" "^7.18.9" - "@babel/helper-remap-async-to-generator" "^7.18.9" - "@babel/plugin-syntax-async-generators" "^7.8.4" - -"@babel/plugin-proposal-class-properties@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.18.6.tgz#b110f59741895f7ec21a6fff696ec46265c446a3" - integrity sha512-cumfXOF0+nzZrrN8Rf0t7M+tF6sZc7vhQwYQck9q1/5w2OExlD+b4v4RpMJFaV1Z7WcDRgO6FqvxqxGlwo+RHQ== - dependencies: - "@babel/helper-create-class-features-plugin" "^7.18.6" - "@babel/helper-plugin-utils" "^7.18.6" - -"@babel/plugin-proposal-class-static-block@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-class-static-block/-/plugin-proposal-class-static-block-7.18.6.tgz#8aa81d403ab72d3962fc06c26e222dacfc9b9020" - integrity sha512-+I3oIiNxrCpup3Gi8n5IGMwj0gOCAjcJUSQEcotNnCCPMEnixawOQ+KeJPlgfjzx+FKQ1QSyZOWe7wmoJp7vhw== - dependencies: - "@babel/helper-create-class-features-plugin" "^7.18.6" - "@babel/helper-plugin-utils" "^7.18.6" - "@babel/plugin-syntax-class-static-block" "^7.14.5" - -"@babel/plugin-proposal-dynamic-import@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-dynamic-import/-/plugin-proposal-dynamic-import-7.18.6.tgz#72bcf8d408799f547d759298c3c27c7e7faa4d94" - integrity sha512-1auuwmK+Rz13SJj36R+jqFPMJWyKEDd7lLSdOj4oJK0UTgGueSAtkrCvz9ewmgyU/P941Rv2fQwZJN8s6QruXw== - dependencies: - "@babel/helper-plugin-utils" "^7.18.6" - "@babel/plugin-syntax-dynamic-import" "^7.8.3" - -"@babel/plugin-proposal-export-namespace-from@^7.18.9": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-export-namespace-from/-/plugin-proposal-export-namespace-from-7.18.9.tgz#5f7313ab348cdb19d590145f9247540e94761203" - integrity sha512-k1NtHyOMvlDDFeb9G5PhUXuGj8m/wiwojgQVEhJ/fsVsMCpLyOP4h0uGEjYJKrRI+EVPlb5Jk+Gt9P97lOGwtA== - dependencies: - "@babel/helper-plugin-utils" "^7.18.9" - "@babel/plugin-syntax-export-namespace-from" "^7.8.3" - -"@babel/plugin-proposal-json-strings@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-json-strings/-/plugin-proposal-json-strings-7.18.6.tgz#7e8788c1811c393aff762817e7dbf1ebd0c05f0b" - integrity sha512-lr1peyn9kOdbYc0xr0OdHTZ5FMqS6Di+H0Fz2I/JwMzGmzJETNeOFq2pBySw6X/KFL5EWDjlJuMsUGRFb8fQgQ== - dependencies: - "@babel/helper-plugin-utils" "^7.18.6" - "@babel/plugin-syntax-json-strings" "^7.8.3" - -"@babel/plugin-proposal-logical-assignment-operators@^7.18.9": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-logical-assignment-operators/-/plugin-proposal-logical-assignment-operators-7.18.9.tgz#8148cbb350483bf6220af06fa6db3690e14b2e23" - integrity sha512-128YbMpjCrP35IOExw2Fq+x55LMP42DzhOhX2aNNIdI9avSWl2PI0yuBWarr3RYpZBSPtabfadkH2yeRiMD61Q== - dependencies: - "@babel/helper-plugin-utils" "^7.18.9" - "@babel/plugin-syntax-logical-assignment-operators" "^7.10.4" - -"@babel/plugin-proposal-nullish-coalescing-operator@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-nullish-coalescing-operator/-/plugin-proposal-nullish-coalescing-operator-7.18.6.tgz#fdd940a99a740e577d6c753ab6fbb43fdb9467e1" - integrity sha512-wQxQzxYeJqHcfppzBDnm1yAY0jSRkUXR2z8RePZYrKwMKgMlE8+Z6LUno+bd6LvbGh8Gltvy74+9pIYkr+XkKA== - dependencies: - "@babel/helper-plugin-utils" "^7.18.6" - "@babel/plugin-syntax-nullish-coalescing-operator" "^7.8.3" - -"@babel/plugin-proposal-numeric-separator@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-numeric-separator/-/plugin-proposal-numeric-separator-7.18.6.tgz#899b14fbafe87f053d2c5ff05b36029c62e13c75" - integrity sha512-ozlZFogPqoLm8WBr5Z8UckIoE4YQ5KESVcNudyXOR8uqIkliTEgJ3RoketfG6pmzLdeZF0H/wjE9/cCEitBl7Q== - dependencies: - "@babel/helper-plugin-utils" "^7.18.6" - "@babel/plugin-syntax-numeric-separator" "^7.10.4" - -"@babel/plugin-proposal-object-rest-spread@^7.18.9": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.18.9.tgz#f9434f6beb2c8cae9dfcf97d2a5941bbbf9ad4e7" - integrity sha512-kDDHQ5rflIeY5xl69CEqGEZ0KY369ehsCIEbTGb4siHG5BE9sga/T0r0OUwyZNLMmZE79E1kbsqAjwFCW4ds6Q== - dependencies: - "@babel/compat-data" "^7.18.8" - "@babel/helper-compilation-targets" "^7.18.9" - "@babel/helper-plugin-utils" "^7.18.9" - "@babel/plugin-syntax-object-rest-spread" "^7.8.3" - "@babel/plugin-transform-parameters" "^7.18.8" - -"@babel/plugin-proposal-optional-catch-binding@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-optional-catch-binding/-/plugin-proposal-optional-catch-binding-7.18.6.tgz#f9400d0e6a3ea93ba9ef70b09e72dd6da638a2cb" - integrity sha512-Q40HEhs9DJQyaZfUjjn6vE8Cv4GmMHCYuMGIWUnlxH6400VGxOuwWsPt4FxXxJkC/5eOzgn0z21M9gMT4MOhbw== - dependencies: - "@babel/helper-plugin-utils" "^7.18.6" - "@babel/plugin-syntax-optional-catch-binding" "^7.8.3" - -"@babel/plugin-proposal-optional-chaining@^7.18.9": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-optional-chaining/-/plugin-proposal-optional-chaining-7.18.9.tgz#e8e8fe0723f2563960e4bf5e9690933691915993" - integrity sha512-v5nwt4IqBXihxGsW2QmCWMDS3B3bzGIk/EQVZz2ei7f3NJl8NzAJVvUmpDW5q1CRNY+Beb/k58UAH1Km1N411w== - dependencies: - "@babel/helper-plugin-utils" "^7.18.9" - "@babel/helper-skip-transparent-expression-wrappers" "^7.18.9" - "@babel/plugin-syntax-optional-chaining" "^7.8.3" - -"@babel/plugin-proposal-private-methods@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-private-methods/-/plugin-proposal-private-methods-7.18.6.tgz#5209de7d213457548a98436fa2882f52f4be6bea" - integrity sha512-nutsvktDItsNn4rpGItSNV2sz1XwS+nfU0Rg8aCx3W3NOKVzdMjJRu0O5OkgDp3ZGICSTbgRpxZoWsxoKRvbeA== - dependencies: - "@babel/helper-create-class-features-plugin" "^7.18.6" - "@babel/helper-plugin-utils" "^7.18.6" - -"@babel/plugin-proposal-private-property-in-object@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.18.6.tgz#a64137b232f0aca3733a67eb1a144c192389c503" - integrity sha512-9Rysx7FOctvT5ouj5JODjAFAkgGoudQuLPamZb0v1TGLpapdNaftzifU8NTWQm0IRjqoYypdrSmyWgkocDQ8Dw== - dependencies: - "@babel/helper-annotate-as-pure" "^7.18.6" - "@babel/helper-create-class-features-plugin" "^7.18.6" - "@babel/helper-plugin-utils" "^7.18.6" - "@babel/plugin-syntax-private-property-in-object" "^7.14.5" - -"@babel/plugin-proposal-unicode-property-regex@^7.18.6", "@babel/plugin-proposal-unicode-property-regex@^7.4.4": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-unicode-property-regex/-/plugin-proposal-unicode-property-regex-7.18.6.tgz#af613d2cd5e643643b65cded64207b15c85cb78e" - integrity sha512-2BShG/d5yoZyXZfVePH91urL5wTG6ASZU9M4o03lKK8u8UW1y08OMttBSOADTcJrnPMpvDXRG3G8fyLh4ovs8w== - dependencies: - "@babel/helper-create-regexp-features-plugin" "^7.18.6" - "@babel/helper-plugin-utils" "^7.18.6" - "@babel/plugin-syntax-async-generators@^7.8.4": version "7.8.4" resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz#a983fb1aeb2ec3f6ed042a210f640e90e786fe0d" @@ -1102,48 +813,13 @@ dependencies: "@babel/helper-plugin-utils" "^7.8.0" -"@babel/plugin-syntax-class-properties@^7.12.13", "@babel/plugin-syntax-class-properties@^7.8.3": +"@babel/plugin-syntax-class-properties@^7.8.3": version "7.12.13" resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz#b5c987274c4a3a82b89714796931a6b53544ae10" integrity sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA== dependencies: "@babel/helper-plugin-utils" "^7.12.13" -"@babel/plugin-syntax-class-static-block@^7.14.5": - version "7.14.5" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz#195df89b146b4b78b3bf897fd7a257c84659d406" - integrity sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw== - dependencies: - "@babel/helper-plugin-utils" "^7.14.5" - -"@babel/plugin-syntax-dynamic-import@^7.8.3": - version "7.8.3" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.8.3.tgz#62bf98b2da3cd21d626154fc96ee5b3cb68eacb3" - integrity sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ== - dependencies: - "@babel/helper-plugin-utils" "^7.8.0" - -"@babel/plugin-syntax-export-namespace-from@^7.8.3": - version "7.8.3" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-export-namespace-from/-/plugin-syntax-export-namespace-from-7.8.3.tgz#028964a9ba80dbc094c915c487ad7c4e7a66465a" - integrity sha512-MXf5laXo6c1IbEbegDmzGPwGNTsHZmEy6QGznu5Sh2UCWvueywb2ee+CCE4zQiZstxU9BMoQO9i6zUFSY0Kj0Q== - dependencies: - "@babel/helper-plugin-utils" "^7.8.3" - -"@babel/plugin-syntax-flow@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.18.6.tgz#774d825256f2379d06139be0c723c4dd444f3ca1" - integrity sha512-LUbR+KNTBWCUAqRG9ex5Gnzu2IOkt8jRJbHHXFT9q+L9zm7M/QQbEqXyw1n1pohYvOyWC8CjeyjrSaIwiYjK7A== - dependencies: - "@babel/helper-plugin-utils" "^7.18.6" - -"@babel/plugin-syntax-import-assertions@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.18.6.tgz#cd6190500a4fa2fe31990a963ffab4b63e4505e4" - integrity sha512-/DU3RXad9+bZwrgWJQKbr39gYbJpLJHezqEzRzi/BHRlJ9zsQb4CK2CA/5apllXNomwA1qHwzvHl+AdEmC5krQ== - dependencies: - "@babel/helper-plugin-utils" "^7.18.6" - "@babel/plugin-syntax-import-meta@^7.8.3": version "7.10.4" resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz#ee601348c370fa334d2207be158777496521fd51" @@ -1158,14 +834,7 @@ dependencies: "@babel/helper-plugin-utils" "^7.8.0" -"@babel/plugin-syntax-jsx@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.18.6.tgz#a8feef63b010150abd97f1649ec296e849943ca0" - integrity sha512-6mmljtAedFGTWu2p/8WIORGwy+61PLgOMPOdazc7YoJ9ZCWUyFy3A6CpPkRKLKD1ToAesxX8KGEViAiLo9N+7Q== - dependencies: - "@babel/helper-plugin-utils" "^7.18.6" - -"@babel/plugin-syntax-logical-assignment-operators@^7.10.4", "@babel/plugin-syntax-logical-assignment-operators@^7.8.3": +"@babel/plugin-syntax-logical-assignment-operators@^7.8.3": version "7.10.4" resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz#ca91ef46303530448b906652bac2e9fe9941f699" integrity sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig== @@ -1179,7 +848,7 @@ dependencies: "@babel/helper-plugin-utils" "^7.8.0" -"@babel/plugin-syntax-numeric-separator@^7.10.4", "@babel/plugin-syntax-numeric-separator@^7.8.3": +"@babel/plugin-syntax-numeric-separator@^7.8.3": version "7.10.4" resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz#b9b070b3e33570cd9fd07ba7fa91c0dd37b9af97" integrity sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug== @@ -1189,425 +858,37 @@ "@babel/plugin-syntax-object-rest-spread@^7.8.3": version "7.8.3" resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz#60e225edcbd98a640332a2e72dd3e66f1af55871" - integrity sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA== - dependencies: - "@babel/helper-plugin-utils" "^7.8.0" - -"@babel/plugin-syntax-optional-catch-binding@^7.8.3": - version "7.8.3" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz#6111a265bcfb020eb9efd0fdfd7d26402b9ed6c1" - integrity sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q== - dependencies: - "@babel/helper-plugin-utils" "^7.8.0" - -"@babel/plugin-syntax-optional-chaining@^7.8.3": - version "7.8.3" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz#4f69c2ab95167e0180cd5336613f8c5788f7d48a" - integrity sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg== - dependencies: - "@babel/helper-plugin-utils" "^7.8.0" - -"@babel/plugin-syntax-private-property-in-object@^7.14.5": - version "7.14.5" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz#0dc6671ec0ea22b6e94a1114f857970cd39de1ad" - integrity sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg== - dependencies: - "@babel/helper-plugin-utils" "^7.14.5" - -"@babel/plugin-syntax-top-level-await@^7.14.5", "@babel/plugin-syntax-top-level-await@^7.8.3": - version "7.14.5" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz#c1cfdadc35a646240001f06138247b741c34d94c" - integrity sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw== - dependencies: - "@babel/helper-plugin-utils" "^7.14.5" - -"@babel/plugin-syntax-typescript@^7.7.2": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.18.6.tgz#1c09cd25795c7c2b8a4ba9ae49394576d4133285" - integrity sha512-mAWAuq4rvOepWCBid55JuRNvpTNf2UGVgoz4JV0fXEKolsVZDzsa4NqCef758WZJj/GDu0gVGItjKFiClTAmZA== - dependencies: - "@babel/helper-plugin-utils" "^7.18.6" - -"@babel/plugin-transform-arrow-functions@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.18.6.tgz#19063fcf8771ec7b31d742339dac62433d0611fe" - integrity sha512-9S9X9RUefzrsHZmKMbDXxweEH+YlE8JJEuat9FdvW9Qh1cw7W64jELCtWNkPBPX5En45uy28KGvA/AySqUh8CQ== - dependencies: - "@babel/helper-plugin-utils" "^7.18.6" - -"@babel/plugin-transform-async-to-generator@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.18.6.tgz#ccda3d1ab9d5ced5265fdb13f1882d5476c71615" - integrity sha512-ARE5wZLKnTgPW7/1ftQmSi1CmkqqHo2DNmtztFhvgtOWSDfq0Cq9/9L+KnZNYSNrydBekhW3rwShduf59RoXag== - dependencies: - "@babel/helper-module-imports" "^7.18.6" - "@babel/helper-plugin-utils" "^7.18.6" - "@babel/helper-remap-async-to-generator" "^7.18.6" - -"@babel/plugin-transform-block-scoped-functions@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.18.6.tgz#9187bf4ba302635b9d70d986ad70f038726216a8" - integrity sha512-ExUcOqpPWnliRcPqves5HJcJOvHvIIWfuS4sroBUenPuMdmW+SMHDakmtS7qOo13sVppmUijqeTv7qqGsvURpQ== - dependencies: - "@babel/helper-plugin-utils" "^7.18.6" - -"@babel/plugin-transform-block-scoping@^7.18.9": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.18.9.tgz#f9b7e018ac3f373c81452d6ada8bd5a18928926d" - integrity sha512-5sDIJRV1KtQVEbt/EIBwGy4T01uYIo4KRB3VUqzkhrAIOGx7AoctL9+Ux88btY0zXdDyPJ9mW+bg+v+XEkGmtw== - dependencies: - "@babel/helper-plugin-utils" "^7.18.9" - -"@babel/plugin-transform-classes@^7.18.9": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-classes/-/plugin-transform-classes-7.18.9.tgz#90818efc5b9746879b869d5ce83eb2aa48bbc3da" - integrity sha512-EkRQxsxoytpTlKJmSPYrsOMjCILacAjtSVkd4gChEe2kXjFCun3yohhW5I7plXJhCemM0gKsaGMcO8tinvCA5g== - dependencies: - "@babel/helper-annotate-as-pure" "^7.18.6" - "@babel/helper-environment-visitor" "^7.18.9" - "@babel/helper-function-name" "^7.18.9" - "@babel/helper-optimise-call-expression" "^7.18.6" - "@babel/helper-plugin-utils" "^7.18.9" - "@babel/helper-replace-supers" "^7.18.9" - "@babel/helper-split-export-declaration" "^7.18.6" - globals "^11.1.0" - -"@babel/plugin-transform-computed-properties@^7.18.9": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.18.9.tgz#2357a8224d402dad623caf6259b611e56aec746e" - integrity sha512-+i0ZU1bCDymKakLxn5srGHrsAPRELC2WIbzwjLhHW9SIE1cPYkLCL0NlnXMZaM1vhfgA2+M7hySk42VBvrkBRw== - dependencies: - "@babel/helper-plugin-utils" "^7.18.9" - -"@babel/plugin-transform-destructuring@^7.18.9": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.18.9.tgz#68906549c021cb231bee1db21d3b5b095f8ee292" - integrity sha512-p5VCYNddPLkZTq4XymQIaIfZNJwT9YsjkPOhkVEqt6QIpQFZVM9IltqqYpOEkJoN1DPznmxUDyZ5CTZs/ZCuHA== - dependencies: - "@babel/helper-plugin-utils" "^7.18.9" - -"@babel/plugin-transform-dotall-regex@^7.18.6", "@babel/plugin-transform-dotall-regex@^7.4.4": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.18.6.tgz#b286b3e7aae6c7b861e45bed0a2fafd6b1a4fef8" - integrity sha512-6S3jpun1eEbAxq7TdjLotAsl4WpQI9DxfkycRcKrjhQYzU87qpXdknpBg/e+TdcMehqGnLFi7tnFUBR02Vq6wg== - dependencies: - "@babel/helper-create-regexp-features-plugin" "^7.18.6" - "@babel/helper-plugin-utils" "^7.18.6" - -"@babel/plugin-transform-duplicate-keys@^7.18.9": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.18.9.tgz#687f15ee3cdad6d85191eb2a372c4528eaa0ae0e" - integrity sha512-d2bmXCtZXYc59/0SanQKbiWINadaJXqtvIQIzd4+hNwkWBgyCd5F/2t1kXoUdvPMrxzPvhK6EMQRROxsue+mfw== - dependencies: - "@babel/helper-plugin-utils" "^7.18.9" - -"@babel/plugin-transform-exponentiation-operator@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.18.6.tgz#421c705f4521888c65e91fdd1af951bfefd4dacd" - integrity sha512-wzEtc0+2c88FVR34aQmiz56dxEkxr2g8DQb/KfaFa1JYXOFVsbhvAonFN6PwVWj++fKmku8NP80plJ5Et4wqHw== - dependencies: - "@babel/helper-builder-binary-assignment-operator-visitor" "^7.18.6" - "@babel/helper-plugin-utils" "^7.18.6" - -"@babel/plugin-transform-flow-strip-types@^7.18.9": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-flow-strip-types/-/plugin-transform-flow-strip-types-7.18.9.tgz#5b4cc521426263b5ce08893a2db41097ceba35bf" - integrity sha512-+G6rp2zRuOAInY5wcggsx4+QVao1qPM0osC9fTUVlAV3zOrzTCnrMAFVnR6+a3T8wz1wFIH7KhYMcMB3u1n80A== - dependencies: - "@babel/helper-plugin-utils" "^7.18.9" - "@babel/plugin-syntax-flow" "^7.18.6" - -"@babel/plugin-transform-for-of@^7.18.8": - version "7.18.8" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.18.8.tgz#6ef8a50b244eb6a0bdbad0c7c61877e4e30097c1" - integrity sha512-yEfTRnjuskWYo0k1mHUqrVWaZwrdq8AYbfrpqULOJOaucGSp4mNMVps+YtA8byoevxS/urwU75vyhQIxcCgiBQ== - dependencies: - "@babel/helper-plugin-utils" "^7.18.6" - -"@babel/plugin-transform-function-name@^7.18.9": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.18.9.tgz#cc354f8234e62968946c61a46d6365440fc764e0" - integrity sha512-WvIBoRPaJQ5yVHzcnJFor7oS5Ls0PYixlTYE63lCj2RtdQEl15M68FXQlxnG6wdraJIXRdR7KI+hQ7q/9QjrCQ== - dependencies: - "@babel/helper-compilation-targets" "^7.18.9" - "@babel/helper-function-name" "^7.18.9" - "@babel/helper-plugin-utils" "^7.18.9" - -"@babel/plugin-transform-literals@^7.18.9": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-literals/-/plugin-transform-literals-7.18.9.tgz#72796fdbef80e56fba3c6a699d54f0de557444bc" - integrity sha512-IFQDSRoTPnrAIrI5zoZv73IFeZu2dhu6irxQjY9rNjTT53VmKg9fenjvoiOWOkJ6mm4jKVPtdMzBY98Fp4Z4cg== - dependencies: - "@babel/helper-plugin-utils" "^7.18.9" - -"@babel/plugin-transform-member-expression-literals@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.18.6.tgz#ac9fdc1a118620ac49b7e7a5d2dc177a1bfee88e" - integrity sha512-qSF1ihLGO3q+/g48k85tUjD033C29TNTVB2paCwZPVmOsjn9pClvYYrM2VeJpBY2bcNkuny0YUyTNRyRxJ54KA== - dependencies: - "@babel/helper-plugin-utils" "^7.18.6" - -"@babel/plugin-transform-modules-amd@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.18.6.tgz#8c91f8c5115d2202f277549848874027d7172d21" - integrity sha512-Pra5aXsmTsOnjM3IajS8rTaLCy++nGM4v3YR4esk5PCsyg9z8NA5oQLwxzMUtDBd8F+UmVza3VxoAaWCbzH1rg== - dependencies: - "@babel/helper-module-transforms" "^7.18.6" - "@babel/helper-plugin-utils" "^7.18.6" - babel-plugin-dynamic-import-node "^2.3.3" - -"@babel/plugin-transform-modules-commonjs@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.18.6.tgz#afd243afba166cca69892e24a8fd8c9f2ca87883" - integrity sha512-Qfv2ZOWikpvmedXQJDSbxNqy7Xr/j2Y8/KfijM0iJyKkBTmWuvCA1yeH1yDM7NJhBW/2aXxeucLj6i80/LAJ/Q== - dependencies: - "@babel/helper-module-transforms" "^7.18.6" - "@babel/helper-plugin-utils" "^7.18.6" - "@babel/helper-simple-access" "^7.18.6" - babel-plugin-dynamic-import-node "^2.3.3" - -"@babel/plugin-transform-modules-systemjs@^7.18.9": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.18.9.tgz#545df284a7ac6a05125e3e405e536c5853099a06" - integrity sha512-zY/VSIbbqtoRoJKo2cDTewL364jSlZGvn0LKOf9ntbfxOvjfmyrdtEEOAdswOswhZEb8UH3jDkCKHd1sPgsS0A== - dependencies: - "@babel/helper-hoist-variables" "^7.18.6" - "@babel/helper-module-transforms" "^7.18.9" - "@babel/helper-plugin-utils" "^7.18.9" - "@babel/helper-validator-identifier" "^7.18.6" - babel-plugin-dynamic-import-node "^2.3.3" - -"@babel/plugin-transform-modules-umd@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.18.6.tgz#81d3832d6034b75b54e62821ba58f28ed0aab4b9" - integrity sha512-dcegErExVeXcRqNtkRU/z8WlBLnvD4MRnHgNs3MytRO1Mn1sHRyhbcpYbVMGclAqOjdW+9cfkdZno9dFdfKLfQ== - dependencies: - "@babel/helper-module-transforms" "^7.18.6" - "@babel/helper-plugin-utils" "^7.18.6" - -"@babel/plugin-transform-named-capturing-groups-regex@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.18.6.tgz#c89bfbc7cc6805d692f3a49bc5fc1b630007246d" - integrity sha512-UmEOGF8XgaIqD74bC8g7iV3RYj8lMf0Bw7NJzvnS9qQhM4mg+1WHKotUIdjxgD2RGrgFLZZPCFPFj3P/kVDYhg== - dependencies: - "@babel/helper-create-regexp-features-plugin" "^7.18.6" - "@babel/helper-plugin-utils" "^7.18.6" - -"@babel/plugin-transform-new-target@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.18.6.tgz#d128f376ae200477f37c4ddfcc722a8a1b3246a8" - integrity sha512-DjwFA/9Iu3Z+vrAn+8pBUGcjhxKguSMlsFqeCKbhb9BAV756v0krzVK04CRDi/4aqmk8BsHb4a/gFcaA5joXRw== - dependencies: - "@babel/helper-plugin-utils" "^7.18.6" - -"@babel/plugin-transform-object-super@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.18.6.tgz#fb3c6ccdd15939b6ff7939944b51971ddc35912c" - integrity sha512-uvGz6zk+pZoS1aTZrOvrbj6Pp/kK2mp45t2B+bTDre2UgsZZ8EZLSJtUg7m/no0zOJUWgFONpB7Zv9W2tSaFlA== - dependencies: - "@babel/helper-plugin-utils" "^7.18.6" - "@babel/helper-replace-supers" "^7.18.6" - -"@babel/plugin-transform-parameters@^7.18.8": - version "7.18.8" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.18.8.tgz#ee9f1a0ce6d78af58d0956a9378ea3427cccb48a" - integrity sha512-ivfbE3X2Ss+Fj8nnXvKJS6sjRG4gzwPMsP+taZC+ZzEGjAYlvENixmt1sZ5Ca6tWls+BlKSGKPJ6OOXvXCbkFg== - dependencies: - "@babel/helper-plugin-utils" "^7.18.6" - -"@babel/plugin-transform-property-literals@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.18.6.tgz#e22498903a483448e94e032e9bbb9c5ccbfc93a3" - integrity sha512-cYcs6qlgafTud3PAzrrRNbQtfpQ8+y/+M5tKmksS9+M1ckbH6kzY8MrexEM9mcA6JDsukE19iIRvAyYl463sMg== - dependencies: - "@babel/helper-plugin-utils" "^7.18.6" - -"@babel/plugin-transform-react-jsx@^7.18.10": - version "7.18.10" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.18.10.tgz#ea47b2c4197102c196cbd10db9b3bb20daa820f1" - integrity sha512-gCy7Iikrpu3IZjYZolFE4M1Sm+nrh1/6za2Ewj77Z+XirT4TsbJcvOFOyF+fRPwU6AKKK136CZxx6L8AbSFG6A== - dependencies: - "@babel/helper-annotate-as-pure" "^7.18.6" - "@babel/helper-module-imports" "^7.18.6" - "@babel/helper-plugin-utils" "^7.18.9" - "@babel/plugin-syntax-jsx" "^7.18.6" - "@babel/types" "^7.18.10" - -"@babel/plugin-transform-regenerator@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.18.6.tgz#585c66cb84d4b4bf72519a34cfce761b8676ca73" - integrity sha512-poqRI2+qiSdeldcz4wTSTXBRryoq3Gc70ye7m7UD5Ww0nE29IXqMl6r7Nd15WBgRd74vloEMlShtH6CKxVzfmQ== - dependencies: - "@babel/helper-plugin-utils" "^7.18.6" - regenerator-transform "^0.15.0" - -"@babel/plugin-transform-reserved-words@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.18.6.tgz#b1abd8ebf8edaa5f7fe6bbb8d2133d23b6a6f76a" - integrity sha512-oX/4MyMoypzHjFrT1CdivfKZ+XvIPMFXwwxHp/r0Ddy2Vuomt4HDFGmft1TAY2yiTKiNSsh3kjBAzcM8kSdsjA== - dependencies: - "@babel/helper-plugin-utils" "^7.18.6" - -"@babel/plugin-transform-shorthand-properties@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.18.6.tgz#6d6df7983d67b195289be24909e3f12a8f664dc9" - integrity sha512-eCLXXJqv8okzg86ywZJbRn19YJHU4XUa55oz2wbHhaQVn/MM+XhukiT7SYqp/7o00dg52Rj51Ny+Ecw4oyoygw== - dependencies: - "@babel/helper-plugin-utils" "^7.18.6" - -"@babel/plugin-transform-spread@^7.18.9": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-spread/-/plugin-transform-spread-7.18.9.tgz#6ea7a6297740f381c540ac56caf75b05b74fb664" - integrity sha512-39Q814wyoOPtIB/qGopNIL9xDChOE1pNU0ZY5dO0owhiVt/5kFm4li+/bBtwc7QotG0u5EPzqhZdjMtmqBqyQA== - dependencies: - "@babel/helper-plugin-utils" "^7.18.9" - "@babel/helper-skip-transparent-expression-wrappers" "^7.18.9" - -"@babel/plugin-transform-sticky-regex@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.18.6.tgz#c6706eb2b1524028e317720339583ad0f444adcc" - integrity sha512-kfiDrDQ+PBsQDO85yj1icueWMfGfJFKN1KCkndygtu/C9+XUfydLC8Iv5UYJqRwy4zk8EcplRxEOeLyjq1gm6Q== - dependencies: - "@babel/helper-plugin-utils" "^7.18.6" - -"@babel/plugin-transform-template-literals@^7.18.9": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.18.9.tgz#04ec6f10acdaa81846689d63fae117dd9c243a5e" - integrity sha512-S8cOWfT82gTezpYOiVaGHrCbhlHgKhQt8XH5ES46P2XWmX92yisoZywf5km75wv5sYcXDUCLMmMxOLCtthDgMA== - dependencies: - "@babel/helper-plugin-utils" "^7.18.9" - -"@babel/plugin-transform-typeof-symbol@^7.18.9": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.18.9.tgz#c8cea68263e45addcd6afc9091429f80925762c0" - integrity sha512-SRfwTtF11G2aemAZWivL7PD+C9z52v9EvMqH9BuYbabyPuKUvSWks3oCg6041pT925L4zVFqaVBeECwsmlguEw== - dependencies: - "@babel/helper-plugin-utils" "^7.18.9" - -"@babel/plugin-transform-unicode-escapes@^7.18.10": - version "7.18.10" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.18.10.tgz#1ecfb0eda83d09bbcb77c09970c2dd55832aa246" - integrity sha512-kKAdAI+YzPgGY/ftStBFXTI1LZFju38rYThnfMykS+IXy8BVx+res7s2fxf1l8I35DV2T97ezo6+SGrXz6B3iQ== - dependencies: - "@babel/helper-plugin-utils" "^7.18.9" - -"@babel/plugin-transform-unicode-regex@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.18.6.tgz#194317225d8c201bbae103364ffe9e2cea36cdca" - integrity sha512-gE7A6Lt7YLnNOL3Pb9BNeZvi+d8l7tcRrG4+pwJjK9hD2xX4mEvjlQW60G9EEmfXVYRPv9VRQcyegIVHCql/AA== + integrity sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA== dependencies: - "@babel/helper-create-regexp-features-plugin" "^7.18.6" - "@babel/helper-plugin-utils" "^7.18.6" + "@babel/helper-plugin-utils" "^7.8.0" -"@babel/preset-env@^7.18.10": - version "7.18.10" - resolved "https://registry.yarnpkg.com/@babel/preset-env/-/preset-env-7.18.10.tgz#83b8dfe70d7eea1aae5a10635ab0a5fe60dfc0f4" - integrity sha512-wVxs1yjFdW3Z/XkNfXKoblxoHgbtUF7/l3PvvP4m02Qz9TZ6uZGxRVYjSQeR87oQmHco9zWitW5J82DJ7sCjvA== +"@babel/plugin-syntax-optional-catch-binding@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz#6111a265bcfb020eb9efd0fdfd7d26402b9ed6c1" + integrity sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q== dependencies: - "@babel/compat-data" "^7.18.8" - "@babel/helper-compilation-targets" "^7.18.9" - "@babel/helper-plugin-utils" "^7.18.9" - "@babel/helper-validator-option" "^7.18.6" - "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression" "^7.18.6" - "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining" "^7.18.9" - "@babel/plugin-proposal-async-generator-functions" "^7.18.10" - "@babel/plugin-proposal-class-properties" "^7.18.6" - "@babel/plugin-proposal-class-static-block" "^7.18.6" - "@babel/plugin-proposal-dynamic-import" "^7.18.6" - "@babel/plugin-proposal-export-namespace-from" "^7.18.9" - "@babel/plugin-proposal-json-strings" "^7.18.6" - "@babel/plugin-proposal-logical-assignment-operators" "^7.18.9" - "@babel/plugin-proposal-nullish-coalescing-operator" "^7.18.6" - "@babel/plugin-proposal-numeric-separator" "^7.18.6" - "@babel/plugin-proposal-object-rest-spread" "^7.18.9" - "@babel/plugin-proposal-optional-catch-binding" "^7.18.6" - "@babel/plugin-proposal-optional-chaining" "^7.18.9" - "@babel/plugin-proposal-private-methods" "^7.18.6" - "@babel/plugin-proposal-private-property-in-object" "^7.18.6" - "@babel/plugin-proposal-unicode-property-regex" "^7.18.6" - "@babel/plugin-syntax-async-generators" "^7.8.4" - "@babel/plugin-syntax-class-properties" "^7.12.13" - "@babel/plugin-syntax-class-static-block" "^7.14.5" - "@babel/plugin-syntax-dynamic-import" "^7.8.3" - "@babel/plugin-syntax-export-namespace-from" "^7.8.3" - "@babel/plugin-syntax-import-assertions" "^7.18.6" - "@babel/plugin-syntax-json-strings" "^7.8.3" - "@babel/plugin-syntax-logical-assignment-operators" "^7.10.4" - "@babel/plugin-syntax-nullish-coalescing-operator" "^7.8.3" - "@babel/plugin-syntax-numeric-separator" "^7.10.4" - "@babel/plugin-syntax-object-rest-spread" "^7.8.3" - "@babel/plugin-syntax-optional-catch-binding" "^7.8.3" - "@babel/plugin-syntax-optional-chaining" "^7.8.3" - "@babel/plugin-syntax-private-property-in-object" "^7.14.5" - "@babel/plugin-syntax-top-level-await" "^7.14.5" - "@babel/plugin-transform-arrow-functions" "^7.18.6" - "@babel/plugin-transform-async-to-generator" "^7.18.6" - "@babel/plugin-transform-block-scoped-functions" "^7.18.6" - "@babel/plugin-transform-block-scoping" "^7.18.9" - "@babel/plugin-transform-classes" "^7.18.9" - "@babel/plugin-transform-computed-properties" "^7.18.9" - "@babel/plugin-transform-destructuring" "^7.18.9" - "@babel/plugin-transform-dotall-regex" "^7.18.6" - "@babel/plugin-transform-duplicate-keys" "^7.18.9" - "@babel/plugin-transform-exponentiation-operator" "^7.18.6" - "@babel/plugin-transform-for-of" "^7.18.8" - "@babel/plugin-transform-function-name" "^7.18.9" - "@babel/plugin-transform-literals" "^7.18.9" - "@babel/plugin-transform-member-expression-literals" "^7.18.6" - "@babel/plugin-transform-modules-amd" "^7.18.6" - "@babel/plugin-transform-modules-commonjs" "^7.18.6" - "@babel/plugin-transform-modules-systemjs" "^7.18.9" - "@babel/plugin-transform-modules-umd" "^7.18.6" - "@babel/plugin-transform-named-capturing-groups-regex" "^7.18.6" - "@babel/plugin-transform-new-target" "^7.18.6" - "@babel/plugin-transform-object-super" "^7.18.6" - "@babel/plugin-transform-parameters" "^7.18.8" - "@babel/plugin-transform-property-literals" "^7.18.6" - "@babel/plugin-transform-regenerator" "^7.18.6" - "@babel/plugin-transform-reserved-words" "^7.18.6" - "@babel/plugin-transform-shorthand-properties" "^7.18.6" - "@babel/plugin-transform-spread" "^7.18.9" - "@babel/plugin-transform-sticky-regex" "^7.18.6" - "@babel/plugin-transform-template-literals" "^7.18.9" - "@babel/plugin-transform-typeof-symbol" "^7.18.9" - "@babel/plugin-transform-unicode-escapes" "^7.18.10" - "@babel/plugin-transform-unicode-regex" "^7.18.6" - "@babel/preset-modules" "^0.1.5" - "@babel/types" "^7.18.10" - babel-plugin-polyfill-corejs2 "^0.3.2" - babel-plugin-polyfill-corejs3 "^0.5.3" - babel-plugin-polyfill-regenerator "^0.4.0" - core-js-compat "^3.22.1" - semver "^6.3.0" + "@babel/helper-plugin-utils" "^7.8.0" -"@babel/preset-modules@^0.1.5": - version "0.1.5" - resolved "https://registry.yarnpkg.com/@babel/preset-modules/-/preset-modules-0.1.5.tgz#ef939d6e7f268827e1841638dc6ff95515e115d9" - integrity sha512-A57th6YRG7oR3cq/yt/Y84MvGgE0eJG2F1JLhKuyG+jFxEgrd/HAMJatiFtmOiZurz+0DkrvbheCLaV5f2JfjA== +"@babel/plugin-syntax-optional-chaining@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz#4f69c2ab95167e0180cd5336613f8c5788f7d48a" + integrity sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg== dependencies: - "@babel/helper-plugin-utils" "^7.0.0" - "@babel/plugin-proposal-unicode-property-regex" "^7.4.4" - "@babel/plugin-transform-dotall-regex" "^7.4.4" - "@babel/types" "^7.4.4" - esutils "^2.0.2" + "@babel/helper-plugin-utils" "^7.8.0" -"@babel/register@^7.18.9": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/register/-/register-7.18.9.tgz#1888b24bc28d5cc41c412feb015e9ff6b96e439c" - integrity sha512-ZlbnXDcNYHMR25ITwwNKT88JiaukkdVj/nG7r3wnuXkOTHc60Uy05PwMCPre0hSkY68E6zK3xz+vUJSP2jWmcw== +"@babel/plugin-syntax-top-level-await@^7.8.3": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz#c1cfdadc35a646240001f06138247b741c34d94c" + integrity sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw== dependencies: - clone-deep "^4.0.1" - find-cache-dir "^2.0.0" - make-dir "^2.1.0" - pirates "^4.0.5" - source-map-support "^0.5.16" + "@babel/helper-plugin-utils" "^7.14.5" -"@babel/runtime@^7.8.4": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.18.9.tgz#b4fcfce55db3d2e5e080d2490f608a3b9f407f4a" - integrity sha512-lkqXDcvlFT5rvEjiu6+QYO+1GXrEHRo2LOtS7E4GtX5ESIZOgepqsZBVIj6Pv+a6zqsya9VCgiK1KAK4BvJDAw== +"@babel/plugin-syntax-typescript@^7.7.2": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.18.6.tgz#1c09cd25795c7c2b8a4ba9ae49394576d4133285" + integrity sha512-mAWAuq4rvOepWCBid55JuRNvpTNf2UGVgoz4JV0fXEKolsVZDzsa4NqCef758WZJj/GDu0gVGItjKFiClTAmZA== dependencies: - regenerator-runtime "^0.13.4" + "@babel/helper-plugin-utils" "^7.18.6" "@babel/template@^7.18.10", "@babel/template@^7.18.6", "@babel/template@^7.3.3": version "7.18.10" @@ -1618,7 +899,7 @@ "@babel/parser" "^7.18.10" "@babel/types" "^7.18.10" -"@babel/traverse@^7.18.10", "@babel/traverse@^7.18.11", "@babel/traverse@^7.18.9", "@babel/traverse@^7.7.2": +"@babel/traverse@^7.18.10", "@babel/traverse@^7.18.9", "@babel/traverse@^7.7.2": version "7.18.11" resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.18.11.tgz#3d51f2afbd83ecf9912bcbb5c4d94e3d2ddaa16f" integrity sha512-TG9PiM2R/cWCAy6BPJKeHzNbu4lPzOSZpeMfeNErskGpTJx6trEvFaVCbDvpcxwy49BKWmEPwiW8mrysNiDvIQ== @@ -1634,7 +915,7 @@ debug "^4.1.0" globals "^11.1.0" -"@babel/types@^7.0.0", "@babel/types@^7.18.10", "@babel/types@^7.18.6", "@babel/types@^7.18.9", "@babel/types@^7.3.0", "@babel/types@^7.3.3", "@babel/types@^7.4.4": +"@babel/types@^7.0.0", "@babel/types@^7.18.10", "@babel/types@^7.18.6", "@babel/types@^7.18.9", "@babel/types@^7.3.0", "@babel/types@^7.3.3": version "7.18.10" resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.18.10.tgz#4908e81b6b339ca7c6b7a555a5fc29446f26dde6" integrity sha512-MJvnbEiiNkpjo+LknnmRrqbY1GPUUggjv+wQVjetM/AONoupqRALB7I6jGqNUAZsKcRIEu2J6FRFvsczljjsaQ== @@ -1661,6 +942,13 @@ eslint-config-airbnb "^19.0.4" eslint-config-airbnb-base "^15.0.0" +"@cspotcode/source-map-support@^0.8.0": + version "0.8.1" + resolved "https://registry.yarnpkg.com/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz#00629c35a688e05a88b1cda684fb9d5e73f000a1" + integrity sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw== + dependencies: + "@jridgewell/trace-mapping" "0.3.9" + "@dabh/diagnostics@^2.0.2": version "2.0.3" resolved "https://registry.yarnpkg.com/@dabh/diagnostics/-/diagnostics-2.0.3.tgz#7f7e97ee9a725dffc7808d93668cc984e1dc477a" @@ -1734,6 +1022,13 @@ js-yaml "^3.13.1" resolve-from "^5.0.0" +"@istanbuljs/nyc-config-typescript@^1.0.2": + version "1.0.2" + resolved "https://registry.yarnpkg.com/@istanbuljs/nyc-config-typescript/-/nyc-config-typescript-1.0.2.tgz#1f5235b28540a07219ae0dd42014912a0b19cf89" + integrity sha512-iKGIyMoyJuFnJRSVTZ78POIRvNnwZaWIf8vG4ZS3rQq58MMDrqEX2nnzx0R28V2X8JvmKYiqY9FP2hlJsm8A0w== + dependencies: + "@istanbuljs/schema" "^0.1.2" + "@istanbuljs/schema@^0.1.2": version "0.1.3" resolved "https://registry.yarnpkg.com/@istanbuljs/schema/-/schema-0.1.3.tgz#e45e384e4b8ec16bce2fd903af78450f6bf7ec98" @@ -1964,7 +1259,15 @@ resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz#add4c98d341472a289190b424efbdb096991bb24" integrity sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw== -"@jridgewell/trace-mapping@^0.3.12", "@jridgewell/trace-mapping@^0.3.13", "@jridgewell/trace-mapping@^0.3.8", "@jridgewell/trace-mapping@^0.3.9": +"@jridgewell/trace-mapping@0.3.9": + version "0.3.9" + resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz#6534fd5933a53ba7cbf3a17615e273a0d1273ff9" + integrity sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ== + dependencies: + "@jridgewell/resolve-uri" "^3.0.3" + "@jridgewell/sourcemap-codec" "^1.4.10" + +"@jridgewell/trace-mapping@^0.3.12", "@jridgewell/trace-mapping@^0.3.13", "@jridgewell/trace-mapping@^0.3.9": version "0.3.15" resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.15.tgz#aba35c48a38d3fd84b37e66c9c0423f9744f9774" integrity sha512-oWZNOULl+UbhsgB51uuZzglikfIKSUBO/M9W2OfEjn7cmqoAiCgmv9lyACTUacZwBz0ITnJ2NqjU8Tx0DHL88g== @@ -1972,11 +1275,6 @@ "@jridgewell/resolve-uri" "^3.0.3" "@jridgewell/sourcemap-codec" "^1.4.10" -"@nicolo-ribaudo/chokidar-2@2.1.8-no-fsevents.3": - version "2.1.8-no-fsevents.3" - resolved "https://registry.yarnpkg.com/@nicolo-ribaudo/chokidar-2/-/chokidar-2-2.1.8-no-fsevents.3.tgz#323d72dd25103d0c4fbdce89dadf574a787b1f9b" - integrity sha512-s88O1aVtXftvp5bCPB7WnmXc5IwOZZ7YPuwNPt+GtOOXpPvad1LfbmjYv+qII7zP6RU2QGnqve27dnLycEnyEQ== - "@nodelib/fs.scandir@2.1.5": version "2.1.5" resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5" @@ -2430,6 +1728,26 @@ resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-2.0.0.tgz#f544a148d3ab35801c1f633a7441fd87c2e484bf" integrity sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A== +"@tsconfig/node10@^1.0.7": + version "1.0.9" + resolved "https://registry.yarnpkg.com/@tsconfig/node10/-/node10-1.0.9.tgz#df4907fc07a886922637b15e02d4cebc4c0021b2" + integrity sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA== + +"@tsconfig/node12@^1.0.7": + version "1.0.11" + resolved "https://registry.yarnpkg.com/@tsconfig/node12/-/node12-1.0.11.tgz#ee3def1f27d9ed66dac6e46a295cffb0152e058d" + integrity sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag== + +"@tsconfig/node14@^1.0.0": + version "1.0.3" + resolved "https://registry.yarnpkg.com/@tsconfig/node14/-/node14-1.0.3.tgz#e4386316284f00b98435bf40f72f75a09dabf6c1" + integrity sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow== + +"@tsconfig/node16@^1.0.2": + version "1.0.3" + resolved "https://registry.yarnpkg.com/@tsconfig/node16/-/node16-1.0.3.tgz#472eaab5f15c1ffdd7f8628bd4c4f753995ec79e" + integrity sha512-yOlFc+7UtL/89t2ZhjPvvB/DeAr3r+Dq58IgzsFkOAvVC6NMJXmCGjbptdXdR9qsX7pKcTL+s87FtYREi2dEEQ== + "@types/babel__core@^7.1.14": version "7.1.19" resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.1.19.tgz#7b497495b7d1b4812bdb9d02804d0576f43ee460" @@ -2497,6 +1815,11 @@ jest-matcher-utils "^28.0.0" pretty-format "^28.0.0" +"@types/json-schema@^7.0.9": + version "7.0.11" + resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.11.tgz#d421b6c527a3037f7c84433fd2c4229e016863d3" + integrity sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ== + "@types/json5@^0.0.29": version "0.0.29" resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee" @@ -2512,6 +1835,11 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-18.7.3.tgz#432c89796eab539b7a30b7b8801a727b585238a4" integrity sha512-LJgzOEwWuMTBxHzgBR/fhhBOWrvBjvO+zPteUgbbuQi80rYIZHrk1mNbRUqPZqSLP2H7Rwt1EFLL/tNLD1Xx/w== +"@types/node@14": + version "14.18.23" + resolved "https://registry.yarnpkg.com/@types/node/-/node-14.18.23.tgz#70f5f20b0b1b38f696848c1d3647bb95694e615e" + integrity sha512-MhbCWN18R4GhO8ewQWAFK4TGQdBpXWByukz7cWyJmXhvRuCIaM/oWytGPqVmDzgEnnaIc9ss6HbU5mUi+vyZPA== + "@types/normalize-package-data@^2.4.0": version "2.4.1" resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.1.tgz#d3357479a0fdfdd5907fe67e17e0a85c906e1301" @@ -2549,6 +1877,86 @@ dependencies: "@types/yargs-parser" "*" +"@typescript-eslint/eslint-plugin@^5.33.0": + version "5.33.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.33.0.tgz#059798888720ec52ffa96c5f868e31a8f70fa3ec" + integrity sha512-jHvZNSW2WZ31OPJ3enhLrEKvAZNyAFWZ6rx9tUwaessTc4sx9KmgMNhVcqVAl1ETnT5rU5fpXTLmY9YvC1DCNg== + dependencies: + "@typescript-eslint/scope-manager" "5.33.0" + "@typescript-eslint/type-utils" "5.33.0" + "@typescript-eslint/utils" "5.33.0" + debug "^4.3.4" + functional-red-black-tree "^1.0.1" + ignore "^5.2.0" + regexpp "^3.2.0" + semver "^7.3.7" + tsutils "^3.21.0" + +"@typescript-eslint/parser@^5.33.0": + version "5.33.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-5.33.0.tgz#26ec3235b74f0667414613727cb98f9b69dc5383" + integrity sha512-cgM5cJrWmrDV2KpvlcSkelTBASAs1mgqq+IUGKJvFxWrapHpaRy5EXPQz9YaKF3nZ8KY18ILTiVpUtbIac86/w== + dependencies: + "@typescript-eslint/scope-manager" "5.33.0" + "@typescript-eslint/types" "5.33.0" + "@typescript-eslint/typescript-estree" "5.33.0" + debug "^4.3.4" + +"@typescript-eslint/scope-manager@5.33.0": + version "5.33.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-5.33.0.tgz#509d7fa540a2c58f66bdcfcf278a3fa79002e18d" + integrity sha512-/Jta8yMNpXYpRDl8EwF/M8It2A9sFJTubDo0ATZefGXmOqlaBffEw0ZbkbQ7TNDK6q55NPHFshGBPAZvZkE8Pw== + dependencies: + "@typescript-eslint/types" "5.33.0" + "@typescript-eslint/visitor-keys" "5.33.0" + +"@typescript-eslint/type-utils@5.33.0": + version "5.33.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-5.33.0.tgz#92ad1fba973c078d23767ce2d8d5a601baaa9338" + integrity sha512-2zB8uEn7hEH2pBeyk3NpzX1p3lF9dKrEbnXq1F7YkpZ6hlyqb2yZujqgRGqXgRBTHWIUG3NGx/WeZk224UKlIA== + dependencies: + "@typescript-eslint/utils" "5.33.0" + debug "^4.3.4" + tsutils "^3.21.0" + +"@typescript-eslint/types@5.33.0": + version "5.33.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.33.0.tgz#d41c584831805554b063791338b0220b613a275b" + integrity sha512-nIMt96JngB4MYFYXpZ/3ZNU4GWPNdBbcB5w2rDOCpXOVUkhtNlG2mmm8uXhubhidRZdwMaMBap7Uk8SZMU/ppw== + +"@typescript-eslint/typescript-estree@5.33.0": + version "5.33.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.33.0.tgz#02d9c9ade6f4897c09e3508c27de53ad6bfa54cf" + integrity sha512-tqq3MRLlggkJKJUrzM6wltk8NckKyyorCSGMq4eVkyL5sDYzJJcMgZATqmF8fLdsWrW7OjjIZ1m9v81vKcaqwQ== + dependencies: + "@typescript-eslint/types" "5.33.0" + "@typescript-eslint/visitor-keys" "5.33.0" + debug "^4.3.4" + globby "^11.1.0" + is-glob "^4.0.3" + semver "^7.3.7" + tsutils "^3.21.0" + +"@typescript-eslint/utils@5.33.0": + version "5.33.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-5.33.0.tgz#46797461ce3146e21c095d79518cc0f8ec574038" + integrity sha512-JxOAnXt9oZjXLIiXb5ZIcZXiwVHCkqZgof0O8KPgz7C7y0HS42gi75PdPlqh1Tf109M0fyUw45Ao6JLo7S5AHw== + dependencies: + "@types/json-schema" "^7.0.9" + "@typescript-eslint/scope-manager" "5.33.0" + "@typescript-eslint/types" "5.33.0" + "@typescript-eslint/typescript-estree" "5.33.0" + eslint-scope "^5.1.1" + eslint-utils "^3.0.0" + +"@typescript-eslint/visitor-keys@5.33.0": + version "5.33.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.33.0.tgz#fbcbb074e460c11046e067bc3384b5d66b555484" + integrity sha512-/XsqCzD4t+Y9p5wd9HZiptuGKBlaZO5showwqODii5C0nZawxWLF+Q6k5wYHBrQv96h6GYKyqqMHCSTqta8Kiw== + dependencies: + "@typescript-eslint/types" "5.33.0" + eslint-visitor-keys "^3.3.0" + JSONStream@^1.0.4: version "1.3.5" resolved "https://registry.yarnpkg.com/JSONStream/-/JSONStream-1.3.5.tgz#3208c1f08d3a4d99261ab64f92302bc15e111ca0" @@ -2567,7 +1975,12 @@ acorn-jsx@^5.3.2: resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937" integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== -acorn@^8.8.0: +acorn-walk@^8.1.1: + version "8.2.0" + resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.2.0.tgz#741210f2e2426454508853a2f44d0ab83b7f69c1" + integrity sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA== + +acorn@^8.4.1, acorn@^8.8.0: version "8.8.0" resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.8.0.tgz#88c0187620435c7f6015803f5539dae05a9dbea8" integrity sha512-QOxyigPVrpZ2GXT+PFyZTl6TtOFc5egxHIP9IlQ+RbupQuX4RkT/Bee4/kQuC02Xkzg84JcT7oLYtDIQxp+v7w== @@ -2654,7 +2067,7 @@ ansicolors@~0.3.2: resolved "https://registry.yarnpkg.com/ansicolors/-/ansicolors-0.3.2.tgz#665597de86a9ffe3aa9bfbe6cae5c6ea426b4979" integrity sha512-QXu7BPrP29VllRxH8GwB7x5iX5qWKAAMLqKQGWTeLWVlNHNOpVMJ91dsxQAIWXpjuW5wqvxu3Jd/nRjrJ+0pqg== -anymatch@^3.0.3, anymatch@~3.1.2: +anymatch@^3.0.3: version "3.1.2" resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.2.tgz#c0557c096af32f106198f4f4e2a383537e378716" integrity sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg== @@ -2687,6 +2100,11 @@ are-we-there-yet@^3.0.0: delegates "^1.0.0" readable-stream "^3.6.0" +arg@^4.1.0: + version "4.1.3" + resolved "https://registry.yarnpkg.com/arg/-/arg-4.1.3.tgz#269fc7ad5b8e42cb63c896d5666017261c144089" + integrity sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA== + argparse@^1.0.7: version "1.0.10" resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911" @@ -2735,17 +2153,6 @@ array.prototype.flat@^1.2.5: es-abstract "^1.19.2" es-shim-unscopables "^1.0.0" -array.prototype.reduce@^1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/array.prototype.reduce/-/array.prototype.reduce-1.0.4.tgz#8167e80089f78bff70a99e20bd4201d4663b0a6f" - integrity sha512-WnM+AjG/DvLRLo4DDl+r+SvCzYtD2Jd9oeBYMcEaI7t3fFrHY9M53/wdLcTvmZNQ70IU6Htj0emFkZ5TS+lrdw== - dependencies: - call-bind "^1.0.2" - define-properties "^1.1.3" - es-abstract "^1.19.2" - es-array-method-boxes-properly "^1.0.0" - is-string "^1.0.7" - arrify@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/arrify/-/arrify-1.0.1.tgz#898508da2226f380df904728456849c1501a4b0d" @@ -2815,13 +2222,6 @@ babel-jest@^28.1.3: graceful-fs "^4.2.9" slash "^3.0.0" -babel-plugin-dynamic-import-node@^2.3.3: - version "2.3.3" - resolved "https://registry.yarnpkg.com/babel-plugin-dynamic-import-node/-/babel-plugin-dynamic-import-node-2.3.3.tgz#84fda19c976ec5c6defef57f9427b3def66e17a3" - integrity sha512-jZVI+s9Zg3IqA/kdi0i6UDCybUI3aSBLnglhYbSSjKlV7yF1F/5LWv8MakQmvYpnbJDS6fcBL2KzHSxNCMtWSQ== - dependencies: - object.assign "^4.1.0" - babel-plugin-istanbul@^6.1.1: version "6.1.1" resolved "https://registry.yarnpkg.com/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz#fa88ec59232fd9b4e36dbbc540a8ec9a9b47da73" @@ -2843,30 +2243,6 @@ babel-plugin-jest-hoist@^28.1.3: "@types/babel__core" "^7.1.14" "@types/babel__traverse" "^7.0.6" -babel-plugin-polyfill-corejs2@^0.3.2: - version "0.3.2" - resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.3.2.tgz#e4c31d4c89b56f3cf85b92558954c66b54bd972d" - integrity sha512-LPnodUl3lS0/4wN3Rb+m+UK8s7lj2jcLRrjho4gLw+OJs+I4bvGXshINesY5xx/apM+biTnQ9reDI8yj+0M5+Q== - dependencies: - "@babel/compat-data" "^7.17.7" - "@babel/helper-define-polyfill-provider" "^0.3.2" - semver "^6.1.1" - -babel-plugin-polyfill-corejs3@^0.5.3: - version "0.5.3" - resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.5.3.tgz#d7e09c9a899079d71a8b670c6181af56ec19c5c7" - integrity sha512-zKsXDh0XjnrUEW0mxIHLfjBfnXSMr5Q/goMe/fxpQnLm07mcOZiIZHBNWCMx60HmdvjxfXcalac0tfFg0wqxyw== - dependencies: - "@babel/helper-define-polyfill-provider" "^0.3.2" - core-js-compat "^3.21.0" - -babel-plugin-polyfill-regenerator@^0.4.0: - version "0.4.0" - resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.4.0.tgz#8f51809b6d5883e07e71548d75966ff7635527fe" - integrity sha512-RW1cnryiADFeHmfLS+WW/G431p1PsW5qdRdz0SDRi7TKcUgc7Oh/uXkT7MZ/+tGsT1BkczEAmD5XjUyJ5SWDTw== - dependencies: - "@babel/helper-define-polyfill-provider" "^0.3.2" - babel-preset-current-node-syntax@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.0.1.tgz#b4399239b89b2a011f9ddbe3e4f401fc40cff73b" @@ -2920,7 +2296,7 @@ bin-links@^3.0.0: rimraf "^3.0.0" write-file-atomic "^4.0.0" -binary-extensions@^2.0.0, binary-extensions@^2.2.0: +binary-extensions@^2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d" integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA== @@ -2950,14 +2326,14 @@ brace-expansion@^2.0.1: dependencies: balanced-match "^1.0.0" -braces@^3.0.2, braces@~3.0.2: +braces@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107" integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A== dependencies: fill-range "^7.0.1" -browserslist@^4.20.2, browserslist@^4.21.3: +browserslist@^4.20.2: version "4.21.3" resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.21.3.tgz#5df277694eb3c48bc5c4b05af3e8b7e09c5a6d1a" integrity sha512-898rgRXLAyRkM1GryrrBHGkqA5hlpkV5MhtZwg9QXeiyLUYs2k00Un05aX5l2/yJIOObYKOpS2JNo8nJDE7fWQ== @@ -2988,11 +2364,6 @@ buffer@4.9.2: ieee754 "^1.1.4" isarray "^1.0.0" -builtin-modules@^3.3.0: - version "3.3.0" - resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-3.3.0.tgz#cae62812b89801e9656336e46223e030386be7b6" - integrity sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw== - builtins@^5.0.0: version "5.0.1" resolved "https://registry.yarnpkg.com/builtins/-/builtins-5.0.1.tgz#87f6db9ab0458be728564fa81d876d8d74552fa9" @@ -3111,27 +2482,12 @@ charenc@0.0.2: resolved "https://registry.yarnpkg.com/charenc/-/charenc-0.0.2.tgz#c0a1d2f3a7092e03774bfa83f14c0fc5790a8667" integrity sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA== -chokidar@^3.4.0: - version "3.5.3" - resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.3.tgz#1cf37c8707b932bd1af1ae22c0432e2acd1903bd" - integrity sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw== - dependencies: - anymatch "~3.1.2" - braces "~3.0.2" - glob-parent "~5.1.2" - is-binary-path "~2.1.0" - is-glob "~4.0.1" - normalize-path "~3.0.0" - readdirp "~3.6.0" - optionalDependencies: - fsevents "~2.3.2" - chownr@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/chownr/-/chownr-2.0.0.tgz#15bfbe53d2eab4cf70f18a8cd68ebe5b3cb1dece" integrity sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ== -ci-info@^3.2.0, ci-info@^3.3.0: +ci-info@^3.2.0: version "3.3.2" resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-3.3.2.tgz#6d2967ffa407466481c6c90b6e16b3098f080128" integrity sha512-xmDt/QIAdeZ9+nfdPsaBCpMvHNLFiLdjj59qjqn+6iPe6YmHGQ35sBnQ8uslRBXFmXkiZQOJRjvQeoGppoTjjg== @@ -3148,13 +2504,6 @@ cjs-module-lexer@^1.0.0: resolved "https://registry.yarnpkg.com/cjs-module-lexer/-/cjs-module-lexer-1.2.2.tgz#9f84ba3244a512f3a54e5277e8eef4c489864e40" integrity sha512-cOU9usZw8/dXIXKtwa8pM0OTJQuJkxMN6w30csNRUerHfeQ5R6U3kkU/FtJeIf3M202OHfY2U8ccInBG7/xogA== -clean-regexp@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/clean-regexp/-/clean-regexp-1.0.0.tgz#8df7c7aae51fd36874e8f8d05b9180bc11a3fed7" - integrity sha512-GfisEZEJvzKrmGWkvfhgzcz/BllN1USeqD2V6tg14OAOgaCD2Z/PUEuxnAZ/nPvmaHRG7a8y77p1T/IRQ4D1Hw== - dependencies: - escape-string-regexp "^1.0.5" - clean-stack@^2.0.0: version "2.2.0" resolved "https://registry.yarnpkg.com/clean-stack/-/clean-stack-2.2.0.tgz#ee8472dbb129e727b31e8a10a427dee9dfe4008b" @@ -3195,15 +2544,6 @@ cliui@^7.0.2: strip-ansi "^6.0.0" wrap-ansi "^7.0.0" -clone-deep@^4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/clone-deep/-/clone-deep-4.0.1.tgz#c19fd9bdbbf85942b4fd979c84dcf7d5f07c2387" - integrity sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ== - dependencies: - is-plain-object "^2.0.4" - kind-of "^6.0.2" - shallow-clone "^3.0.0" - clone@^1.0.2: version "1.0.4" resolved "https://registry.yarnpkg.com/clone/-/clone-1.0.4.tgz#da309cc263df15994c688ca902179ca3c7cd7c7e" @@ -3294,11 +2634,6 @@ combined-stream@^1.0.8: dependencies: delayed-stream "~1.0.0" -commander@^4.0.1: - version "4.1.1" - resolved "https://registry.yarnpkg.com/commander/-/commander-4.1.1.tgz#9fd602bd936294e9e9ef46a3f4d6964044b18068" - integrity sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA== - comment-parser@1.3.1: version "1.3.1" resolved "https://registry.yarnpkg.com/comment-parser/-/comment-parser-1.3.1.tgz#3d7ea3adaf9345594aedee6563f422348f165c1b" @@ -3380,7 +2715,7 @@ conventional-commits-parser@^3.2.3: split2 "^3.0.0" through2 "^4.0.0" -convert-source-map@^1.1.0, convert-source-map@^1.4.0, convert-source-map@^1.6.0, convert-source-map@^1.7.0: +convert-source-map@^1.4.0, convert-source-map@^1.6.0, convert-source-map@^1.7.0: version "1.8.0" resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.8.0.tgz#f3373c32d21b4d780dd8004514684fb791ca4369" integrity sha512-+OQdjP49zViI/6i7nIJpA8rAl4sV/JdPfU9nZs3VqOwGIgizICvuN2ru6fMd+4llL0tar18UYJXfZ/TWtmhUjA== @@ -3392,19 +2727,6 @@ cookie@^0.4.1: resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.2.tgz#0e41f24de5ecf317947c82fc789e06a884824432" integrity sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA== -core-js-compat@^3.21.0, core-js-compat@^3.22.1: - version "3.24.1" - resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.24.1.tgz#d1af84a17e18dfdd401ee39da9996f9a7ba887de" - integrity sha512-XhdNAGeRnTpp8xbD+sR/HFDK9CbeeeqXT6TuofXh3urqEevzkWmLRgrVoykodsw8okqo2pu1BOmuCKrHx63zdw== - dependencies: - browserslist "^4.21.3" - semver "7.0.0" - -core-js@^3.22.1: - version "3.24.1" - resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.24.1.tgz#cf7724d41724154010a6576b7b57d94c5d66e64f" - integrity sha512-0QTBSYSUZ6Gq21utGzkfITDylE8jWC9Ne1D2MrhvlsZBI1x39OdDIVbzSqtgMndIy6BlHxBXpMGqzZmnztg2rg== - core-util-is@~1.0.0: version "1.0.3" resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.3.tgz#a6042d3634c2b27e9328f837b965fac83808db85" @@ -3421,6 +2743,11 @@ cosmiconfig@^7.0.0: path-type "^4.0.0" yaml "^1.10.0" +create-require@^1.1.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333" + integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ== + cross-spawn@^7.0.0, cross-spawn@^7.0.2, cross-spawn@^7.0.3: version "7.0.3" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" @@ -3593,6 +2920,11 @@ diff-sequences@^28.1.1: resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-28.1.1.tgz#9989dc731266dc2903457a70e996f3a041913ac6" integrity sha512-FU0iFaH/E23a+a718l8Qa/19bF9p06kgE0KipMOMadwa3SjnaElKzPaUC0vnibs6/B/9ni97s61mcejk8W1fQw== +diff@^4.0.1: + version "4.0.2" + resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d" + integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A== + diff@^5.0.0: version "5.1.0" resolved "https://registry.yarnpkg.com/diff/-/diff-5.1.0.tgz#bc52d298c5ea8df9194800224445ed43ffc87e40" @@ -3708,7 +3040,7 @@ error-ex@^1.3.1: dependencies: is-arrayish "^0.2.1" -es-abstract@^1.19.0, es-abstract@^1.19.1, es-abstract@^1.19.2, es-abstract@^1.19.5, es-abstract@^1.20.0, es-abstract@^1.20.1: +es-abstract@^1.19.0, es-abstract@^1.19.1, es-abstract@^1.19.2, es-abstract@^1.19.5, es-abstract@^1.20.0: version "1.20.1" resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.20.1.tgz#027292cd6ef44bd12b1913b828116f54787d1814" integrity sha512-WEm2oBhfoI2sImeM4OF2zE2V3BYdSF+KnSi9Sidz51fQHd7+JuF8Xgcj9/0o+OWeIeIS/MiuNnlruQrJf16GQA== @@ -3737,11 +3069,6 @@ es-abstract@^1.19.0, es-abstract@^1.19.1, es-abstract@^1.19.2, es-abstract@^1.19 string.prototype.trimstart "^1.0.5" unbox-primitive "^1.0.2" -es-array-method-boxes-properly@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/es-array-method-boxes-properly/-/es-array-method-boxes-properly-1.0.0.tgz#873f3e84418de4ee19c5be752990b2e44718d09e" - integrity sha512-wd6JXUmyHmt8T5a2xreUwKcGPq6f1f+WwIJkijUqiGcJz1qqnZgP6XIK+QyIWU5lT7imeNxUll48bziG+TSYcA== - es-shim-unscopables@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/es-shim-unscopables/-/es-shim-unscopables-1.0.0.tgz#702e632193201e3edf8713635d083d378e510241" @@ -3817,14 +3144,6 @@ eslint-module-utils@^2.7.3: dependencies: debug "^3.2.7" -eslint-plugin-flowtype@^8.0.3: - version "8.0.3" - resolved "https://registry.yarnpkg.com/eslint-plugin-flowtype/-/eslint-plugin-flowtype-8.0.3.tgz#e1557e37118f24734aa3122e7536a038d34a4912" - integrity sha512-dX8l6qUL6O+fYPtpNRideCFSpmWOUVx5QcaGLVqe/vlDiBSe4vYljDWDETwnyFzpl7By/WVIu6rcrniCgH9BqQ== - dependencies: - lodash "^4.17.21" - string-natural-compare "^3.0.1" - eslint-plugin-import@^2.25.2: version "2.26.0" resolved "https://registry.yarnpkg.com/eslint-plugin-import/-/eslint-plugin-import-2.26.0.tgz#f812dc47be4f2b72b478a021605a59fc6fe8b88b" @@ -3857,31 +3176,6 @@ eslint-plugin-jsdoc@^39.3.2: semver "^7.3.7" spdx-expression-parse "^3.0.1" -eslint-plugin-sonarjs@^0.13.0: - version "0.13.0" - resolved "https://registry.yarnpkg.com/eslint-plugin-sonarjs/-/eslint-plugin-sonarjs-0.13.0.tgz#c34d140cc90abaaed38f5a5201a2ccdebe398862" - integrity sha512-t3m7ta0EspzDxSOZh3cEOJIJVZgN/TlJYaBGnQlK6W/PZNbWep8q4RQskkJkA7/zwNpX0BaoEOSUUrqaADVoqA== - -eslint-plugin-unicorn@^42.0.0: - version "42.0.0" - resolved "https://registry.yarnpkg.com/eslint-plugin-unicorn/-/eslint-plugin-unicorn-42.0.0.tgz#47d60c00c263ad743403b052db689e39acbacff1" - integrity sha512-ixBsbhgWuxVaNlPTT8AyfJMlhyC5flCJFjyK3oKE8TRrwBnaHvUbuIkCM1lqg8ryYrFStL/T557zfKzX4GKSlg== - dependencies: - "@babel/helper-validator-identifier" "^7.15.7" - ci-info "^3.3.0" - clean-regexp "^1.0.0" - eslint-utils "^3.0.0" - esquery "^1.4.0" - indent-string "^4.0.0" - is-builtin-module "^3.1.0" - lodash "^4.17.21" - pluralize "^8.0.0" - read-pkg-up "^7.0.1" - regexp-tree "^0.1.24" - safe-regex "^2.1.1" - semver "^7.3.5" - strip-indent "^3.0.0" - eslint-scope@^5.1.1: version "5.1.1" resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-5.1.1.tgz#e786e59a66cb92b3f6c1fb0d508aab174848f48c" @@ -3905,7 +3199,7 @@ eslint-utils@^3.0.0: dependencies: eslint-visitor-keys "^2.0.0" -eslint-visitor-keys@^2.0.0, eslint-visitor-keys@^2.1.0: +eslint-visitor-keys@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz#f65328259305927392c938ed44eb0a5c9b2bd303" integrity sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw== @@ -4122,15 +3416,6 @@ fill-range@^7.0.1: dependencies: to-regex-range "^5.0.1" -find-cache-dir@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/find-cache-dir/-/find-cache-dir-2.1.0.tgz#8d0f94cd13fe43c6c7c261a0d86115ca918c05f7" - integrity sha512-Tq6PixE0w/VMFfCgbONnkiQIVol/JJL7nRMi20fqzA4NRs9AfeqMGeRdPi3wIhYkxjeBaWh2rxwapn5Tu3IqOQ== - dependencies: - commondir "^1.0.1" - make-dir "^2.0.0" - pkg-dir "^3.0.0" - find-cache-dir@^3.2.0: version "3.3.2" resolved "https://registry.yarnpkg.com/find-cache-dir/-/find-cache-dir-3.3.2.tgz#b30c5b6eff0730731aea9bbd9dbecbd80256d64b" @@ -4147,13 +3432,6 @@ find-up@^2.0.0: dependencies: locate-path "^2.0.0" -find-up@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/find-up/-/find-up-3.0.0.tgz#49169f1d7993430646da61ecc5ae355c21c97b73" - integrity sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg== - dependencies: - locate-path "^3.0.0" - find-up@^4.0.0, find-up@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/find-up/-/find-up-4.1.0.tgz#97afe7d6cdc0bc5928584b7c8d7b16e8a9aa5d19" @@ -4253,17 +3531,12 @@ fs-minipass@^2.0.0, fs-minipass@^2.1.0: dependencies: minipass "^3.0.0" -fs-readdir-recursive@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/fs-readdir-recursive/-/fs-readdir-recursive-1.1.0.tgz#e32fc030a2ccee44a6b5371308da54be0b397d27" - integrity sha512-GNanXlVr2pf02+sPN40XN8HG+ePaNcvM0q5mZBd668Obwb0yD5GiUbZOFgwn8kGMY6I3mdyDJzieUy3PTYyTRA== - fs.realpath@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== -fsevents@^2.3.2, fsevents@~2.3.2: +fsevents@^2.3.2: version "2.3.2" resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a" integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== @@ -4356,7 +3629,7 @@ git-log-parser@^1.2.0: through2 "~2.0.0" traverse "~0.6.6" -glob-parent@^5.1.2, glob-parent@~5.1.2: +glob-parent@^5.1.2: version "5.1.2" resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4" integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== @@ -4370,7 +3643,7 @@ glob-parent@^6.0.1: dependencies: is-glob "^4.0.3" -glob@^7.1.3, glob@^7.1.4, glob@^7.1.6, glob@^7.2.0: +glob@^7.1.3, glob@^7.1.4, glob@^7.1.6: version "7.2.3" resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b" integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q== @@ -4503,13 +3776,6 @@ hasha@^5.0.0: is-stream "^2.0.0" type-fest "^0.8.0" -homedir-polyfill@^1.0.1: - version "1.0.3" - resolved "https://registry.yarnpkg.com/homedir-polyfill/-/homedir-polyfill-1.0.3.tgz#743298cef4e5af3e194161fbadcc2151d3a058e8" - integrity sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA== - dependencies: - parse-passwd "^1.0.0" - hook-std@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/hook-std/-/hook-std-2.0.0.tgz#ff9aafdebb6a989a354f729bb6445cf4a3a7077c" @@ -4726,13 +3992,6 @@ is-bigint@^1.0.1: dependencies: has-bigints "^1.0.1" -is-binary-path@~2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09" - integrity sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw== - dependencies: - binary-extensions "^2.0.0" - is-boolean-object@^1.1.0: version "1.1.2" resolved "https://registry.yarnpkg.com/is-boolean-object/-/is-boolean-object-1.1.2.tgz#5c6dc200246dd9321ae4b885a114bb1f75f63719" @@ -4746,13 +4005,6 @@ is-buffer@~1.1.6: resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be" integrity sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w== -is-builtin-module@^3.1.0: - version "3.2.0" - resolved "https://registry.yarnpkg.com/is-builtin-module/-/is-builtin-module-3.2.0.tgz#bb0310dfe881f144ca83f30100ceb10cf58835e0" - integrity sha512-phDA4oSGt7vl1n5tJvTWooWWAsXLY+2xCnxNqvKhGEzujg+A43wPlPOyDg3C8XQHN+6k/JTQWJ/j0dQh/qr+Hw== - dependencies: - builtin-modules "^3.3.0" - is-callable@^1.1.3, is-callable@^1.1.4, is-callable@^1.2.4: version "1.2.4" resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.4.tgz#47301d58dd0259407865547853df6d61fe471945" @@ -4801,7 +4053,7 @@ is-generator-function@^1.0.7: dependencies: has-tostringtag "^1.0.0" -is-glob@^4.0.0, is-glob@^4.0.1, is-glob@^4.0.3, is-glob@~4.0.1: +is-glob@^4.0.0, is-glob@^4.0.1, is-glob@^4.0.3: version "4.0.3" resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084" integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg== @@ -4850,13 +4102,6 @@ is-plain-obj@^1.1.0: resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-1.1.0.tgz#71a50c8429dfca773c92a390a4a03b39fcd51d3e" integrity sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg== -is-plain-object@^2.0.4: - version "2.0.4" - resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-2.0.4.tgz#2c163b3fafb1b606d9d17928f05c2a1c38e07677" - integrity sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og== - dependencies: - isobject "^3.0.1" - is-plain-object@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-5.0.0.tgz#4427f50ab3429e9025ea7d52e9043a9ef4159344" @@ -4941,11 +4186,6 @@ isexe@^2.0.0: resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== -isobject@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df" - integrity sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg== - issue-parser@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/issue-parser/-/issue-parser-6.0.0.tgz#b1edd06315d4f2044a9755daf85fdafde9b4014a" @@ -5426,11 +4666,6 @@ jsesc@^2.5.1: resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-2.5.2.tgz#80564d2e483dacf6e8ef209650a67df3f0c283a4" integrity sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA== -jsesc@~0.5.0: - version "0.5.0" - resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-0.5.0.tgz#e7dee66e35d6fc16f710fe91d5cf69f70f08911d" - integrity sha512-uZz5UnB7u4T9LvwmFqXii7pZSouaRPorGs5who1Ip7VO0wxanFvBL7GkM6dTHlgX+jhBApRetaWpnDabOeTcnA== - json-parse-better-errors@^1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz#bb867cfb3450e69107c131d1c514bab3dc8bcaa9" @@ -5497,7 +4732,7 @@ just-diff@^5.0.1: resolved "https://registry.yarnpkg.com/just-diff/-/just-diff-5.1.1.tgz#8da6414342a5ed6d02ccd64f5586cbbed3146202" integrity sha512-u8HXJ3HlNrTzY7zrYYKjNEfBlyjqhdBkoyTVdjtn7p02RJD5NvR8rIClzeGA7t+UYP1/7eAkWNLU0+P3QrEqKQ== -kind-of@^6.0.2, kind-of@^6.0.3: +kind-of@^6.0.3: version "6.0.3" resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd" integrity sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw== @@ -5661,14 +4896,6 @@ locate-path@^2.0.0: p-locate "^2.0.0" path-exists "^3.0.0" -locate-path@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-3.0.0.tgz#dbec3b3ab759758071b58fe59fc41871af21400e" - integrity sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A== - dependencies: - p-locate "^3.0.0" - path-exists "^3.0.0" - locate-path@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-5.0.0.tgz#1afba396afd676a6d42504d0a67a3a7eb9f62aa0" @@ -5688,11 +4915,6 @@ lodash.capitalize@^4.2.1: resolved "https://registry.yarnpkg.com/lodash.capitalize/-/lodash.capitalize-4.2.1.tgz#f826c9b4e2a8511d84e3aca29db05e1a4f3b72a9" integrity sha512-kZzYOKspf8XVX5AvmQF94gQW0lejFVgb80G85bU4ZWzoJ6C03PQg3coYAUpSTpQWelrZELd3XWgHzw4Ck5kaIw== -lodash.debounce@^4.0.8: - version "4.0.8" - resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af" - integrity sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow== - lodash.escaperegexp@^4.1.2: version "4.1.2" resolved "https://registry.yarnpkg.com/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz#64762c48618082518ac3df4ccf5d5886dae20347" @@ -5769,14 +4991,6 @@ lru_map@^0.3.3: resolved "https://registry.yarnpkg.com/lru_map/-/lru_map-0.3.3.tgz#b5c8351b9464cbd750335a79650a0ec0e56118dd" integrity sha512-Pn9cox5CsMYngeDbmChANltQl+5pi6XmTrraMSzhPmMBbmgcxmqWry0U3PGapCU1yB4/LqCcom7qhHZiF/jGfQ== -make-dir@^2.0.0, make-dir@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-2.1.0.tgz#5f0310e18b8be898cc07009295a30ae41e91e6f5" - integrity sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA== - dependencies: - pify "^4.0.1" - semver "^5.6.0" - make-dir@^3.0.0, make-dir@^3.0.2: version "3.1.0" resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-3.1.0.tgz#415e967046b3a7f1d185277d84aa58203726a13f" @@ -5784,6 +4998,11 @@ make-dir@^3.0.0, make-dir@^3.0.2: dependencies: semver "^6.0.0" +make-error@^1.1.1: + version "1.3.6" + resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.6.tgz#2eb2e37ea9b67c4891f684a1394799af484cf7a2" + integrity sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw== + make-fetch-happen@^10.0.3, make-fetch-happen@^10.0.6, make-fetch-happen@^10.2.0: version "10.2.0" resolved "https://registry.yarnpkg.com/make-fetch-happen/-/make-fetch-happen-10.2.0.tgz#0bde3914f2f82750b5d48c6d2294d2c74f985e5b" @@ -6072,14 +5291,6 @@ node-emoji@^1.11.0: dependencies: lodash "^4.17.21" -node-environment-flags@^1.0.5: - version "1.0.6" - resolved "https://registry.yarnpkg.com/node-environment-flags/-/node-environment-flags-1.0.6.tgz#a30ac13621f6f7d674260a54dede048c3982c088" - integrity sha512-5Evy2epuL+6TM0lCQGpFIj6KwiEsGh1SrHUhTbNX+sLbBtjidPZFAnVK9y5yU1+h//RitLbRHTIMyxQPtxMdHw== - dependencies: - object.getownpropertydescriptors "^2.0.3" - semver "^5.7.0" - node-fetch@^2.6.7: version "2.6.7" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.7.tgz#24de9fba827e3b4ae44dc8b20256a379160052ad" @@ -6164,7 +5375,7 @@ normalize-package-data@^4.0.0: semver "^7.3.5" validate-npm-package-license "^3.0.4" -normalize-path@^3.0.0, normalize-path@~3.0.0: +normalize-path@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== @@ -6393,7 +5604,7 @@ object-keys@^1.1.1: resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e" integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA== -object.assign@^4.1.0, object.assign@^4.1.2: +object.assign@^4.1.2: version "4.1.3" resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.1.3.tgz#d36b7700ddf0019abb6b1df1bb13f6445f79051f" integrity sha512-ZFJnX3zltyjcYJL0RoCJuzb+11zWGyaDbjgxZbdV7rFEcHQuYxrZqhow67aA7xpes6LhojyFDaBKAFfogQrikA== @@ -6412,16 +5623,6 @@ object.entries@^1.1.5: define-properties "^1.1.3" es-abstract "^1.19.1" -object.getownpropertydescriptors@^2.0.3: - version "2.1.4" - resolved "https://registry.yarnpkg.com/object.getownpropertydescriptors/-/object.getownpropertydescriptors-2.1.4.tgz#7965e6437a57278b587383831a9b829455a4bc37" - integrity sha512-sccv3L/pMModT6dJAYF3fzGMVcb38ysQ0tEE6ixv2yXJDtEIPph268OlAdJj5/qZMZDq2g/jqvwppt36uS/uQQ== - dependencies: - array.prototype.reduce "^1.0.4" - call-bind "^1.0.2" - define-properties "^1.1.4" - es-abstract "^1.20.1" - object.values@^1.1.5: version "1.1.5" resolved "https://registry.yarnpkg.com/object.values/-/object.values-1.1.5.tgz#959f63e3ce9ef108720333082131e4a459b716ac" @@ -6498,7 +5699,7 @@ p-limit@^1.1.0: dependencies: p-try "^1.0.0" -p-limit@^2.0.0, p-limit@^2.2.0: +p-limit@^2.2.0: version "2.3.0" resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.3.0.tgz#3dd33c647a214fdfffd835933eb086da0dc21db1" integrity sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w== @@ -6519,13 +5720,6 @@ p-locate@^2.0.0: dependencies: p-limit "^1.1.0" -p-locate@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-3.0.0.tgz#322d69a05c0264b25997d9f40cd8a891ab0064a4" - integrity sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ== - dependencies: - p-limit "^2.0.0" - p-locate@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-4.1.0.tgz#a3428bb7088b3a60292f66919278b7c297ad4f07" @@ -6653,11 +5847,6 @@ parse-json@^5.0.0, parse-json@^5.2.0: json-parse-even-better-errors "^2.3.0" lines-and-columns "^1.1.6" -parse-passwd@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/parse-passwd/-/parse-passwd-1.0.0.tgz#6d5b934a456993b23d37f40a382d6f1666a8e5c6" - integrity sha512-1Y1A//QUXEZK7YKz+rD9WydcE1+EuPr6ZBgKecAB8tmoW6UFv0NREVJe1p+jRxtThkcbbKkfwIbWJe/IeE6m2Q== - path-exists@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-3.0.0.tgz#ce0ebeaa5f78cb18925ea7d810d7b59b010fd515" @@ -6693,7 +5882,7 @@ picocolors@^1.0.0: resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c" integrity sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ== -picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.2.3, picomatch@^2.3.1: +picomatch@^2.0.4, picomatch@^2.2.3, picomatch@^2.3.1: version "2.3.1" resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== @@ -6703,12 +5892,7 @@ pify@^3.0.0: resolved "https://registry.yarnpkg.com/pify/-/pify-3.0.0.tgz#e5a4acd2c101fdf3d9a4d07f0dbc4db49dd28176" integrity sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg== -pify@^4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/pify/-/pify-4.0.1.tgz#4b2cd25c50d598735c50292224fd8c6df41e3231" - integrity sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g== - -pirates@^4.0.4, pirates@^4.0.5: +pirates@^4.0.4: version "4.0.5" resolved "https://registry.yarnpkg.com/pirates/-/pirates-4.0.5.tgz#feec352ea5c3268fb23a37c702ab1699f35a5f3b" integrity sha512-8V9+HQPupnaXMA23c5hvl69zXvTwTzyAYasnkb0Tts4XvO4CliqONMOnvlq26rkhLC3nWDFBJf73LU1e1VZLaQ== @@ -6721,13 +5905,6 @@ pkg-conf@^2.1.0: find-up "^2.0.0" load-json-file "^4.0.0" -pkg-dir@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-3.0.0.tgz#2749020f239ed990881b1f71210d51eb6523bea3" - integrity sha512-/E57AYkoeQ25qkxMj5PBOVgF8Kiu/h7cYS30Z5+R7WaiCCBfLq58ZI/dSeaEKb9WVJV5n/03QwrN3IeWIFllvw== - dependencies: - find-up "^3.0.0" - pkg-dir@^4.1.0, pkg-dir@^4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-4.2.0.tgz#f099133df7ede422e81d1d8448270eeb3e4261f3" @@ -6735,11 +5912,6 @@ pkg-dir@^4.1.0, pkg-dir@^4.2.0: dependencies: find-up "^4.0.0" -pluralize@^8.0.0: - version "8.0.0" - resolved "https://registry.yarnpkg.com/pluralize/-/pluralize-8.0.0.tgz#1a6fa16a38d12a1901e0320fa017051c539ce3b1" - integrity sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA== - postcss-selector-parser@^6.0.10: version "6.0.10" resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz#79b61e2c0d1bfc2602d549e11d0876256f8df88d" @@ -6954,13 +6126,6 @@ readdir-scoped-modules@^1.1.0: graceful-fs "^4.1.2" once "^1.3.0" -readdirp@~3.6.0: - version "3.6.0" - resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7" - integrity sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA== - dependencies: - picomatch "^2.2.1" - redent@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/redent/-/redent-3.0.0.tgz#e557b7998316bb53c9f1f56fa626352c6963059f" @@ -6976,35 +6141,6 @@ redeyed@~2.1.0: dependencies: esprima "~4.0.0" -regenerate-unicode-properties@^10.0.1: - version "10.0.1" - resolved "https://registry.yarnpkg.com/regenerate-unicode-properties/-/regenerate-unicode-properties-10.0.1.tgz#7f442732aa7934a3740c779bb9b3340dccc1fb56" - integrity sha512-vn5DU6yg6h8hP/2OkQo3K7uVILvY4iu0oI4t3HFa81UPkhGJwkRwM10JEc3upjdhHjs/k8GJY1sRBhk5sr69Bw== - dependencies: - regenerate "^1.4.2" - -regenerate@^1.4.2: - version "1.4.2" - resolved "https://registry.yarnpkg.com/regenerate/-/regenerate-1.4.2.tgz#b9346d8827e8f5a32f7ba29637d398b69014848a" - integrity sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A== - -regenerator-runtime@^0.13.4: - version "0.13.9" - resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz#8925742a98ffd90814988d7566ad30ca3b263b52" - integrity sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA== - -regenerator-transform@^0.15.0: - version "0.15.0" - resolved "https://registry.yarnpkg.com/regenerator-transform/-/regenerator-transform-0.15.0.tgz#cbd9ead5d77fae1a48d957cf889ad0586adb6537" - integrity sha512-LsrGtPmbYg19bcPHwdtmXwbW+TqNvtY4riE3P83foeHRroMbH6/2ddFBfab3t7kbzc7v7p4wbkIecHImqt0QNg== - dependencies: - "@babel/runtime" "^7.8.4" - -regexp-tree@^0.1.24, regexp-tree@~0.1.1: - version "0.1.24" - resolved "https://registry.yarnpkg.com/regexp-tree/-/regexp-tree-0.1.24.tgz#3d6fa238450a4d66e5bc9c4c14bb720e2196829d" - integrity sha512-s2aEVuLhvnVJW6s/iPgEGK6R+/xngd2jNQ+xy4bXNDKxZKJH6jpPHY6kVeVv1IeLCHgswRj+Kl3ELaDjG6V1iw== - regexp.prototype.flags@^1.4.3: version "1.4.3" resolved "https://registry.yarnpkg.com/regexp.prototype.flags/-/regexp.prototype.flags-1.4.3.tgz#87cab30f80f66660181a3bb7bf5981a872b367ac" @@ -7019,18 +6155,6 @@ regexpp@^3.2.0: resolved "https://registry.yarnpkg.com/regexpp/-/regexpp-3.2.0.tgz#0425a2768d8f23bad70ca4b90461fa2f1213e1b2" integrity sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg== -regexpu-core@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/regexpu-core/-/regexpu-core-5.1.0.tgz#2f8504c3fd0ebe11215783a41541e21c79942c6d" - integrity sha512-bb6hk+xWd2PEOkj5It46A16zFMs2mv86Iwpdu94la4S3sJ7C973h2dHpYKwIBGaWSO7cIRJ+UX0IeMaWcO4qwA== - dependencies: - regenerate "^1.4.2" - regenerate-unicode-properties "^10.0.1" - regjsgen "^0.6.0" - regjsparser "^0.8.2" - unicode-match-property-ecmascript "^2.0.0" - unicode-match-property-value-ecmascript "^2.0.0" - registry-auth-token@^4.0.0: version "4.2.2" resolved "https://registry.yarnpkg.com/registry-auth-token/-/registry-auth-token-4.2.2.tgz#f02d49c3668884612ca031419491a13539e21fac" @@ -7038,18 +6162,6 @@ registry-auth-token@^4.0.0: dependencies: rc "1.2.8" -regjsgen@^0.6.0: - version "0.6.0" - resolved "https://registry.yarnpkg.com/regjsgen/-/regjsgen-0.6.0.tgz#83414c5354afd7d6627b16af5f10f41c4e71808d" - integrity sha512-ozE883Uigtqj3bx7OhL1KNbCzGyW2NQZPl6Hs09WTvCuZD5sTI4JY58bkbQWa/Y9hxIsvJ3M8Nbf7j54IqeZbA== - -regjsparser@^0.8.2: - version "0.8.4" - resolved "https://registry.yarnpkg.com/regjsparser/-/regjsparser-0.8.4.tgz#8a14285ffcc5de78c5b95d62bbf413b6bc132d5f" - integrity sha512-J3LABycON/VNEu3abOviqGHuB/LOtOQj8SKmfP9anY5GfAVw/SPjwzSjxGjbZXIxbGfqTHtJw58C2Li/WkStmA== - dependencies: - jsesc "~0.5.0" - release-zalgo@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/release-zalgo/-/release-zalgo-1.0.0.tgz#09700b7e5074329739330e535c5a90fb67851730" @@ -7098,7 +6210,7 @@ resolve.exports@^1.1.0: resolved "https://registry.yarnpkg.com/resolve.exports/-/resolve.exports-1.1.0.tgz#5ce842b94b05146c0e03076985d1d0e7e48c90c9" integrity sha512-J1l+Zxxp4XK3LUDZ9m60LRJF/mAe4z6a4xyabPHk7pvK5t35dACV32iIjJDFeWZFfZlO29w6SZ67knR0tHzJtQ== -resolve@^1.10.0, resolve@^1.14.2, resolve@^1.20.0, resolve@^1.22.0, resolve@^1.22.1: +resolve@^1.10.0, resolve@^1.20.0, resolve@^1.22.0, resolve@^1.22.1: version "1.22.1" resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.1.tgz#27cb2ebb53f91abb49470a928bba7558066ac177" integrity sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw== @@ -7146,13 +6258,6 @@ safe-buffer@~5.1.0, safe-buffer@~5.1.1: resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== -safe-regex@^2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/safe-regex/-/safe-regex-2.1.1.tgz#f7128f00d056e2fe5c11e81a1324dd974aadced2" - integrity sha512-rx+x8AMzKb5Q5lQ95Zoi6ZbJqwCLkqi3XuJXp5P3rT8OEc6sZCJG5AE5dU3lsgRr/F4Bs31jSlVN+j5KrsGu9A== - dependencies: - regexp-tree "~0.1.1" - safe-stable-stringify@^2.3.1: version "2.3.1" resolved "https://registry.yarnpkg.com/safe-stable-stringify/-/safe-stable-stringify-2.3.1.tgz#ab67cbe1fe7d40603ca641c5e765cb942d04fc73" @@ -7219,17 +6324,12 @@ semver-regex@^3.1.2: resolved "https://registry.yarnpkg.com/semver-regex/-/semver-regex-3.1.4.tgz#13053c0d4aa11d070a2f2872b6b1e3ae1e1971b4" integrity sha512-6IiqeZNgq01qGf0TId0t3NvKzSvUsjcpdEO3AQNeIjR6A2+ckTnQlDpl4qu1bjRv0RzN3FP9hzFmws3lKqRWkA== -"semver@2 || 3 || 4 || 5", semver@^5.6.0, semver@^5.7.0: +"semver@2 || 3 || 4 || 5": version "5.7.1" resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7" integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ== -semver@7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/semver/-/semver-7.0.0.tgz#5f3ca35761e47e05b206c6daff2cf814f0316b8e" - integrity sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A== - -semver@^6.0.0, semver@^6.1.1, semver@^6.1.2, semver@^6.3.0: +semver@^6.0.0, semver@^6.3.0: version "6.3.0" resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d" integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw== @@ -7246,13 +6346,6 @@ set-blocking@^2.0.0: resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7" integrity sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw== -shallow-clone@^3.0.0: - version "3.0.1" - resolved "https://registry.yarnpkg.com/shallow-clone/-/shallow-clone-3.0.1.tgz#8f2981ad92531f55035b01fb230769a40e02efa3" - integrity sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA== - dependencies: - kind-of "^6.0.2" - shebang-command@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea" @@ -7305,11 +6398,6 @@ sisteransi@^1.0.5: resolved "https://registry.yarnpkg.com/sisteransi/-/sisteransi-1.0.5.tgz#134d681297756437cc05ca01370d3a7a571075ed" integrity sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg== -slash@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/slash/-/slash-2.0.0.tgz#de552851a1759df3a8f206535442f5ec4ddeab44" - integrity sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A== - slash@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634" @@ -7354,14 +6442,6 @@ source-map-support@0.5.13: buffer-from "^1.0.0" source-map "^0.6.0" -source-map-support@^0.5.16: - version "0.5.21" - resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.21.tgz#04fe7c7f9e1ed2d662233c28cb2b35b9f63f6e4f" - integrity sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w== - dependencies: - buffer-from "^1.0.0" - source-map "^0.6.0" - source-map@^0.6.0, source-map@^0.6.1: version "0.6.1" resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" @@ -7471,11 +6551,6 @@ string-length@^4.0.1: char-regex "^1.0.2" strip-ansi "^6.0.0" -string-natural-compare@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/string-natural-compare/-/string-natural-compare-3.0.1.tgz#7a42d58474454963759e8e8b7ae63d71c1e7fdf4" - integrity sha512-n3sPwynL1nwKi3WJ6AIsClwBMa0zTi54fn2oLU6ndfTSIO05xaznjSf15PcBZU6FNWbmN5Q6cxT4V5hGvB4taw== - "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" @@ -7724,6 +6799,25 @@ triple-beam@^1.3.0: resolved "https://registry.yarnpkg.com/triple-beam/-/triple-beam-1.3.0.tgz#a595214c7298db8339eeeee083e4d10bd8cb8dd9" integrity sha512-XrHUvV5HpdLmIj4uVMxHggLbFSZYIn7HEWsqePZcI50pco+MPqJ50wMGY794X7AOOhxOBAjbkqfAbEe/QMp2Lw== +ts-node@^10.9.1: + version "10.9.1" + resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-10.9.1.tgz#e73de9102958af9e1f0b168a6ff320e25adcff4b" + integrity sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw== + dependencies: + "@cspotcode/source-map-support" "^0.8.0" + "@tsconfig/node10" "^1.0.7" + "@tsconfig/node12" "^1.0.7" + "@tsconfig/node14" "^1.0.0" + "@tsconfig/node16" "^1.0.2" + acorn "^8.4.1" + acorn-walk "^8.1.1" + arg "^4.1.0" + create-require "^1.1.0" + diff "^4.0.1" + make-error "^1.1.1" + v8-compile-cache-lib "^3.0.1" + yn "3.1.1" + tsconfig-paths@^3.14.1: version "3.14.1" resolved "https://registry.yarnpkg.com/tsconfig-paths/-/tsconfig-paths-3.14.1.tgz#ba0734599e8ea36c862798e920bcf163277b137a" @@ -7734,7 +6828,16 @@ tsconfig-paths@^3.14.1: minimist "^1.2.6" strip-bom "^3.0.0" -tslib@^1.11.1, tslib@^1.9.3: +tsconfig-paths@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/tsconfig-paths/-/tsconfig-paths-4.1.0.tgz#f8ef7d467f08ae3a695335bf1ece088c5538d2c1" + integrity sha512-AHx4Euop/dXFC+Vx589alFba8QItjF+8hf8LtmuiCwHyI4rHXQtOOENaM8kvYf5fR0dRChy3wzWIZ9WbB7FWow== + dependencies: + json5 "^2.2.1" + minimist "^1.2.6" + strip-bom "^3.0.0" + +tslib@^1.11.1, tslib@^1.8.1, tslib@^1.9.3: version "1.14.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== @@ -7744,6 +6847,13 @@ tslib@^2.3.1: resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.0.tgz#7cecaa7f073ce680a05847aa77be941098f36dc3" integrity sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ== +tsutils@^3.21.0: + version "3.21.0" + resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.21.0.tgz#b48717d394cea6c1e096983eed58e9d61715b623" + integrity sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA== + dependencies: + tslib "^1.8.1" + type-check@^0.4.0, type-check@~0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1" @@ -7798,6 +6908,11 @@ typedarray-to-buffer@^3.1.5: dependencies: is-typedarray "^1.0.0" +typescript@^4.7.4: + version "4.7.4" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.7.4.tgz#1a88596d1cf47d59507a1bcdfb5b9dfe4d488235" + integrity sha512-C0WQT0gezHuw6AdY1M2jxUO83Rjf0HP7Sk1DtXj6j1EwkQNZrHAg2XPWlq62oqEhYvONq5pkC2Y9oPljWToLmQ== + uglify-js@^3.1.4: version "3.16.3" resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.16.3.tgz#94c7a63337ee31227a18d03b8a3041c210fd1f1d" @@ -7813,29 +6928,6 @@ unbox-primitive@^1.0.2: has-symbols "^1.0.3" which-boxed-primitive "^1.0.2" -unicode-canonical-property-names-ecmascript@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.0.tgz#301acdc525631670d39f6146e0e77ff6bbdebddc" - integrity sha512-yY5PpDlfVIU5+y/BSCxAJRBIS1Zc2dDG3Ujq+sR0U+JjUevW2JhocOF+soROYDSaAezOzOKuyyixhD6mBknSmQ== - -unicode-match-property-ecmascript@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz#54fd16e0ecb167cf04cf1f756bdcc92eba7976c3" - integrity sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q== - dependencies: - unicode-canonical-property-names-ecmascript "^2.0.0" - unicode-property-aliases-ecmascript "^2.0.0" - -unicode-match-property-value-ecmascript@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.0.0.tgz#1a01aa57247c14c568b89775a54938788189a714" - integrity sha512-7Yhkc0Ye+t4PNYzOGKedDhXbYIBe1XEQYQxOPyhcXNMJ0WCABqqj6ckydd6pWRZTHV4GuCPKdBAUiMc60tsKVw== - -unicode-property-aliases-ecmascript@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.0.0.tgz#0a36cb9a585c4f6abd51ad1deddb285c165297c8" - integrity sha512-5Zfuy9q/DFr4tfO7ZPeVXb1aPoeQSdeFMLpYuFebehDAhbuevLs5yxSZmIFN1tP5F9Wl4IpJrYojg85/zgyZHQ== - unique-filename@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/unique-filename/-/unique-filename-1.1.1.tgz#1d69769369ada0583103a1e6ae87681b56573230" @@ -7940,6 +7032,11 @@ uuid@^8.3.2: resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== +v8-compile-cache-lib@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz#6336e8d71965cb3d35a1bbb7868445a7c05264bf" + integrity sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg== + v8-compile-cache@^2.0.3: version "2.3.0" resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz#2de19618c66dc247dcfb6f99338035d8245a2cee" @@ -7954,13 +7051,6 @@ v8-to-istanbul@^9.0.1: "@types/istanbul-lib-coverage" "^2.0.1" convert-source-map "^1.6.0" -v8flags@^3.1.1: - version "3.2.0" - resolved "https://registry.yarnpkg.com/v8flags/-/v8flags-3.2.0.tgz#b243e3b4dfd731fa774e7492128109a0fe66d656" - integrity sha512-mH8etigqMfiGWdeXpaaqGfs6BndypxusHHcv2qSHyZkGEznCd/qAXCWWRzeowtL54147cktFOC4P5y+kl8d8Jg== - dependencies: - homedir-polyfill "^1.0.1" - validate-npm-package-license@^3.0.1, validate-npm-package-license@^3.0.4: version "3.0.4" resolved "https://registry.yarnpkg.com/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz#fc91f6b9c7ba15c857f4cb2c5defeec39d4f410a" @@ -8248,6 +7338,11 @@ yargs@^17.3.1: y18n "^5.0.5" yargs-parser "^21.0.0" +yn@3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/yn/-/yn-3.1.1.tgz#1e87401a09d767c1d5eab26a6e4c185182d2eb50" + integrity sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q== + yocto-queue@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" From c22b93f58feb04a1ce0ce2a9fc79dc845cebe497 Mon Sep 17 00:00:00 2001 From: Seb Aebischer Date: Wed, 17 Aug 2022 09:59:06 +0100 Subject: [PATCH 02/39] Delete src and tests Starting from a clean slate so that I can focus on getting dependency injection working first, then gradually add back in other things. Locally I've just renamed these directories. --- src/Config/Dependencies.js | 26 - .../DependencyAware.class.js | 33 -- .../DependencyInjection.class.js | 116 ---- src/Model/CloudEvent.model.js | 133 ----- src/Model/Model.model.js | 31 - src/Model/Response.model.js | 127 ---- .../SQS/MarketingPreference.constraints.json | 28 - src/Model/SQS/MarketingPreference.model.js | 559 ------------------ src/Model/SQS/Message.model.js | 88 --- src/Model/Status.model.js | 65 -- src/Service/BaseConfig.service.js | 204 ------- src/Service/HTTP.service.js | 51 -- src/Service/Logger.service.js | 284 --------- src/Service/Request.service.js | 333 ----------- src/Service/SQS.service.js | 413 ------------- src/Service/Timer.service.js | 40 -- src/Wrapper/LambdaTermination.js | 29 - src/Wrapper/LambdaWrapper.js | 156 ----- src/Wrapper/PromisifiedDelay.js | 52 -- src/index.js | 24 - src/index.ts | 0 tests/lib/mocks.js | 49 -- .../DependencyAware.class.test.js | 32 - .../DependencyInjection.class.test.js | 101 ---- tests/unit/Model/CloudEvent.model.test.js | 58 -- tests/unit/Model/Response.model.test.js | 89 --- .../SQS/MarketingPreferences.model.test.js | 460 -------------- tests/unit/Model/SQS/Message.model.test.js | 46 -- tests/unit/Model/Status.model.test.js | 22 - tests/unit/Service/BaseConfig.service.test.js | 270 --------- tests/unit/Service/HTTP.service.test.js | 62 -- tests/unit/Service/Logger.service.test.js | 191 ------ tests/unit/Service/Request.service.test.js | 243 -------- tests/unit/Service/SQS.service.test.js | 269 --------- .../BaseConfig.service.test.js.snap | 35 -- .../__snapshots__/HTTP.service.test.js.snap | 298 ---------- .../__snapshots__/Logger.service.test.js.snap | 138 ----- .../Request.service.test.js.snap | 106 ---- .../__snapshots__/SQS.service.test.js.snap | 7 - tests/unit/Wrapper/LambdaTermination.test.js | 38 -- tests/unit/Wrapper/LambdaWrapper.test.js | 256 -------- .../__snapshots__/LambdaWrapper.test.js.snap | 221 ------- 42 files changed, 5783 deletions(-) delete mode 100644 src/Config/Dependencies.js delete mode 100644 src/DependencyInjection/DependencyAware.class.js delete mode 100644 src/DependencyInjection/DependencyInjection.class.js delete mode 100644 src/Model/CloudEvent.model.js delete mode 100644 src/Model/Model.model.js delete mode 100644 src/Model/Response.model.js delete mode 100644 src/Model/SQS/MarketingPreference.constraints.json delete mode 100644 src/Model/SQS/MarketingPreference.model.js delete mode 100644 src/Model/SQS/Message.model.js delete mode 100644 src/Model/Status.model.js delete mode 100644 src/Service/BaseConfig.service.js delete mode 100644 src/Service/HTTP.service.js delete mode 100644 src/Service/Logger.service.js delete mode 100644 src/Service/Request.service.js delete mode 100644 src/Service/SQS.service.js delete mode 100644 src/Service/Timer.service.js delete mode 100644 src/Wrapper/LambdaTermination.js delete mode 100644 src/Wrapper/LambdaWrapper.js delete mode 100644 src/Wrapper/PromisifiedDelay.js delete mode 100644 src/index.js create mode 100644 src/index.ts delete mode 100644 tests/lib/mocks.js delete mode 100644 tests/unit/DependencyInjection/DependencyAware.class.test.js delete mode 100644 tests/unit/DependencyInjection/DependencyInjection.class.test.js delete mode 100644 tests/unit/Model/CloudEvent.model.test.js delete mode 100644 tests/unit/Model/Response.model.test.js delete mode 100644 tests/unit/Model/SQS/MarketingPreferences.model.test.js delete mode 100644 tests/unit/Model/SQS/Message.model.test.js delete mode 100644 tests/unit/Model/Status.model.test.js delete mode 100644 tests/unit/Service/BaseConfig.service.test.js delete mode 100644 tests/unit/Service/HTTP.service.test.js delete mode 100644 tests/unit/Service/Logger.service.test.js delete mode 100644 tests/unit/Service/Request.service.test.js delete mode 100644 tests/unit/Service/SQS.service.test.js delete mode 100644 tests/unit/Service/__snapshots__/BaseConfig.service.test.js.snap delete mode 100644 tests/unit/Service/__snapshots__/HTTP.service.test.js.snap delete mode 100644 tests/unit/Service/__snapshots__/Logger.service.test.js.snap delete mode 100644 tests/unit/Service/__snapshots__/Request.service.test.js.snap delete mode 100644 tests/unit/Service/__snapshots__/SQS.service.test.js.snap delete mode 100644 tests/unit/Wrapper/LambdaTermination.test.js delete mode 100644 tests/unit/Wrapper/LambdaWrapper.test.js delete mode 100644 tests/unit/Wrapper/__snapshots__/LambdaWrapper.test.js.snap diff --git a/src/Config/Dependencies.js b/src/Config/Dependencies.js deleted file mode 100644 index ab2680ee..00000000 --- a/src/Config/Dependencies.js +++ /dev/null @@ -1,26 +0,0 @@ -import HTTPService from '../Service/HTTP.service'; -import LoggerService from '../Service/Logger.service'; -import RequestService from '../Service/Request.service'; -import SQSService from '../Service/SQS.service'; -import TimerService from '../Service/Timer.service'; - -export const DEFINITIONS = { - HTTP: 'HTTP', - LOGGER: 'LOGGER', - REQUEST: 'REQUEST', - SQS: 'SQS', - TIMER: 'TIMER', -}; - -export const DEPENDENCIES = { - [DEFINITIONS.HTTP]: HTTPService, - [DEFINITIONS.LOGGER]: LoggerService, - [DEFINITIONS.REQUEST]: RequestService, - [DEFINITIONS.SQS]: SQSService, - [DEFINITIONS.TIMER]: TimerService, -}; - -export default { - DEFINITIONS, - DEPENDENCIES, -}; diff --git a/src/DependencyInjection/DependencyAware.class.js b/src/DependencyInjection/DependencyAware.class.js deleted file mode 100644 index 61d7b800..00000000 --- a/src/DependencyInjection/DependencyAware.class.js +++ /dev/null @@ -1,33 +0,0 @@ -import DependencyInjection from './DependencyInjection.class'; - -/** - * DependencyAwareClass Class - */ -export default class DependencyAwareClass { - /** - * DependencyAwareClass constructor - * - * @param {DependencyInjection} di - */ - constructor(di: DependencyInjection) { - this.di = di; - } - - /** - * Get Dependency Injection Container - * - * @returns {DependencyInjection} - */ - getContainer() { - return this.di; - } - - /** - * Shortcut for `this.getContainer().definitions` - * - * @returns {object} - */ - get definitions() { - return this.getContainer().definitions; - } -} diff --git a/src/DependencyInjection/DependencyInjection.class.js b/src/DependencyInjection/DependencyInjection.class.js deleted file mode 100644 index 16ac73bd..00000000 --- a/src/DependencyInjection/DependencyInjection.class.js +++ /dev/null @@ -1,116 +0,0 @@ -import { DEFINITIONS, DEPENDENCIES } from '../Config/Dependencies'; - -/** - * DependencyInjection class - */ -export default class DependencyInjection { - /** - * DependencyInjection constructor - * - * @param configuration - * @param event - * @param context - */ - constructor(configuration, event, context) { - this.event = event; - this.context = context; - - this.dependencies = {}; - this.configuration = configuration; - - for (let x = 0; x <= 1; x += 1) { - // Iterate over lapper dependencies and add to container - Object.keys(DEFINITIONS).forEach((dependencyKey) => { - this.dependencies[dependencyKey] = new DEPENDENCIES[dependencyKey](this); - }); - - // Iterate over child dependencies and add to container - if (typeof configuration.DEPENDENCIES !== 'undefined') { - Object.keys(configuration.DEPENDENCIES).forEach((dependencyKey) => { - this.dependencies[dependencyKey] = new configuration.DEPENDENCIES[dependencyKey](this); - }); - } - } - } - - /** - * Get Dependency - * - * @param definition - * @returns {*} - */ - get(definition) { - if (typeof this.dependencies[definition] === 'undefined') { - throw new TypeError(`${definition} does not exist in di container`); - } - - return this.dependencies[definition]; - } - - /** - * Get Event - * - * @returns {*} - */ - getEvent() { - return this.event; - } - - /** - * Get Context - * - * @returns {*} - */ - getContext() { - return this.context; - } - - /** - * Get Configuration - * - * @param definition string - * @returns {*} - */ - getConfiguration(definition = null) { - if (definition !== null && typeof this.configuration[definition] === 'undefined') { - return null; - } - if (typeof this.configuration[definition] !== 'undefined') { - return this.configuration[definition]; - } - - return this.configuration; - } - - /** - * Check whether the function - * is being executed in a serverless-offline context - * - * @returns {boolean} - */ - get isOffline() { - const context = this.getContext() || {}; - - if (!Object.prototype.hasOwnProperty.call(context, 'invokedFunctionArn')) { - return true; - } - - if (context.invokedFunctionArn.includes('offline')) { - return true; - } - - return !!process.env.USE_SERVERLESS_OFFLINE; - } - - /** - * Returns the definitions - * associated to this DependencyInjection - * so that services can refer to them - * without causing circular imports. - * - * @returns {object} - */ - get definitions() { - return this.configuration.DEFINITIONS; - } -} diff --git a/src/Model/CloudEvent.model.js b/src/Model/CloudEvent.model.js deleted file mode 100644 index 98776c98..00000000 --- a/src/Model/CloudEvent.model.js +++ /dev/null @@ -1,133 +0,0 @@ -import { v4 as UUID } from 'uuid'; - -import Model from './Model.model'; - -/** - * CloudEventModel class - * Class to implement cloud events - https://github.com/cloudevents/spec/blob/master/spec.md - */ -export default class CloudEventModel extends Model { - /** - * CloudEventModel constructor - */ - constructor() { - super(); - - this.cloudEventsVersion = '0.1'; - this.eventType = ''; - this.source = ''; - this.eventID = UUID(); - this.eventTime = new Date().toISOString(); - this.extensions = {}; - this.contentType = 'application/json'; - this.data = {}; - } - - /** - * Get Cloud Events Version - * - * @returns {number} - */ - getCloudEventsVersion(): string { - return this.cloudEventsVersion; - } - - /** - * Get event type - * - * @returns {string|*} - */ - getEventType() { - return this.eventType; - } - - /** - * Set event type - * - * @param value string - */ - setEventType(value: string) { - this.eventType = value; - } - - /** - * Get source - * - * @returns {string|*} - */ - getSource() { - return this.source; - } - - /** - * Set source - * - * @param value string - */ - setSource(value: string) { - this.source = value; - } - - /** - * Get event id - * - * @returns {*|string} - */ - getEventID() { - return this.eventID; - } - - /** - * Get event time - * - * @returns {*|string} - */ - getEventTime() { - return this.eventTime; - } - - /** - * Get extensions - * - * @returns {{}|*} - */ - getExtensions() { - return this.extensions; - } - - /** - * Set extensions - * - * @param value object - */ - setExtensions(value: object) { - this.extensions = value; - } - - /** - * Get content type - * - * @returns {string} - */ - getContentType() { - return this.contentType; - } - - /** - * Get data - * - * @returns {{}|*} - */ - getData() { - return this.data; - } - - /** - * Set data - * - * @param value object - */ - setData(value: object) { - this.data = value; - } -} diff --git a/src/Model/Model.model.js b/src/Model/Model.model.js deleted file mode 100644 index a2e17452..00000000 --- a/src/Model/Model.model.js +++ /dev/null @@ -1,31 +0,0 @@ -/* eslint-disable class-methods-use-this */ -import validate from 'validate.js/validate'; - -/** - * Model base class - */ -export default class Model { - /** - * Instantiate a function with a value if defined - * - * @param classFunctionName string - * @param value mixed - */ - instantiateFunctionWithDefinedValue(classFunctionName, value) { - if (typeof value !== 'undefined') { - this[classFunctionName](value); - } - } - - /** - * Validate values against constraints - * - * @param values object - * @param constraints object - * @returns {boolean} - */ - validateAgainstConstraints(values: object, constraints: object): boolean { - const validation = validate(values, constraints); - return typeof validation === 'undefined'; - } -} diff --git a/src/Model/Response.model.js b/src/Model/Response.model.js deleted file mode 100644 index 78fd9a26..00000000 --- a/src/Model/Response.model.js +++ /dev/null @@ -1,127 +0,0 @@ -import Model from './Model.model'; - -/** - * - * @type {object} - */ -export const RESPONSE_HEADERS = { - 'Content-Type': 'application/json', - 'Access-Control-Allow-Origin': '*', // Required for CORS support to work - 'Access-Control-Allow-Credentials': true, // Required for cookies, authorization headers with HTTPS -}; - -/** - * Default message provided as part of response - * - * @type {string} - */ -export const DEFAULT_MESSAGE = 'success'; - -/** - * class ResponseModel - */ -export default class ResponseModel extends Model { - /** - * ResponseModel Constructor - * - * @param data - * @param code - * @param message - */ - constructor(data = null, code = null, message = null) { - super(); - - this.body = { - data: data !== null ? data : {}, - message: message !== null ? message : DEFAULT_MESSAGE, - }; - this.code = code !== null ? code : {}; - } - - /** - * Add or update a body variable - * - * @param variable - * @param value - */ - setBodyVariable(variable: string, value) { - this.body[variable] = value; - } - - /** - * Set Data - * - * @param data - */ - setData(data: object) { - this.body.data = data; - } - - /** - * Set Status Code - * - * @param code - */ - setCode(code: number) { - this.code = code; - } - - /** - * Get Status Code - * - * @returns {*} - */ - getCode() { - return this.code; - } - - /** - * Set message - * - * @param message - */ - setMessage(message: string) { - this.body.message = message; - } - - /** - * Get Message - * - * @returns {string|*} - */ - getMessage() { - return this.body.message; - } - - /** - * Geneate a response - * - * @returns {object} - */ - generate() { - return { - statusCode: this.code, - headers: RESPONSE_HEADERS, - body: JSON.stringify(this.body), - }; - } - - /** - * Shorthand static method - * that generates the response immediately - * if no additional processing is required. - * - * Saves only 1 line of code - * but keeps code terse in a lot of places. - * - * @param {*} data - * @param {*} code - * @param {*} message - * @returns {object} - */ - static generate(data = null, code = null, message = null) { - const response = new this(data, code, message); - - return response.generate(); - } -} diff --git a/src/Model/SQS/MarketingPreference.constraints.json b/src/Model/SQS/MarketingPreference.constraints.json deleted file mode 100644 index d1858f81..00000000 --- a/src/Model/SQS/MarketingPreference.constraints.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "campaign": { - "presence": { - "allowEmpty": false - } - }, - "transSource": { - "presence": { - "allowEmpty": false - } - }, - "transSourceUrl": { - "presence": { - "allowEmpty": false - }, - "url": true - }, - "transType": { - "presence": { - "allowEmpty": false - } - }, - "timestamp": { - "presence": { - "allowEmpty": false - } - } -} diff --git a/src/Model/SQS/MarketingPreference.model.js b/src/Model/SQS/MarketingPreference.model.js deleted file mode 100644 index ee4cb243..00000000 --- a/src/Model/SQS/MarketingPreference.model.js +++ /dev/null @@ -1,559 +0,0 @@ -/* eslint-disable sonarjs/cognitive-complexity */ -import validate from 'validate.js'; - -import Model from '../Model.model'; -import ResponseModel from '../Response.model'; -import requestConstraints from './MarketingPreference.constraints.json'; - -// Define action specific error types -export const ERROR_TYPES = { - VALIDATION_ERROR: new ResponseModel({}, 400, 'required fields are missing'), -}; - -/** - * Marketing Preference - */ -export default class MarketingPreference extends Model { - /** - * Message constructor - * - * @param data object - */ - constructor(data = {}) { - super(); - - this.firstname = null; - this.lastname = null; - this.phone = null; - this.mobile = null; - this.address1 = null; - this.address2 = null; - this.address3 = null; - this.town = null; - this.postcode = null; - this.country = null; - this.campaign = ''; - this.transactionId = null; - this.transSource = ''; - this.transSourceUrl = ''; - this.transType = 'prefs'; - this.email = null; - this.permissionPost = null; - this.permissionEmail = null; - this.permissionPhone = null; - this.permissionSMS = null; - this.timestamp = null; - - this.instantiateFunctionWithDefinedValue('setFirstName', data.firstName); - this.instantiateFunctionWithDefinedValue('setFirstName', data.firstname); - this.instantiateFunctionWithDefinedValue('setLastName', data.lastName); - this.instantiateFunctionWithDefinedValue('setLastName', data.lastname); - this.instantiateFunctionWithDefinedValue('setPhone', data.phone); - this.instantiateFunctionWithDefinedValue('setMobile', data.mobile); - this.instantiateFunctionWithDefinedValue('setAddress1', data.address1); - this.instantiateFunctionWithDefinedValue('setAddress2', data.address2); - this.instantiateFunctionWithDefinedValue('setAddress3', data.address3); - this.instantiateFunctionWithDefinedValue('setTown', data.town); - this.instantiateFunctionWithDefinedValue('setPostcode', data.postcode); - this.instantiateFunctionWithDefinedValue('setCountry', data.country); - this.instantiateFunctionWithDefinedValue('setCampaign', data.campaign); - this.instantiateFunctionWithDefinedValue('setTransactionId', data.transactionId); - this.instantiateFunctionWithDefinedValue('setTransSource', data.transSource); - this.instantiateFunctionWithDefinedValue('setTransSourceUrl', data.transSourceUrl); - this.instantiateFunctionWithDefinedValue('setEmail', data.email); - this.instantiateFunctionWithDefinedValue('setPermissionPost', data.permissionPost); - this.instantiateFunctionWithDefinedValue('setPermissionEmail', data.permissionEmail); - this.instantiateFunctionWithDefinedValue('setPermissionPhone', data.permissionPhone); - this.instantiateFunctionWithDefinedValue('setPermissionSMS', data.permissionSMS); - if (typeof data.timestamp !== 'undefined' && data.timestamp !== '' && data.timestamp !== null) { - this.instantiateFunctionWithDefinedValue('setTimestamp', data.timestamp); - } else { - this.generateTimestamp(); - } - } - - /** - * Get First Name - * - * @returns {string|*} - */ - getFirstName() { - return this.firstname; - } - - /** - * Set First Name - * - * @param value string - */ - setFirstName(value: string) { - this.firstname = value; - } - - /** - * Get Last Name - * - * @returns {string|*} - */ - getLastName() { - return this.lastname; - } - - /** - * Set Last Name - * - * @param value string - */ - setLastName(value: string) { - this.lastname = value; - } - - /** - * Get phone - * - * @returns {string|*} - */ - getPhone() { - return this.phone; - } - - /** - * Set phone - * - * @param value string - */ - setPhone(value: string) { - this.phone = value; - } - - /** - * Get Mobile - * - * @returns {string|*} - */ - getMobile() { - return this.mobile; - } - - /** - * Set Mobile - * - * @param value string - */ - setMobile(value: string) { - this.mobile = value; - } - - /** - * Get Address Line 1 - * - * @returns {string|*} - */ - getAddress1() { - return this.address1; - } - - /** - * Set Address Line 1 - * - * @param value string - */ - setAddress1(value: string) { - this.address1 = value; - } - - /** - * Get Address Line 2 - * - * @returns {string|*} - */ - getAddress2() { - return this.address2; - } - - /** - * Set Address Line 2 - * - * @param value string - */ - setAddress2(value: string) { - this.address2 = typeof value === 'undefined' || value === '' ? null : value; - } - - /** - * Get Address Line 3 - * - * @returns {string|*} - */ - getAddress3() { - return this.address3; - } - - /** - * Set Address Line 3 - * - * @param value string - */ - setAddress3(value: string) { - this.address3 = typeof value === 'undefined' || value === '' ? null : value; - } - - /** - * Get Town - * - * @returns {string|*} - */ - getTown() { - return this.town; - } - - /** - * Set Town - * - * @param value string - */ - setTown(value: string) { - this.town = value; - } - - /** - * Get Postcode - * - * @returns {string|*} - */ - getPostcode() { - return this.postcode; - } - - /** - * Set Postcode - * - * @param value string - */ - setPostcode(value: string) { - this.postcode = value; - } - - /** - * Get Country - * - * @returns {string|*} - */ - getCountry() { - return this.country; - } - - /** - * Set Country - * - * @param value string - */ - setCountry(value: string) { - this.country = value; - } - - /** - * Get Campaign - * - * @returns {string|*} - */ - getCampaign() { - return this.campaign; - } - - /** - * Set Campaign - * - * @param value string - */ - setCampaign(value: string) { - this.campaign = value; - } - - /** - * Get Transaction Id - * - * @returns {string|*} - */ - getTransactionId() { - return this.transactionId; - } - - /** - * Set Transaction Id - * - * @param value string - */ - setTransactionId(value: string) { - this.transactionId = value; - } - - /** - * Get Transaction Source - * - * @returns {string|*} - */ - getTransSource() { - return this.transSource; - } - - /** - * Set Transaction Source - * - * @param value string - */ - setTransSource(value: string) { - this.transSource = value; - } - - /** - * Get Transaction Source URL - * - * @returns {string|*} - */ - getTransSourceUrl() { - return this.transSourceUrl; - } - - /** - * Set Transaction Source URL - * - * @param value string - */ - setTransSourceUrl(value: string) { - this.transSourceUrl = value; - } - - /** - * Get Transaction Type - * - * @returns {string|*} - */ - getTransType() { - return this.transType; - } - - /** - * Set Transaction Type - * - * @param value string - */ - setTransType(value: string) { - this.transType = value; - } - - /** - * Get Email - * - * @returns {string|*} - */ - getEmail() { - return this.email; - } - - /** - * Set Email - * - * @param value string - */ - setEmail(value: string) { - this.email = value; - } - - /** - * Get Email Permission - * - * @returns {string|*} - */ - getPermissionEmail() { - return this.permissionEmail; - } - - /** - * Set Email Permission - * - * @param value string - */ - setPermissionEmail(value: string) { - this.permissionEmail = typeof value === 'undefined' || value === '' ? null : value; - } - - /** - * Get Post Permission - * - * @returns {string|*} - */ - getPermissionPost() { - return this.permissionPost; - } - - /** - * Set Post Permission - * - * @param value string - */ - setPermissionPost(value: string) { - this.permissionPost = typeof value === 'undefined' || value === '' ? null : value; - } - - /** - * Get Phone Permission - * - * @returns {string|*} - */ - getPermissionPhone() { - return this.permissionPhone; - } - - /** - * Set Phone Permission - * - * @param value string - */ - setPermissionPhone(value: string) { - this.permissionPhone = typeof value === 'undefined' || value === '' ? null : value; - } - - /** - * Get SMS Permission - * - * @returns {string|*} - */ - getPermissionSMS() { - return this.permissionSMS; - } - - /** - * Set SMS Permission - * - * @param value string - */ - setPermissionSMS(value: string) { - this.permissionSMS = typeof value === 'undefined' || value === '' ? null : value; - } - - /** - * Get Timestamp - * - * @returns {string|*} - */ - getTimestamp() { - return this.timestamp; - } - - /** - * Set Timestamp - * - * @param value string - */ - setTimestamp(value: string) { - this.timestamp = value; - } - - /** - * Generate Timestamp - */ - generateTimestamp() { - this.timestamp = Math.floor(Date.now() / 1000); - } - - /** - * Get Base entity mappings - * - * @returns {object} - */ - getEntityMappings() { - return { - firstname: this.getFirstName(), - lastname: this.getLastName(), - phone: this.getPhone(), - mobile: this.getMobile(), - address1: this.getAddress1(), - address2: this.getAddress2(), - address3: this.getAddress3(), - town: this.getTown(), - postcode: this.getPostcode(), - country: this.getCountry(), - campaign: this.getCampaign(), - transactionId: this.getTransactionId(), - transSource: this.getTransSource(), - transSourceUrl: this.getTransSourceUrl(), - transType: this.getTransType(), - email: this.getEmail(), - permissionEmail: this.getPermissionEmail(), - permissionPost: this.getPermissionPost(), - permissionPhone: this.getPermissionPhone(), - permissionSMS: this.getPermissionSMS(), - timestamp: this.getTimestamp(), - }; - } - - /** - * Check if any permission is set - * - * @returns {boolean} - */ - isPermissionSet() { - return ( - (this.getPermissionEmail() !== null && this.getPermissionEmail() !== '') - || (this.getPermissionPost() !== null && this.getPermissionPost() !== '') - || (this.getPermissionPhone() !== null && this.getPermissionPhone() !== '') - || (this.getPermissionSMS() !== null && this.getPermissionSMS() !== '') - ); - } - - /** - * Validate the model - * - * @returns {Promise} - */ - validate() { - return new Promise((resolve, reject) => { - const requestConstraintsClone = { ...requestConstraints }; - if ( - (this.getPermissionEmail() !== null - && this.getPermissionEmail() !== '' - && this.getPermissionEmail() !== '0' - && this.getPermissionEmail() !== 0) - || this.getEmail() - ) { - if (this.getEmail()) { - requestConstraintsClone.email = { email: true }; - } else { - requestConstraintsClone.email = { presence: { allowEmpty: false }, email: true }; - } - } - // Update constraints if fields are not empty - requestConstraintsClone.firstname = this.getFirstName() !== null && this.getFirstName() !== '' - ? { format: { pattern: "[a-zA-Z.'-_ ]+", flags: 'i', message: 'can only contain alphabetical characters' } } - : ''; - requestConstraintsClone.lastname = this.getLastName() !== null && this.getLastName() !== '' - ? { format: { pattern: "[a-zA-Z.'-_ ]+", flags: 'i', message: 'can only contain alphabetical characters' } } - : ''; - requestConstraintsClone.phone = this.getPhone() !== null && this.getPhone() !== '' - ? { format: { pattern: '[0-9 ]+', flags: 'i', message: 'can only contain numerical characters' } } - : ''; - requestConstraintsClone.mobile = this.getMobile() !== null && this.getMobile() !== '' - ? { format: { pattern: '[0-9 ]+', flags: 'i', message: 'can only contain numerical characters' } } - : ''; - requestConstraintsClone.address1 = this.getAddress1() !== null && this.getAddress1() !== '' - ? { format: { pattern: "[a-zA-Z.'-_& ]+", flags: 'i', message: "can only contain alphanumeric characters and . ' - _ &" } } - : ''; - requestConstraintsClone.country = this.getCountry() !== null && this.getCountry() !== '' - ? { format: { pattern: "[a-zA-Z.'-_& ]+", flags: 'i', message: "can only contain alphabetical characters and . ' - _ &" } } - : ''; - - const validation = validate(this.getEntityMappings(), requestConstraintsClone); - - if (typeof validation === 'undefined') { - resolve(); - return; - } - - const validationErrorResponse = ERROR_TYPES.VALIDATION_ERROR; - validationErrorResponse.setBodyVariable('validation_errors', validation); - - reject(validationErrorResponse); - }); - } -} diff --git a/src/Model/SQS/Message.model.js b/src/Model/SQS/Message.model.js deleted file mode 100644 index 2bd6c087..00000000 --- a/src/Model/SQS/Message.model.js +++ /dev/null @@ -1,88 +0,0 @@ -import Model from '../Model.model'; - -/** - * Message Model - */ -export default class Message extends Model { - /** - * Message constructor - * - * @param message - */ - constructor(message) { - super(); - - this.messageId = message.MessageId; - this.receiptHandle = message.ReceiptHandle; - - this.body = JSON.parse(message.Body); - this.forDeletion = false; - this.metadata = {}; - } - - /** - * Get Message ID - * - * @returns {*} - */ - getMessageId() { - return this.messageId; - } - - /** - * Get Receipt Handle - * - * @returns {*} - */ - getReceiptHandle() { - return this.receiptHandle; - } - - /** - * Get Body - * - * @returns {any | *} - */ - getBody() { - return this.body; - } - - /** - * Set for deletion status - * - * @param forDeletion - */ - setForDeletion(forDeletion: boolean) { - this.forDeletion = forDeletion; - } - - /** - * Whether message is for deletion - * - * @returns {boolean|*} - */ - isForDeletion() { - return this.forDeletion; - } - - /** - * Get all of the message metadata - * - * @returns {{}} - */ - getMetaData() { - return this.metadata; - } - - /** - * Set message metadata value - * - * @param key - * @param value - */ - setMetaData(key, value) { - this.metadata[key] = value; - - return this; - } -} diff --git a/src/Model/Status.model.js b/src/Model/Status.model.js deleted file mode 100644 index cc6b0190..00000000 --- a/src/Model/Status.model.js +++ /dev/null @@ -1,65 +0,0 @@ -import Model from './Model.model'; - -export const STATUS_TYPES = { - OK: 'OK', - ACCEPTABLE_FAILURE: 'ACCEPTABLE_FAILURE', - APPLICATION_FAILURE: 'APPLICATION_FAILURE', -}; - -/** - * StatusModel Class - */ -export default class StatusModel extends Model { - /** - * StatusModel constructor - * - * @param service - * @param status - */ - constructor(service: string, status: string) { - super(); - - this.setService(service); - this.setStatus(status); - } - - /** - * Get Service - * - * @returns {*} - */ - getService(): string { - return this.service; - } - - /** - * Set Service - * - * @param service - */ - setService(service: string) { - this.service = service; - } - - /** - * Set the status - * - * @param status - */ - setStatus(status: string) { - if (typeof STATUS_TYPES[status] === 'undefined') { - throw new TypeError(`${StatusModel.name} - ${status} is not a valid status type`); - } - - this.status = status; - } - - /** - * Get status - * - * @returns {string|*} - */ - getStatus(): string { - return this.status; - } -} diff --git a/src/Service/BaseConfig.service.js b/src/Service/BaseConfig.service.js deleted file mode 100644 index 65dc2ba2..00000000 --- a/src/Service/BaseConfig.service.js +++ /dev/null @@ -1,204 +0,0 @@ -import { S3 } from 'aws-sdk'; - -import DependencyAwareClass from '../DependencyInjection/DependencyAware.class'; -import LambdaTermination from '../Wrapper/LambdaTermination'; -/** - * Error.code for S3 404 errors - */ -export const S3_NO_SUCH_KEY_ERROR_CODE = 'NoSuchKey'; - -/** - * Represents the service states - */ -export const ServiceStates = { - OK: 'OK', - TEMPORARILY_PAUSED: 'TEMPORARILY_PAUSED', - INDEFINITELY_PAUSED: 'INDEFINITELY_PAUSED', -}; - -/** - * Maps service states to HTTP codes - */ -export const ServiceStatesHttpCodes = { - [ServiceStates.OK]: 200, - [ServiceStates.TEMPORARILY_PAUSED]: 409, - [ServiceStates.INDEFINITELY_PAUSED]: 409, -}; - -/** - * BaseConfigService class - * - * This class is to be extended by the implementing services - * so that `defaultConfig` and possibly `s3Config` can be - * overriden / extended. - */ -export default class BaseConfigService extends DependencyAwareClass { - /** - * Returns the basic config. - * This getter is used to set the default config - * should the service not find any - * on the configured S3 Bucket. - * - * See `getOrCreate` and `patch` methods. - */ - static get defaultConfig() { - return { - state: ServiceStates.OK, - }; - } - - /** - * Returns the S3 configuration - * used to retrieve / update the - * service configuration. - */ - static get s3config() { - return { - Bucket: process.env.SERVICE_CONFIG_S3_BUCKET, - Key: process.env.SERVICE_CONFIG_S3_KEY, - }; - } - - /** - * Returns an S3 client - * - * @returns {S3} - */ - static get client() { - return new S3({ - region: process.env.REGION, - }); - } - - /** - * Returns an S3 client - * - * @returns {S3} - */ - get client() { - return this.constructor.client; - } - - /** - * Deletes the configuration stored on S3. - * Helpful in feature tests. - */ - async delete() { - return this.client.deleteObject(this.constructor.s3config).promise(); - } - - /** - * Puts the given configuration on S3 - * - * @param config - */ - async put(config) { - await this.client.putObject({ - ...this.constructor.s3config, - Body: JSON.stringify(config), - }) - .promise(); - - return config; - } - - /** - * Gets the service configuration. - */ - async get() { - const response = await this.client.getObject(this.constructor.s3config).promise(); - const body = String(response.Body); - - if (!body) { - // Empty strings are not valid configurations - throw new Error('Configuration file is empty'); - } - - try { - return JSON.parse(body); - } catch { - throw new Error('Invalid configuration file'); - } - } - - /** - * Gets or creates the service configuration. - * - * If the configuration is not found on S3 - * the default configuration - * is uploaded and returned instead. - */ - async getOrCreate() { - try { - return await this.get(); - } catch (error) { - if (error.code !== S3_NO_SUCH_KEY_ERROR_CODE) { - // Throw directly any other error - throw error; - } - - return this.put(this.constructor.defaultConfig); - } - } - - /** - * Patches the existing configuration - * or the default configuration - * with the provided partial configuration - * - * @param partialConfig - */ - async patch(partialConfig) { - let base = this.constructor.defaultConfig; - - try { - base = await this.get(); - } catch (error) { - if (error.code !== S3_NO_SUCH_KEY_ERROR_CODE) { - // Throw directly any other error - throw error; - } - - // Config doesn't exist - // keep using `this.constructor.defaultConfig` - } - - return this.put({ - ...base, - ...partialConfig, - }); - } - - /** - * Performs a health check - * given the currentConfig. - * - * If currentConfig is not supplied - * it uses `getOrCreate` to fetch it. - * - * @param currentConfig - */ - async healthCheck(currentConfig = null) { - const config = currentConfig || await this.getOrCreate(); - - return ServiceStatesHttpCodes[config.state] || 500; - } - - /** - * Ensures that the application is healthy - * or throws a LambdaTermination - * - * @param currentConfig - */ - async ensureHealthy(currentConfig = null) { - const statusCode = await this.healthCheck(currentConfig); - - if (statusCode < 400) { - return statusCode; - } - - const message = 'Application is not healthy.'; - - throw new LambdaTermination(message, statusCode, message, message); - } -} diff --git a/src/Service/HTTP.service.js b/src/Service/HTTP.service.js deleted file mode 100644 index ca6aabac..00000000 --- a/src/Service/HTTP.service.js +++ /dev/null @@ -1,51 +0,0 @@ -import axios, { AxiosRequestConfig, AxiosResponse } from 'axios'; - -import DependencyAwareClass from '../DependencyInjection/DependencyAware.class'; - -export const COMICRELIEF_TEST_METADATA_HEADER = 'x-comicrelief-test-metadata'; -export const DEFAULT_HTTP_TIMEOUT = 10 * 1000; - -/** - * HTTPService class - */ -export default class HTTPService extends DependencyAwareClass { - constructor(di) { - super(di); - - this.config = { - timeout: DEFAULT_HTTP_TIMEOUT, - }; - } - - /** - * Sets the default timeout - * - * @param {number} ms - */ - setDefaultTimeout(ms) { - this.config.timeout = ms; - } - - /** - * Performs and HTTP Request - * - * @param config - */ - async request(config: AxiosRequestConfig): Promise { - const mergedConfig = { - timeout: this.config.timeout, - headers: {}, - ...config, - }; - - const lambdaRequest = this.getContainer().get(this.definitions.REQUEST); - const testMetadata = lambdaRequest.getHeader(COMICRELIEF_TEST_METADATA_HEADER); - - if (testMetadata) { - mergedConfig.headers = mergedConfig.headers || {}; - mergedConfig.headers[COMICRELIEF_TEST_METADATA_HEADER] = testMetadata; - } - - return axios.request(mergedConfig); - } -} diff --git a/src/Service/Logger.service.js b/src/Service/Logger.service.js deleted file mode 100644 index 18eb144b..00000000 --- a/src/Service/Logger.service.js +++ /dev/null @@ -1,284 +0,0 @@ -import * as Sentry from '@sentry/node'; -import Epsagon from 'epsagon'; -import Winston from 'winston'; - -import DependencyAwareClass from '../DependencyInjection/DependencyAware.class'; -import DependencyInjection from '../DependencyInjection/DependencyInjection.class'; - -// Instantiate the sentry client -const sentryIsAvailable = typeof process.env.RAVEN_DSN !== 'undefined' && typeof process.env.RAVEN_DSN === 'string' && process.env.RAVEN_DSN !== 'undefined'; - -if (sentryIsAvailable) { - Sentry.init({ - dsn: process.env.RAVEN_DSN, - shutdownTimeout: 5, - environment: process.env.STAGE, - }); -} - -/** - * LoggerService class - */ -export default class LoggerService extends DependencyAwareClass { - constructor(di: DependencyInjection) { - super(di); - this.sentry = null; - this.winston = null; - - const container = this.getContainer(); - const event = container.getEvent(); - const context = container.getContext(); - - // Set sentry client context - if (sentryIsAvailable && !container.isOffline) { - Sentry.configureScope((scope) => { - scope.setTags({ - Event: event, - Context: context, - }); - scope.setExtras({ - lambda: context.functionName, - memory_size: context.memoryLimitInMB, - log_group: context.log_group_name, - log_stream: context.log_stream_name, - stage: process.env.STAGE, - path: event.path, - httpMethod: event.httpMethod, - }); - }); - - this.sentry = Sentry; - } - } - - /** - * Returns a Winston logger object - * configured for our lambdas. - * - * Note: - * - * If the lambda is executed - * in a `serverless-offline` context - * the log output to console will be pretty printed. - */ - getLogger() { - const loggerFormats = [ - Winston.format.json({ - replacer: (key, value) => { - if (value instanceof Buffer) { - return value.toString('base64'); - } - if (value instanceof Error) { - const error = {}; - - Object.getOwnPropertyNames(value).forEach((objectKey) => { - error[objectKey] = value[objectKey]; - }); - - return error; - } - - return value; - }, - }), - ]; - - if (this.getContainer().isOffline) { - loggerFormats.push(Winston.format.prettyPrint()); - } - - return Winston.createLogger({ - level: 'info', - format: Winston.format.combine(...loggerFormats), - transports: [new Winston.transports.Console()], - }); - } - - /** - * Returns the logger. - * - * Uses a cached `Winston` object - * if it has been already generated, - * otherwise it generates one. - */ - get logger() { - if (!this.winston) { - this.winston = this.getLogger(); - } - - return this.winston; - } - - /** - * While handling an error, lambda wrapper should - * recognise axios errors and trim down the information. - * - * Keep the following keys: - * - message.config - * - message.message - * - message.response?.status - * - message.response?.data - * - * @param {object} error - */ - static processAxiosError(error) { - const processed = { - config: error.config, - message: error.message, - }; - - // It's pretty common for axios errors - // to not have.response e.g.when there's - // a network error or timeout. - // These errors will have .request but not .response. - if (error.response) { - processed.response = { - status: error.response.status, - data: error.response.data, - }; - } - - return processed; - } - - /** - * Transform the original message - * before it is passed to the winston logger - * - * @param {string|object} message - */ - processMessage(message = '') { - let processed = message; - - if (processed && processed.isAxiosError) { - processed = this.constructor.processAxiosError(processed); - } - - return processed; - } - - /** - * Log Error Message - * - * @param error object - * @param message string - */ - error(error, message = '') { - if (sentryIsAvailable && error instanceof Error) { - Sentry.captureException(error); - } - - if ( - typeof process.env.EPSAGON_TOKEN === 'string' - && process.env.EPSAGON_TOKEN !== 'undefined' - && typeof process.env.EPSAGON_SERVICE_NAME === 'string' - && process.env.EPSAGON_SERVICE_NAME !== 'undefined' - && error instanceof Error - ) { - Epsagon.setError(error); - } - - this.logger.log('error', message, { error: this.processMessage(error) }); - this.label('error', true); - this.metric('error', 'error', true); - } - - /** - * Get sentry client - * - * @returns {null|*} - */ - getSentry() { - return this.sentry; - } - - /** - * Log Information Message - * - * @param message string - */ - info(message) { - this.logger.log('info', this.processMessage(message)); - } - - /** - * Logs an error, using `LoggerService.error` - * or `LoggerService.info` based on - * `process.env.LOGGER_SOFT_WARNING`. - * - * Please note that `LoggerService.error` and `LoggerService.info` - * have different signatures. The function uses the shared argument - * instead of introducing ambiguity. - * - * @param error - */ - warning(error) { - const softWarningValues = ['true', '1']; - - if (softWarningValues.includes(process.env.LOGGER_SOFT_WARNING)) { - return this.info(error); - } - - return this.error(error); - } - - /** - * Add a label - * - * @param descriptor string - * @param silent boolean - */ - label(descriptor, silent = false) { - if ( - typeof process.env.EPSAGON_TOKEN === 'string' - && process.env.EPSAGON_TOKEN !== 'undefined' - && typeof process.env.EPSAGON_SERVICE_NAME === 'string' - && process.env.EPSAGON_SERVICE_NAME !== 'undefined' - ) { - Epsagon.label(descriptor, true); - } - - if (silent === false) { - this.logger.log('info', `label - ${descriptor}`); - } - } - - /** - * Add a metric - * - * @param descriptor string - * @param stat integer | string - * @param silent boolean - */ - metric(descriptor, stat, silent = false) { - if ( - typeof process.env.EPSAGON_TOKEN === 'string' - && process.env.EPSAGON_TOKEN !== 'undefined' - && typeof process.env.EPSAGON_SERVICE_NAME === 'string' - && process.env.EPSAGON_SERVICE_NAME !== 'undefined' - ) { - Epsagon.label(descriptor, stat); - } - - if (silent === false) { - this.logger.log('info', `metric - ${descriptor} - ${stat}`); - } - } - - /** - * Logs an object so that it can be inspected - * - * @param action - What are we doing with the object, i.e. 'Processing' - * @param object - The object to be stored in logs - * @param level - 'error', 'warning' or 'info' - */ - object(action, object, level = 'info') { - if (!(['error', 'warning', 'info'].includes(level))) { - throw new Error('Unrecognised log level'); - } - - const payload = JSON.stringify(object, null, 4); - - return this[level](`${action}: '${payload}'`); - } -} diff --git a/src/Service/Request.service.js b/src/Service/Request.service.js deleted file mode 100644 index b6ad5533..00000000 --- a/src/Service/Request.service.js +++ /dev/null @@ -1,333 +0,0 @@ -/* eslint-disable class-methods-use-this */ -/* eslint-disable sonarjs/no-duplicate-string */ -/* @flow */ - -import QueryString from 'querystring'; - -import useragent from 'useragent'; -import validate from 'validate.js/validate'; -import XML2JS from 'xml2js'; - -import { DEFINITIONS } from '../Config/Dependencies'; -import DependencyAwareClass from '../DependencyInjection/DependencyAware.class'; -import ResponseModel from '../Model/Response.model'; - -export const REQUEST_TYPES = { - DELETE: 'DELETE', - GET: 'GET', - HEAD: 'HEAD', - OPTIONS: 'OPTIONS', - PATCH: 'PATCH', - POST: 'POST', - PUT: 'PUT', -}; - -export const HTTP_METHODS_WITHOUT_PAYLOADS = [ - REQUEST_TYPES.DELETE, - REQUEST_TYPES.GET, - REQUEST_TYPES.HEAD, - REQUEST_TYPES.OPTIONS, -]; - -export const HTTP_METHODS_WITH_PAYLOADS = [ - REQUEST_TYPES.PATCH, - REQUEST_TYPES.POST, - REQUEST_TYPES.PUT, -]; - -// Define action specific error types -export const ERROR_TYPES = { - VALIDATION_ERROR: new ResponseModel({}, 400, 'required fields are missing'), -}; - -/** - * RequestService class - */ -export default class RequestService extends DependencyAwareClass { - /** - * Get a parameter from the request. - * - * @param parameter - * @param ifNull - * @param requestType - */ - get(parameter: string, ifNull = null, requestType = null) { - const queryParameters = this.getAll(requestType); - - if (queryParameters === null) { - return ifNull; - } - - return typeof queryParameters[parameter] !== 'undefined' ? queryParameters[parameter] : ifNull; - } - - /** - * Get all HTTP headers included in the request. - * - * @returns {object} An object with a key for each header. - */ - getAllHeaders() { - return { ...this.getContainer().getEvent().headers }; - } - - /** - * Get an HTTP header from the request. - * - * The header name is case-insensitive. - * - * @param {string} name The name of the header. - * @param {string} [whenMissing] Value to return if the header is missing. - * (default: empty string) - * @returns {string} - */ - getHeader(name: string, whenMissing: string = '') { - const headers = this.getAllHeaders(); - if (!headers) { - return whenMissing; - } - const lowerName = name.toLowerCase(); - const key = Object.keys(headers).find((k) => k.toLowerCase() === lowerName); - return (key && headers[key]) || whenMissing; - } - - /** - * Get authorization token - * - * @returns {*} - */ - getAuthorizationToken() { - const authorization = this.getHeader('Authorization'); - if (!authorization) { - return null; - } - - const tokenParts = authorization.split(' '); - const tokenValue = tokenParts[1]; - - if (!(tokenParts[0].toLowerCase() === 'bearer' && tokenValue)) { - return null; - } - - return tokenValue; - } - - /** - * Get a path parameter - * - * @param parameter - * @param ifNull mixed - */ - getPathParameter(parameter: string = null, ifNull = {}) { - const event = this.getContainer().getEvent(); - - // If no parameter has been requested, return all path parameters - if (parameter === null && typeof event.pathParameters === 'object') { - return event.pathParameters; - } - - // If a specifc parameter has been requested, return the parameter if it exists - if ( - typeof parameter === 'string' - && typeof event.pathParameters === 'object' - && event.pathParameters !== null - && typeof event.pathParameters[parameter] !== 'undefined' - ) { - return event.pathParameters[parameter]; - } - - return ifNull; - } - - /** - * Get all request parameters - * - * @param requestType - * @returns {{}} - */ - // eslint-disable-next-line sonarjs/cognitive-complexity - getAll(requestType = null) { - const event = this.getContainer().getEvent(); - - if (HTTP_METHODS_WITHOUT_PAYLOADS.includes(event.httpMethod) || HTTP_METHODS_WITHOUT_PAYLOADS.includes(requestType)) { - // get simple parameters - const params = { ...event.queryStringParameters }; - // add array parameters as arrays - Object.keys(params) - .filter((key) => key.endsWith('[]')) - .forEach((key) => { - params[key] = event.multiValueQueryStringParameters[key]; - }); - return params; - } - - if (HTTP_METHODS_WITH_PAYLOADS.includes(event.httpMethod) || HTTP_METHODS_WITH_PAYLOADS.includes(requestType)) { - const contentType = this.getHeader('Content-Type'); - let queryParameters = {}; - - if (contentType.includes('application/x-www-form-urlencoded')) { - queryParameters = QueryString.parse(event.body); - } - - if (contentType.includes('application/json')) { - try { - queryParameters = JSON.parse(event.body); - } catch { - queryParameters = {}; - } - } - - if (contentType.includes('text/xml')) { - XML2JS.parseString(event.body, (error, result) => { - queryParameters = error ? {} : result; - }); - } - - if (contentType.includes('multipart/form-data')) { - queryParameters = this.parseForm(true); - } - - return typeof queryParameters !== 'undefined' ? queryParameters : {}; - } - - return null; - } - - /** - * Fetch the request IP address - * - * @returns {*} - */ - getIp() { - const event = this.getContainer().getEvent(); - - if ( - typeof event.requestContext !== 'undefined' - && typeof event.requestContext.identity !== 'undefined' - && typeof event.requestContext.identity.sourceIp !== 'undefined' - ) { - return event.requestContext.identity.sourceIp; - } - - return null; - } - - /** - * Get user agent - * - * @returns {*} - */ - getUserBrowserAndDevice() { - const userAgent = this.getHeader('user-agent', null); - if (userAgent === null) { - return null; - } - - try { - const agent = useragent.parse(userAgent); - const os = agent.os.toJSON(); - - return { - 'browser-type': agent.family, - 'browser-version': agent.toVersion(), - 'device-type': agent.device.family, - 'operating-system': os.family, - 'operating-system-version': agent.os.toVersion(), - }; - } catch { - this.getContainer().get(DEFINITIONS.LOGGER).label('user-agent-parsing-failed'); - - return null; - } - } - - /** - * Test a request against validation constraints - * - * @param constraints - * @returns {Promise} - */ - validateAgainstConstraints(constraints: object) { - const Logger = this.getContainer().get(DEFINITIONS.LOGGER); - - return new Promise((resolve, reject) => { - const validation = validate(this.getAll(), constraints); - - if (typeof validation === 'undefined') { - resolve(); - } else { - Logger.label('request-validation-failed'); - const validationErrorResponse = ERROR_TYPES.VALIDATION_ERROR; - validationErrorResponse.setBodyVariable('validation_errors', validation); - - reject(validationErrorResponse); - } - }); - } - - /** - * Fetch the request multipart form - * - * @param useBuffer - * @returns {*} - */ - parseForm(useBuffer: boolean) { - const event = this.getContainer().getEvent(); - const boundary = this.getBoundary(event); - - const body = event.isBase64Encoded ? Buffer.from(event.body, 'base64').toString('binary').trim() : event.body; - - const result = {}; - body.split(boundary).forEach((item) => { - if (/filename=".+"/g.test(item)) { - result[item.match(/name=".+";/g)[0].slice(6, -2)] = { - type: 'file', - filename: item.match(/filename=".+"/g)[0].slice(10, -1), - contentType: item.match(/Content-Type:\s.+/g)[0].slice(14), - content: useBuffer - ? Buffer.from(item.slice(item.search(/Content-Type:\s.+/g) + item.match(/Content-Type:\s.+/g)[0].length + 4, -4), 'binary') - : item.slice(item.search(/Content-Type:\s.+/g) + item.match(/Content-Type:\s.+/g)[0].length + 4, -4), - }; - } else if (/name=".+"/g.test(item)) { - result[item.match(/name=".+"/g)[0].slice(6, -1)] = item.slice(item.search(/name=".+"/g) + item.match(/name=".+"/g)[0].length + 4, -4); - } - }); - return result; - } - - /** - * Fetch the request AWS event Records - * - * @returns {*} - */ - getAWSRecords() { - const event = this.getContainer().getEvent(); - const eventRecord = event.Records && event.Records[0]; - - if (typeof event.Records !== 'undefined' && typeof event.Records[0] !== 'undefined' && typeof eventRecord.eventSource !== 'undefined') { - return eventRecord; - } - return null; - } - - /** - * Gets a value independently from - * the case of the key - * - * @param object - * @param key - */ - getValueIgnoringKeyCase(object, key) { - const foundKey = Object.keys(object).find((currentKey) => currentKey.toLocaleLowerCase() === key.toLowerCase()); - return object[foundKey]; - } - - /** - * Returns the content type - * assoiated with the request - * - * @param event - */ - getBoundary(event) { - return this.getValueIgnoringKeyCase(event.headers, 'Content-Type').split('=')[1]; - } -} diff --git a/src/Service/SQS.service.js b/src/Service/SQS.service.js deleted file mode 100644 index 1d4dbbf2..00000000 --- a/src/Service/SQS.service.js +++ /dev/null @@ -1,413 +0,0 @@ -/* @flow */ -import alai from 'alai'; -import each from 'async/each'; -import AWS from 'aws-sdk'; -import { v4 as UUID } from 'uuid'; - -import { DEFINITIONS } from '../Config/Dependencies'; -import DependencyAwareClass from '../DependencyInjection/DependencyAware.class'; -import DependencyInjection from '../DependencyInjection/DependencyInjection.class'; -import SQSMessageModel from '../Model/SQS/Message.model'; -import StatusModel, { STATUS_TYPES } from '../Model/Status.model'; - -/** - * Allowed values for `process.env.LAMBDA_WRAPPER_OFFLINE_SQS_MODE`. - */ -export const SQS_OFFLINE_MODES = { - /** - * When running offline, messages will trigger the consumer function directly - * via a Lambda endpoint, set using `process.env.SERVICE_LAMBDA_URL`. This is - * the default. - */ - DIRECT: 'direct', - - /** - * When running offline, send messages to an offline SQS service defined by - * `process.env.LAMBDA_WRAPPER_OFFLINE_SQS_HOST`. - */ - LOCAL: 'local', - - /** - * When running offline, send messages to AWS as normal. - */ - AWS: 'aws', -}; - -/** - * Defines the preferred behaviour - * for SQSService.prototype.publish - * should AWS SQS fail. - */ -export const SQS_PUBLISH_FAILURE_MODES = { - /** - * Catches the exception and logs it. - * This is the default behaviour - * for LambdaWrapper 1.8.0 and below - * and for LambdaWrapper 1.8.2 and above - */ - CATCH: 'catch', - - /** - * Throws the exception so that the caller - * can handle it directly. - */ - THROW: 'throw', -}; - -/** - * SQSService class - */ -export default class SQSService extends DependencyAwareClass { - /** - * SQSService constructor - * - * @param di DependencyInjection - */ - constructor(di: DependencyInjection) { - super(di); - - const { - LAMBDA_WRAPPER_OFFLINE_SQS_HOST: offlineHost = 'localhost', - LAMBDA_WRAPPER_OFFLINE_SQS_PORT: offlinePort = '4576', - LAMBDA_WRAPPER_OFFLINE_SQS_MODE: offlineMode = SQS_OFFLINE_MODES.DIRECT, - AWS_ACCOUNT_ID, - REGION, - } = process.env; - - const container = this.getContainer(); - const context = container.getContext(); - const queues = container.getConfiguration('QUEUES'); - const accountId = (context && context.invokedFunctionArn && alai.parse(context)) || AWS_ACCOUNT_ID; - - this.queues = {}; - - this.$lambda = null; - this.$sqs = null; - - if (container.isOffline && !Object.values(SQS_OFFLINE_MODES).includes(offlineMode)) { - throw new Error(`Invalid LAMBDA_WRAPPER_OFFLINE_SQS_MODE: ${offlineMode}\n` - + `Please use one of: ${Object.values(SQS_OFFLINE_MODES).join(', ')}`); - } - - // Add the queues from configuration - if (queues !== null && Object.keys(queues).length > 0) { - Object.keys(queues).forEach((queueDefinition) => { - if (container.isOffline && offlineMode === SQS_OFFLINE_MODES.LOCAL) { - // custom URL when using an offline SQS service such as Localstack - this.queues[queueDefinition] = `http://${offlineHost}:${offlinePort}/queue/${queues[queueDefinition]}`; - } else { - // default AWS queue URL - this.queues[queueDefinition] = `https://sqs.${REGION}.amazonaws.com/${accountId}/${queues[queueDefinition]}`; - } - }); - } - } - - /** - * Returns an SQS client instance - */ - get sqs() { - if (!this.$sqs) { - this.$sqs = new AWS.SQS({ - region: process.env.REGION, - httpOptions: { - // longest publish on NOTV took 5 seconds - connectTimeout: 8 * 1000, - timeout: 8 * 1000, - }, - maxRetries: 3, // default is 3, we can change that - }); - } - - return this.$sqs; - } - - /** - * Returns a Lambda client instance - */ - get lambda() { - if (!this.$lambda) { - const endpoint = process.env.SERVICE_LAMBDA_URL; - - if (!endpoint) { - throw new Error('process.env.SERVICE_LAMBDA_URL must be defined.'); - } - - // move to subprocess - this.$lambda = new AWS.Lambda({ - region: process.env.AWS_REGION, - endpoint, - }); - } - - return this.$lambda; - } - - /** - * Returns the mode to use for offline SQS. - * - * This is configured by `process.env.LAMBDA_WRAPPER_OFFLINE_SQS_MODE`. The - * default is `SQS_OFFLINE_MODES.LAMBDA`. - */ - static get offlineMode() { - return process.env.LAMBDA_WRAPPER_OFFLINE_SQS_MODE || SQS_OFFLINE_MODES.DIRECT; - } - - /** - * Batch delete messages - * - * @param queue strung - * @param messageModels [SQSMessageModel] - * @returns {Promise} - */ - batchDelete(queue: string, messageModels: [SQSMessageModel]) { - const container = this.getContainer(); - const queueUrl = this.queues[queue]; - const Logger = container.get(DEFINITIONS.LOGGER); - const Timer = container.get(DEFINITIONS.TIMER); - const timerId = `sqs-batch-delete-${UUID()} - Queue: '${queueUrl}'`; - - return new Promise((resolve) => { - const messagesForDeletion = []; - - Timer.start(timerId); - // assuming openFiles is an array of file names - each( - messageModels, - (messageModel, callback) => { - if (messageModel instanceof SQSMessageModel && messageModel.isForDeletion() === true) { - messagesForDeletion.push({ - Id: messageModel.getMessageId(), - ReceiptHandle: messageModel.getReceiptHandle(), - }); - } - callback(); - }, - (loopError) => { - if (loopError) { - Logger.error(loopError); - resolve(); - } - - this.sqs.deleteMessageBatch( - { - Entries: messagesForDeletion, - QueueUrl: queueUrl, - }, - (error) => { - Timer.stop(timerId); - - if (error) { - Logger.error(error); - } - - resolve(); - }, - ); - }, - ); - }); - } - - /** - * Check SQS status - * - * @returns {Promise} - */ - checkStatus() { - const container = this.getContainer(); - const Logger = container.get(DEFINITIONS.LOGGER); - const Timer = container.get(DEFINITIONS.TIMER); - const timerId = `sqs-list-queues-${UUID()}`; - - return new Promise((resolve) => { - Timer.start(timerId); - - this.sqs.listQueues({}, (error, data) => { - Timer.stop(timerId); - - const statusModel = new StatusModel('SQS', STATUS_TYPES.OK); - - if (error) { - Logger.error(error); - statusModel.setStatus(STATUS_TYPES.APPLICATION_FAILURE); - } - - if (typeof data.QueueUrls === 'undefined' || data.QueueUrls.length === 0) { - statusModel.setStatus(STATUS_TYPES.APPLICATION_FAILURE); - } - - resolve(statusModel); - }); - }); - } - - /** - * Get number of messages in a queue - * - * @param queue - * @returns {Promise} - */ - getMessageCount(queue: string) { - const container = this.getContainer(); - const queueUrl = this.queues[queue]; - const Logger = container.get(DEFINITIONS.LOGGER); - const Timer = container.get(DEFINITIONS.TIMER); - const timerId = `sqs-get-queue-attributes-${UUID()} - Queue: '${queueUrl}'`; - - return new Promise((resolve) => { - Timer.start(timerId); - - this.sqs.getQueueAttributes( - { - AttributeNames: ['ApproximateNumberOfMessages'], - QueueUrl: queueUrl, - }, - (error, data) => { - Timer.stop(timerId); - - if (error) { - Logger.error(error); - resolve(0); - } - - resolve(Number.parseInt(data.Attributes.ApproximateNumberOfMessages, 10)); - }, - ); - }); - } - - /** - * Publish to message queue - * - * When running within serverless-offline, messages can be published to a - * local Lambda or SQS service instead of to AWS, depending on the offline - * mode specified by `process.env.LAMBDA_WRAPPER_OFFLINE_SQS_MODE`. - * - * @param queue string - * @param messageObject object - * @param messageGroupId string - * @param {'catch' | 'throw'} failureMode Choose how failures are handled: - * - `catch`: errors will be caught and logged. This is the default. - * - `throw`: errors will be thrown, causing promise to reject. - * @returns {Promise} - */ - async publish(queue: string, messageObject: object, messageGroupId = null, failureMode = SQS_PUBLISH_FAILURE_MODES.CATCH) { - if (!Object.values(SQS_PUBLISH_FAILURE_MODES).includes(failureMode)) { - throw new Error(`Invalid value for 'failureMode': ${failureMode}`); - } - - const container = this.getContainer(); - const queueUrl = this.queues[queue]; - const Timer = container.get(DEFINITIONS.TIMER); - const timerId = `sqs-send-message-${UUID()} - Queue: '${queueUrl}'`; - - Timer.start(timerId); - - const messageParameters = { - MessageBody: JSON.stringify(messageObject), - QueueUrl: queueUrl, - }; - - if (queueUrl.includes('.fifo')) { - messageParameters.MessageDeduplicationId = UUID(); - messageParameters.MessageGroupId = messageGroupId !== null ? messageGroupId : UUID(); - } - - try { - if (container.isOffline && this.constructor.offlineMode === SQS_OFFLINE_MODES.DIRECT) { - await this.publishOffline(queue, messageParameters); - } else { - await this.sqs.sendMessage(messageParameters).promise(); - } - } catch (error) { - if (failureMode === SQS_PUBLISH_FAILURE_MODES.CATCH) { - container.get(DEFINITIONS.LOGGER).error(error); - return null; - } - throw error; - } - - return queue; - } - - /** - * Sends a message to a queue consumer running in serverless-offline. - * - * This method invokes the consumer function directly instead of sending the - * message to SQS, which requires a real or emulated SQS service not provided - * by serverless-offline. This works very well for local testing. - * - * @param queue - * @param messageParameters - */ - async publishOffline(queue: string, messageParameters) { - const container = this.getContainer(); - - if (!container.isOffline) { - throw new Error('Can only publishOffline while running serverless offline.'); - } - - const consumers = container.getConfiguration('QUEUE_CONSUMERS') || {}; - const FunctionName = consumers[queue]; - - if (!FunctionName) { - throw new Error(`Queue consumer for queue ${queue} was not found. Please configure your application's QUEUE_CONSUMERS.`); - } - - const InvocationType = 'RequestResponse'; - - const Payload = JSON.stringify({ - Records: [ - { - body: messageParameters.MessageBody, - }, - ], - }); - - const parameters = { FunctionName, InvocationType, Payload }; - - await this.lambda.invoke(parameters).promise(); - } - - /** - * Receive from message queue - * - * @param queue string - * @param timeout number - * @returns {Promise} - */ - receive(queue: string, timeout: number = 15) { - const container = this.getContainer(); - const queueUrl = this.queues[queue]; - const Logger = container.get(DEFINITIONS.LOGGER); - const Timer = container.get(DEFINITIONS.TIMER); - const timerId = `sqs-receive-message-${UUID()} - Queue: '${queueUrl}'`; - - return new Promise((resolve, reject) => { - Timer.start(timerId); - - this.sqs.receiveMessage( - { - QueueUrl: queueUrl, - VisibilityTimeout: timeout, - MaxNumberOfMessages: 10, - }, - (error, data) => { - Timer.stop(timerId); - - if (error) { - Logger.error(error); - return reject(error); - } - - if (typeof data.Messages === 'undefined') { - return resolve([]); - } - - return resolve(data.Messages.map((message) => new SQSMessageModel(message))); - }, - ); - }); - } -} diff --git a/src/Service/Timer.service.js b/src/Service/Timer.service.js deleted file mode 100644 index 5d544444..00000000 --- a/src/Service/Timer.service.js +++ /dev/null @@ -1,40 +0,0 @@ -import { DEFINITIONS } from '../Config/Dependencies'; -import DependencyAwareClass from '../DependencyInjection/DependencyAware.class'; -import DependencyInjection from '../DependencyInjection/DependencyInjection.class'; - -/** - * TimerService class - */ -export default class TimerService extends DependencyAwareClass { - /** - * TimerService constructor - * - * @param di - */ - constructor(di: DependencyInjection) { - super(di); - this.timers = {}; - } - - /** - * Start timer - * - * @param identifier - */ - start(identifier: string) { - this.timers[identifier] = Date.now(); - } - - /** - * Stop timer - * - * @param identifier - */ - stop(identifier: string) { - if (typeof this.timers[identifier] !== 'undefined') { - const duration = Date.now() - this.timers[identifier]; - - this.getContainer().get(DEFINITIONS.LOGGER).info(`Timing - ${identifier} took ${duration}ms to complete`); - } - } -} diff --git a/src/Wrapper/LambdaTermination.js b/src/Wrapper/LambdaTermination.js deleted file mode 100644 index 09d05cfc..00000000 --- a/src/Wrapper/LambdaTermination.js +++ /dev/null @@ -1,29 +0,0 @@ -/** - * - */ -export default class LambdaTermination extends Error { - /** - * Triggers a Lambda Termination. - * Offers developer details (that are logged) - * an code for the Lambda and a front facing - * consumer message. - * - * @param {object|string} internal - * @param {number?} code - * @param {object|string?} body - * @param details - */ - constructor(internal, code = 500, body = null, details = 'unknown error') { - let stringified = internal; - - if (typeof internal !== 'string') { - stringified = JSON.stringify(internal); - } - super(stringified); - - this.internal = internal; - this.code = code; - this.body = body || 'unknown error'; - this.details = details; - } -} diff --git a/src/Wrapper/LambdaWrapper.js b/src/Wrapper/LambdaWrapper.js deleted file mode 100644 index 9e31abbb..00000000 --- a/src/Wrapper/LambdaWrapper.js +++ /dev/null @@ -1,156 +0,0 @@ -/* eslint-disable sonarjs/cognitive-complexity */ -import Epsagon from 'epsagon'; - -import { DEFINITIONS } from '../Config/Dependencies'; -import DependencyInjection from '../DependencyInjection/DependencyInjection.class'; -import ResponseModel from '../Model/Response.model'; - -/** - * Processes the outcome once we have one - * - * @param di - * @param outcome - */ -export const handleSuccess = (di, outcome) => { - const logger = di.get(DEFINITIONS.LOGGER); - - // Outcome may be undefined as not all lambdas have a return value. - logger.metric('lambda.statusCode', (outcome && outcome.statusCode) || 200); - - return outcome; -}; - -/** - * Gracefully handles an error - * logging in Epsagon and generating - * a response reflecting the `code` - * of the error, if defined. - * - * Note about Epsagon: - * Epsagon generates alerts for logs on level ERROR. - * This means that logger.error will produce an alert. - * To avoid not meaningful notifications, most likely - * coming from tests, we log INFO unless either: - * - * 1. `error.raiseOnEpsagon` is defined & truthy - * 2. `error.code` is defined and `error.code >= 500`. - * - * @param {DependencyInjection} di - * @param {Error} error - * @param {boolean} [throwError=false] - */ -export const handleError = (di, error, throwError = false) => { - const logger = di.get(DEFINITIONS.LOGGER); - - logger.metric('lambda.statusCode', error.code || 500); - - if (error.raiseOnEpsagon || !error.code || error.code >= 500) { - logger.error(error); - } else { - logger.info(error); - } - - if (throwError) { - if (error instanceof Error) { - return error; - } - - // We want to be absolutely sure - // that we are returning an error - // as Lambda sync handlers will only fail - // if the object is instanceof Error - return new Error(error); - } - - const responseDetails = { - body: error.body || {}, - code: error.code || 500, - details: error.details || 'unknown error', - }; - - return ResponseModel.generate(responseDetails.body, responseDetails.code, responseDetails.details); -}; - -/** - * Lambda Wrapper. - * - * Wraps a lambda handler, generating a new function - * that has access to the dependency injection - * for the service and handles logging and exceptions. - * - * @param configuration - * @param handler - * @param throwError - */ -export default (configuration, handler, throwError = false) => { - let instance = (event, context, callback) => { - const di = new DependencyInjection(configuration, event, context); - const request = di.get(DEFINITIONS.REQUEST); - const logger = di.get(DEFINITIONS.LOGGER); - - context.callbackWaitsForEmptyEventLoop = false; - - // If the event is to trigger a warm up, then don't bother returning the function. - if (di.getEvent().source === 'serverless-plugin-warmup') { - return callback(null, 'Lambda is warm!'); - } - - // Log the users ip address silently for use in error tracing - if (request.getIp() !== null) { - logger.metric('ipAddress', request.getIp(), true); - } - - // Add metrics with user browser information for rapid debugging - const userBrowserAndDevice = request.getUserBrowserAndDevice(); - if (userBrowserAndDevice !== null && typeof userBrowserAndDevice === 'object') { - Object.keys(userBrowserAndDevice).forEach((metricKey) => { - logger.metric(metricKey, userBrowserAndDevice[metricKey], true); - }); - } - - let outcome; - - try { - outcome = handler.call(instance, di, request, callback); - - if (outcome instanceof Promise) { - outcome = outcome - .then((value) => handleSuccess(di, value)) - .catch((error) => { - const handled = handleError(di, error, throwError); - - if (throwError) { - // AWS Lambda with async handler is looking for a rejection - // and not an error object directly - // and will treat resolved errors as successful - // as it will cast the error to JSON, i.e. `{}` - throw handled; - } - - return handled; - }); - } - } catch (error) { - outcome = handleError(di, error, throwError); - } - - return outcome; - }; - - // If the Epsagon token is enabled, then wrap the instance in the Epsagon wrapper - if ( - typeof process.env.EPSAGON_TOKEN === 'string' - && process.env.EPSAGON_TOKEN !== 'undefined' - && typeof process.env.EPSAGON_SERVICE_NAME === 'string' - && process.env.EPSAGON_SERVICE_NAME !== 'undefined' - ) { - Epsagon.init({ - token: process.env.EPSAGON_TOKEN, - appName: process.env.EPSAGON_SERVICE_NAME, - }); - - instance = Epsagon.lambdaWrapper(instance); - } - - return instance; -}; diff --git a/src/Wrapper/PromisifiedDelay.js b/src/Wrapper/PromisifiedDelay.js deleted file mode 100644 index 94992ea9..00000000 --- a/src/Wrapper/PromisifiedDelay.js +++ /dev/null @@ -1,52 +0,0 @@ -const STANDARD_LATENCY_DELAYS = { - 2000: 70, - 3500: 15, - 4000: 10, - 5000: 5, -}; - -const HIGH_LATENCY_DELAYS = { - 2000: 65, - 3500: 15, - 4000: 9, - 5000: 5, - 10_000: 5, - 20_000: 1, -}; - -/** - * PromisifiedDelay class - */ -export default class PromisifiedDelay { - /** - * PromisifiedDelay constructor - * - * @param highLatency - */ - constructor(highLatency = true) { - this.delays = []; - - const delayArray = highLatency === true ? HIGH_LATENCY_DELAYS : STANDARD_LATENCY_DELAYS; - - Object.keys(delayArray).forEach((delayDuration) => { - const delayIterations = delayArray[delayDuration]; - - for (let i = 0; i < delayIterations; i += 1) { - this.delays.push(delayDuration); - } - }); - } - - /** - * Create a promisified delay - * - * @returns {Promise} - */ - get() { - return new Promise((resolve) => { - setTimeout(() => { - resolve(); - }, this.delays[Math.floor(Math.random() * this.delays.length)]); - }); - } -} diff --git a/src/index.js b/src/index.js deleted file mode 100644 index 81fc1775..00000000 --- a/src/index.js +++ /dev/null @@ -1,24 +0,0 @@ -export { DEFINITIONS } from './Config/Dependencies'; - -// DependencyInjection -export { default as DependencyAwareClass } from './DependencyInjection/DependencyAware.class'; -export { default as DependencyInjection } from './DependencyInjection/DependencyInjection.class'; - -// Model -export { default as Model } from './Model/Model.model'; -export { default as ResponseModel } from './Model/Response.model'; -export { default as StatusModel, STATUS_TYPES } from './Model/Status.model'; -export { default as SQSMessageModel } from './Model/SQS/Message.model'; -export { default as MarketingPreferenceModel } from './Model/SQS/MarketingPreference.model'; - -// Service -export { default as BaseConfigService } from './Service/BaseConfig.service'; -export { default as HTTPService, COMICRELIEF_TEST_METADATA_HEADER } from './Service/HTTP.service'; -export { default as LoggerService } from './Service/Logger.service'; -export { default as RequestService } from './Service/Request.service'; -export { default as SQSService, SQS_OFFLINE_MODES, SQS_PUBLISH_FAILURE_MODES } from './Service/SQS.service'; - -// Wrapper -export { default as LambdaTermination } from './Wrapper/LambdaTermination'; -export { default as LambdaWrapper } from './Wrapper/LambdaWrapper'; -export { default as PromisifiedDelay } from './Wrapper/PromisifiedDelay'; diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 00000000..e69de29b diff --git a/tests/lib/mocks.js b/tests/lib/mocks.js deleted file mode 100644 index 656e57ce..00000000 --- a/tests/lib/mocks.js +++ /dev/null @@ -1,49 +0,0 @@ -import { DEFINITIONS } from '../../src/Config/Dependencies'; - -/** - * Returns a mocked logger. - * - * You can pass an overrides object - * specifying the return values - * and/or behaviour for each property. - * - * @param {object} overrides - * @param {object} di - */ -export const getMockedLogger = (overrides = {}, di = null) => { - const logger = { - di, - error: jest.fn(() => overrides.error || null), - info: jest.fn(() => overrides.info || null), - metric: jest.fn(() => overrides.metric || null), - }; - - logger.getContainer = () => logger.di; - - return logger; -}; - -/** - * Returns a mocked di. - * - * You can pass an overrides object - * specifying the behaviour of a depedendency. - * - * @param {object} overrides - */ -export const getMockedDi = (overrides = {}) => { - const deps = { - [DEFINITIONS.LOGGER]: null, - ...overrides, - }; - const di = { - deps, - get: (key) => deps[key], - }; - - if (!deps[DEFINITIONS.LOGGER]) { - deps[DEFINITIONS.LOGGER] = getMockedLogger({}, di); - } - - return di; -}; diff --git a/tests/unit/DependencyInjection/DependencyAware.class.test.js b/tests/unit/DependencyInjection/DependencyAware.class.test.js deleted file mode 100644 index 3b151fde..00000000 --- a/tests/unit/DependencyInjection/DependencyAware.class.test.js +++ /dev/null @@ -1,32 +0,0 @@ -import DependencyInjection from '../../../src/DependencyInjection/DependencyInjection.class'; -// The import order is relevant here to avoid circular imports -// eslint-disable-next-line import/order -import DependencyAware from '../../../src/DependencyInjection/DependencyAware.class'; -import getContext from '../../mocks/aws/context.json'; -import getEvent from '../../mocks/aws/event.json'; - -describe('DependencyInjection/DependencyAwareClass', () => { - describe('getContainer', () => { - const dependencyInjectionClass = new DependencyInjection({}, getEvent, getContext); - const dependencyAwareClass = new DependencyAware(dependencyInjectionClass); - - it('should instantiate and be able to get the dependency injection container', () => { - expect(dependencyAwareClass.getContainer()).toEqual(dependencyInjectionClass); - }); - }); - - describe('definitions', () => { - describe('Returns the provided definitions', () => { - [ - [{}, undefined], - [{ DEFINITIONS: 1 }, 1], - ].forEach(([configuration, expected]) => { - it(`With configuration: ${configuration}`, () => { - const di = new DependencyInjection(configuration); - const service = new DependencyAware(di); - expect(service.definitions).toEqual(expected); - }); - }); - }); - }); -}); diff --git a/tests/unit/DependencyInjection/DependencyInjection.class.test.js b/tests/unit/DependencyInjection/DependencyInjection.class.test.js deleted file mode 100644 index 4304f7a0..00000000 --- a/tests/unit/DependencyInjection/DependencyInjection.class.test.js +++ /dev/null @@ -1,101 +0,0 @@ -import { DEFINITIONS } from '../../../src/Config/Dependencies'; -import DependencyInjection from '../../../src/DependencyInjection/DependencyInjection.class'; -import LoggerService from '../../../src/Service/Logger.service'; -import RequestService from '../../../src/Service/Request.service'; -import getContext from '../../mocks/aws/context.json'; -import getEvent from '../../mocks/aws/event.json'; - -describe('DependencyInjection/DependencyInjectionClass', () => { - describe('should instantiate', () => { - const configuration = { - test: 123, - }; - const dependencyInjection = new DependencyInjection(configuration, getEvent, getContext); - - it('should output the event that was provided to it', () => { - expect(dependencyInjection.getEvent()).toEqual(getEvent); - }); - - it('should output the context that was provided to it', () => { - expect(dependencyInjection.getContext()).toEqual(getContext); - }); - - it('should output the configuration that was provided to it', () => { - expect(dependencyInjection.getConfiguration()).toEqual(configuration); - }); - }); - - describe('should get dependencies', () => { - const dependencyInjection = new DependencyInjection({}, getEvent, getContext); - - it('Should throw validation errors when an non existent model is requested', () => { - expect(() => dependencyInjection.get('test')).toThrow('test does not exist in di container'); - }); - - it('should fetch an instance of the logger service', () => { - expect(dependencyInjection.get(DEFINITIONS.LOGGER) instanceof LoggerService).toEqual(true); - }); - - it('should fetch an instance of the request service', () => { - const requestService = dependencyInjection.get(DEFINITIONS.REQUEST); - expect(requestService instanceof RequestService).toEqual(true); - expect(requestService.di instanceof DependencyInjection).toEqual(true); - }); - }); - - describe('isOffline', () => { - let useServerlessOffline; - - beforeAll(() => { - useServerlessOffline = process.env.USE_SERVERLESS_OFFLINE; - process.env.USE_SERVERLESS_OFFLINE = ''; - }); - - afterEach(() => { - process.env.USE_SERVERLESS_OFFLINE = ''; - }); - - afterAll(() => { - process.env.USE_SERVERLESS_OFFLINE = useServerlessOffline; - }); - - describe('is true', () => { - it("when context doesn't define an invokedFunctionArn", () => { - const di = new DependencyInjection({}, getEvent, {}); - expect(di.isOffline).toEqual(true); - }); - - it('When the invokedFunctionArn includes `offline`', () => { - const di = new DependencyInjection({}, getEvent, { invokedFunctionArn: 'my-offline-function' }); - expect(di.isOffline).toEqual(true); - }); - - it('When process.env.USE_SERVERLESS_OFFLINE is defined', () => { - process.env.USE_SERVERLESS_OFFLINE = 'true'; - const di = new DependencyInjection({}, getEvent, { invokedFunctionArn: 'my-function' }); - expect(di.isOffline).toEqual(true); - }); - }); - - describe('is false`', () => { - it("When the invokedFunctionArn doesn't contain `offline", () => { - const di = new DependencyInjection({}, getEvent, { invokedFunctionArn: 'my-function' }); - expect(di.isOffline).toEqual(false); - }); - }); - }); - - describe('definitions', () => { - describe('Returns the provided definitions', () => { - [ - [{}, undefined], - [{ DEFINITIONS: 1 }, 1], - ].forEach(([configuration, expected]) => { - it(`With configuration: ${configuration}`, () => { - const di = new DependencyInjection(configuration); - expect(di.definitions).toEqual(expected); - }); - }); - }); - }); -}); diff --git a/tests/unit/Model/CloudEvent.model.test.js b/tests/unit/Model/CloudEvent.model.test.js deleted file mode 100644 index 9e00179c..00000000 --- a/tests/unit/Model/CloudEvent.model.test.js +++ /dev/null @@ -1,58 +0,0 @@ -import CloudEventModel from '../../../src/Model/CloudEvent.model'; - -// Test definitions. -describe('Model/CloudEventModel', () => { - describe('Ensure setting and getting of variables', () => { - const model = new CloudEventModel(); - - it('should get the cloud event version', () => { - expect(model.getCloudEventsVersion()).toEqual('0.1'); - }); - - it('should set and get the event type', () => { - expect(model.getEventType()).toEqual(''); - const eventType = 'test.event'; - model.setEventType(eventType); - expect(model.getEventType()).toEqual(eventType); - }); - - it('should set and get the source', () => { - expect(model.getSource()).toEqual(''); - const source = 'test'; - model.setSource(source); - expect(model.getSource()).toEqual(source); - }); - - it('should generate a uuid as the event id', () => { - expect(model.getEventID().length).toEqual(36); - }); - - it('should generate the current timestamp as the current time', () => { - expect(new CloudEventModel().getEventTime().replace(/:[^:]+$/, '')).toEqual(new Date().toISOString().replace(/:[^:]+$/, '')); - }); - - it('should set and get the extensions', () => { - expect(model.getExtensions()).toEqual({}); - - const extensions = { - test: 'test', - }; - model.setExtensions(extensions); - expect(model.getExtensions()).toEqual(extensions); - }); - - it('should get the content type', () => { - expect(model.getContentType()).toEqual('application/json'); - }); - - it('should set and get the extensions', () => { - expect(model.getData()).toEqual({}); - - const data = { - test: 'test', - }; - model.setData(data); - expect(model.getData()).toEqual(data); - }); - }); -}); diff --git a/tests/unit/Model/Response.model.test.js b/tests/unit/Model/Response.model.test.js deleted file mode 100644 index 2625e013..00000000 --- a/tests/unit/Model/Response.model.test.js +++ /dev/null @@ -1,89 +0,0 @@ -import ResponseModel, { DEFAULT_MESSAGE, RESPONSE_HEADERS } from '../../../src/Model/Response.model'; - -describe('Model/ResponseModel', () => { - it('should return the expected headers', () => { - const response = new ResponseModel({}, 500); - expect(response.generate().headers).toEqual(RESPONSE_HEADERS); - }); - - describe('ensure body set correctly', () => { - it('should set the body data from the constructor', () => { - const response = new ResponseModel({ test: 123 }, 500); - - const responseBody = response.generate(); - - expect(typeof responseBody.body).toEqual('string'); - expect(responseBody.body.indexOf('123')).not.toEqual(-1); - expect(JSON.parse(responseBody.body).data.test).toEqual(123); - }); - - it('should be able to modify the body data', () => { - const response = new ResponseModel({ test: 123 }, 500); - - response.setData({ test: 234 }); - const responseBody = response.generate(); - - expect(typeof responseBody.body).toEqual('string'); - expect(responseBody.body.indexOf('234')).not.toEqual(-1); - expect(JSON.parse(responseBody.body).data.test).toEqual(234); - }); - }); - - describe('ensure status codes are set correctly', () => { - it('should return the 200 status code that is supplied to it', () => { - const response = new ResponseModel({}, 200); - expect(response.generate().statusCode).toEqual(200); - }); - - it('should return the 500 status code that is supplied to it', () => { - const response = new ResponseModel({}, 500); - expect(response.generate().statusCode).toEqual(500); - }); - - it('should allow the status code to be modified once set via the constructor', () => { - const response = new ResponseModel({}, 200); - response.setCode(300); - - expect(response.generate().statusCode).toEqual(300); - }); - }); - - describe('ensure messages are set correctly', () => { - it('should return a message field when a message is supplied to it', () => { - const message = 'test 123'; - const response = new ResponseModel({}, 500, message); - expect(JSON.parse(response.generate().body).message).toEqual(message); - }); - - it('should be able to get the message using the message getter', () => { - const message = 'test 123'; - const response = new ResponseModel({}, 500, message); - expect(response.getMessage()).toEqual(message); - }); - - it('should return success message field when a message is not supplied to it', () => { - const response = new ResponseModel({}, 500); - expect(JSON.parse(response.generate().body).message).toEqual(DEFAULT_MESSAGE); - }); - - it('should allow the message supplied via the constructor to be overridden', () => { - const response = new ResponseModel({}, 200, 'replace-me'); - response.setMessage('test'); - - expect(JSON.parse(response.generate().body).message).toEqual('test'); - }); - }); - - describe('generate', () => { - it('static and instance method produce the same output', () => { - const data = { a: 1, b: { c: 2 } }; - const code = 201; - const message = 'Some message'; - - const response1 = new ResponseModel(data, code, message).generate(); - const response2 = ResponseModel.generate(data, code, message); - - expect(response1).toEqual(response2); - }); - }); -}); diff --git a/tests/unit/Model/SQS/MarketingPreferences.model.test.js b/tests/unit/Model/SQS/MarketingPreferences.model.test.js deleted file mode 100644 index a85b0906..00000000 --- a/tests/unit/Model/SQS/MarketingPreferences.model.test.js +++ /dev/null @@ -1,460 +0,0 @@ -/* eslint-disable sonarjs/no-identical-functions */ -/* eslint-disable sonarjs/no-duplicate-string */ -import ResponseModel from '../../../../src/Model/Response.model'; -import MarketingPreferencesModel from '../../../../src/Model/SQS/MarketingPreference.model'; - -// Test definitions. -describe('Model/MarketingPreferencesModel', () => { - describe('Ensure setting and getting of variables', () => { - const mockedData = { - firstname: 'Tim', - lastname: 'Jones', - phone: '0208 254 3062', - mobile: '07917 321 492', - address1: '32-36', - address2: "St. Smith's Avenue", - address3: '', - town: 'London', - postcode: 'sw184bx', - country: 'United Kindgom', - campaign: 'sr18', - transactionId: 'AN129MNDJDJ', - transSource: 'giftaid-sportrelief', - transSourceUrl: 'https://giftaid.sportrelief.com/', - transType: 'prefs', - email: 'tim.jones@comicrelief.com', - permissionEmail: 1, - permissionPost: 0, - permissionPhone: 0, - permissionSMS: 0, - timestamp: '1550841771', - }; - - const model = new MarketingPreferencesModel(mockedData); - - it('should set and get the firstname', () => { - expect(model.getFirstName()).toEqual(mockedData.firstname); - }); - - it('should set and get the lastname', () => { - expect(model.getLastName()).toEqual(mockedData.lastname); - }); - - it('should set and get the phone', () => { - expect(model.getPhone()).toEqual(mockedData.phone); - }); - - it('should set and get the mobile', () => { - expect(model.getMobile()).toEqual(mockedData.mobile); - }); - - it('should set and get the address1', () => { - expect(model.getAddress1()).toEqual(mockedData.address1); - }); - - it('should set and get the address2', () => { - expect(model.getAddress2()).toEqual(mockedData.address2); - }); - - it('should set and get the address3', () => { - expect(model.getAddress3()).toEqual(null); - }); - - it('should set and get the town', () => { - expect(model.getTown()).toEqual(mockedData.town); - }); - - it('should set and get the postcode', () => { - expect(model.getPostcode()).toEqual(mockedData.postcode); - }); - - it('should set and get the country', () => { - expect(model.getCountry()).toEqual(mockedData.country); - }); - - it('should set and get the campaign', () => { - expect(model.getCampaign()).toEqual(mockedData.campaign); - }); - - it('should set and get the transaction id', () => { - expect(model.getTransactionId()).toEqual(mockedData.transactionId); - }); - - it('should set and get the transSource', () => { - expect(model.getTransSource()).toEqual(mockedData.transSource); - }); - - it('should set and get the transSourceUrl', () => { - expect(model.getTransSourceUrl()).toEqual(mockedData.transSourceUrl); - }); - - it('should set and get the transType', () => { - expect(model.getTransType()).toEqual(mockedData.transType); - }); - - it('should set and get the email', () => { - expect(model.getEmail()).toEqual(mockedData.email); - }); - - it('should set and get the permissionEmail', () => { - expect(model.getPermissionEmail()).toEqual(mockedData.permissionEmail); - }); - - it('should set and get the permissionPost', () => { - expect(model.getPermissionPost()).toEqual(mockedData.permissionPost); - }); - - it('should set and get the permissionPhone', () => { - expect(model.getPermissionPhone()).toEqual(mockedData.permissionPhone); - }); - - it('should set and get the permissionSMS', () => { - expect(model.getPermissionSMS()).toEqual(mockedData.permissionSMS); - }); - - it('should set and get the Timestamp', () => { - expect(model.getTimestamp()).toEqual(mockedData.timestamp); - }); - - it('should validate the model', (done) => { - model - .validate() - .then(() => { - expect(true).toEqual(true); - done(); - }) - .catch(() => { - expect(true).toEqual(false); - done(); - }); - }); - }); - - describe('Ensure validation fails when variables are not correctly set', () => { - const mockedData = {}; - - const model = new MarketingPreferencesModel(mockedData); - - it('should validate the model and return an error response', (done) => { - model - .validate() - .then(() => { - expect(true).toEqual(false); - done(); - }) - .catch((error) => { - expect(error instanceof ResponseModel).toEqual(true); - expect(error.getCode()).toEqual(400); - expect(error.body.message).toEqual('required fields are missing'); - expect(true).toEqual(true); - done(); - }); - }); - }); - - describe('Ensure validation fails when email permission is set and no email is provided', () => { - const mockedData = { - firstname: 'Tim', - lastname: 'Jones', - mobile: '07917 321 492', - address1: '32-36', - address2: "St. Smith's Avenue", - address3: '', - town: 'London', - postcode: 'sw184bx', - country: 'United Kindgom', - campaign: 'sr18', - transactionId: 'AN129MNDJDJ', - transSource: 'giftaid-sportrelief', - transSourceUrl: 'https://giftaid.sportrelief.com/', - transType: 'prefs', - permissionEmail: 1, - permissionPost: 0, - permissionPhone: 0, - permissionSMS: 0, - timestamp: '1550841771', - }; - - const model = new MarketingPreferencesModel(mockedData); - - it('should validate the model and return an error response', (done) => { - model - .validate() - .then(() => { - expect(true).toEqual(false); - done(); - }) - .catch((error) => { - expect(error instanceof ResponseModel).toEqual(true); - expect(error.getCode()).toEqual(400); - expect(error.body.validation_errors.email[0]).toEqual("Email can't be blank"); - expect(true).toEqual(true); - done(); - }); - }); - }); - - describe('Ensure validation fails when email permission is set and invalid email is provided', () => { - const mockedData = { - firstname: 'Tim', - lastname: 'Jones', - phone: '0208 254 3062', - mobile: '07917 321 492', - address1: '32-36', - address2: "St. Smith's Avenue", - address3: '', - town: 'London', - postcode: 'sw184bx', - country: 'United Kindgom', - campaign: 'sr18', - transactionId: 'AN129MNDJDJ', - transSource: 'giftaid-sportrelief', - transSourceUrl: 'https://giftaid.sportrelief.com/', - transType: 'prefs', - email: 'tim@', - permissionEmail: 1, - permissionPost: 0, - permissionPhone: 0, - permissionSMS: 0, - }; - - const model = new MarketingPreferencesModel(mockedData); - - it('should validate the model and return an error response', (done) => { - model - .validate() - .then(() => { - expect(true).toEqual(false); - done(); - }) - .catch((error) => { - expect(error instanceof ResponseModel).toEqual(true); - expect(error.getCode()).toEqual(400); - expect(error.body.validation_errors.email[0]).toEqual('Email is not a valid email'); - expect(true).toEqual(true); - done(); - }); - }); - }); - - describe('Ensure validation passes when permissions are not set', () => { - const mockedData = { - firstname: 'Kelvin', - lastname: 'James', - phone: '0208 254 3062', - mobile: '07425253522', - address1: 'COMIC RELIEF', - address2: 'CAMELFORD HOUSE 87-90', - address3: 'ALBERT EMBANKMENT', - town: 'LONDON', - postcode: 'SE1 7TP', - country: 'GB', - campaign: 'RND19', - transactionId: 'AN129MNDJDJ', - transSource: 'RND19_GiftAid', - transSourceUrl: 'https://giftaid.sportrelief.com/', - transType: 'prefs', - confirm: 1, - permissionEmail: null, - permissionPost: null, - permissionPhone: null, - permissionSMS: null, - timestamp: '1562165588', - }; - - const model = new MarketingPreferencesModel(mockedData); - - it('should validate the model', (done) => { - model - .validate() - .then(() => { - expect(true).toEqual(true); - done(); - }) - .catch((error) => { - console.log('Error:', error); - expect(true).toEqual(false); - done(); - }); - }); - }); - - describe('Ensure validation passes when email permission is NO', () => { - const mockedData = { - firstname: 'Kelvin', - lastname: 'James', - phone: '0208 254 3062', - mobile: '07425253522', - address1: 'COMIC RELIEF', - address2: 'CAMELFORD HOUSE 87-90', - address3: 'ALBERT EMBANKMENT', - town: 'LONDON', - postcode: 'SE1 7TP', - country: 'GB', - campaign: 'RND19', - transactionId: 'AN129MNDJDJ', - transSource: 'RND19_GiftAid', - transSourceUrl: 'https://giftaid.sportrelief.com/', - transType: 'prefs', - confirm: 1, - permissionEmail: 0, - permissionPost: null, - permissionPhone: null, - permissionSMS: null, - timestamp: '1562165588', - }; - - const model = new MarketingPreferencesModel(mockedData); - - it('should validate the model', (done) => { - model - .validate() - .then(() => { - expect(true).toEqual(true); - done(); - }) - .catch((error) => { - console.log('Error:', error); - expect(true).toEqual(false); - done(); - }); - }); - }); - - describe('Ensure generating of timestamp when not set', () => { - const mockedData = { - firstname: 'Tim', - lastname: 'Jones', - phone: '0208 254 3062', - mobile: '07917 321 492', - address1: '32-36', - address2: "St. Smith's Avenue", - address3: '', - town: 'London', - postcode: 'sw184bx', - country: 'United Kindgom', - campaign: 'sr18', - transactionId: 'AN129MNDJDJ', - transSource: 'giftaid-sportrelief', - transSourceUrl: 'https://giftaid.sportrelief.com/', - transType: 'prefs', - email: 'tim.jones@comicrelief.com', - permissionEmail: 1, - permissionPost: 0, - permissionPhone: 0, - permissionSMS: 0, - }; - - const model = new MarketingPreferencesModel(mockedData); - - it('should get a timestamp', () => { - expect(model.getTimestamp() > 0).toEqual(true); - }); - - it('should validate the model', (done) => { - model - .validate() - .then(() => { - expect(true).toEqual(true); - done(); - }) - .catch(() => { - expect(true).toEqual(false); - done(); - }); - }); - }); - - describe('Ensure validation passes when nullable fields are not present', () => { - const mockedData = { - firstname: 'Tim', - lastname: 'Jones', - phone: '0208 254 3062', - mobile: '07917 321 492', - address1: "32 Smith's Avenue", - town: 'London', - postcode: 'sw184bx', - country: 'United Kindgom', - campaign: 'sr18', - transSource: 'giftaid-sportrelief', - transSourceUrl: 'https://giftaid.sportrelief.com/', - transType: 'prefs', - email: '', - }; - - const model = new MarketingPreferencesModel(mockedData); - - it('should validate the model', (done) => { - model - .validate() - .then(() => { - expect(true).toEqual(true); - done(); - }) - .catch((error) => { - console.log('Error:', error); - expect(true).toEqual(false); - done(); - }); - }); - }); - - describe('Ensure model permission evaluates to false when no permission is set', () => { - const mockedData = { - firstname: 'Tim', - lastname: 'Jones', - phone: '0208 254 3062', - mobile: '07917 321 492', - address1: "32 Smith's Avenue", - town: 'London', - postcode: 'sw184bx', - country: 'United Kindgom', - campaign: 'sr18', - transSource: 'giftaid-sportrelief', - transSourceUrl: 'https://giftaid.sportrelief.com/', - transType: 'prefs', - email: '', - permissionEmail: '', - permissionPost: '', - permissionPhone: '', - permissionSMS: '', - }; - - const model = new MarketingPreferencesModel(mockedData); - - it('should evaluate model permissions to false', (done) => { - expect(model.isPermissionSet()).toEqual(false); - done(); - }); - }); - - describe('Ensure model permission evaluates to true when at least one permission is set', () => { - const mockedData = { - firstname: 'Tim', - lastname: 'Jones', - phone: '0208 254 3062', - mobile: '07917 321 492', - address1: "32 Smith's Avenue", - town: 'London', - postcode: 'sw184bx', - country: 'United Kindgom', - campaign: 'sr18', - transactionId: 'AN129MNDJDJ', - transSource: 'giftaid-sportrelief', - transSourceUrl: 'https://giftaid.sportrelief.com/', - transType: 'prefs', - email: 'tim@example.com', - permissionEmail: 1, - permissionPost: '', - permissionPhone: '', - permissionSMS: '', - }; - - const model = new MarketingPreferencesModel(mockedData); - - it('should evaluate model permissions to true', (done) => { - expect(model.isPermissionSet()).toEqual(true); - done(); - }); - }); -}); diff --git a/tests/unit/Model/SQS/Message.model.test.js b/tests/unit/Model/SQS/Message.model.test.js deleted file mode 100644 index e2d4615c..00000000 --- a/tests/unit/Model/SQS/Message.model.test.js +++ /dev/null @@ -1,46 +0,0 @@ -import Message from '../../../../src/Model/SQS/Message.model'; - -// Test definitions. -describe('Model/SQS/Message.model', () => { - describe('Ensure setting and getting of variables', () => { - const messageData = { - test: 123, - }; - - const mockedMessage = { - MessageId: 123, - ReceiptHandle: 123, - Body: JSON.stringify(messageData), - }; - - const messageModel = new Message(mockedMessage); - - it('should set and get the message id', () => { - expect(messageModel.getMessageId()).toEqual(mockedMessage.MessageId); - }); - - it('should set and get the receipt handle', () => { - expect(messageModel.getReceiptHandle()).toEqual(mockedMessage.ReceiptHandle); - }); - - it('should set, parse the JSON and get the body', () => { - expect(messageModel.getBody()).toEqual(messageData); - }); - - it('should default to having a for deletion status of false', () => { - expect(messageModel.isForDeletion()).toEqual(false); - }); - - it('should be able to change the for deletion status to true', () => { - messageModel.setForDeletion(true); - expect(messageModel.isForDeletion()).toEqual(true); - }); - - it('should be able to set metadata', () => { - messageModel.setMetaData('test', 123); - expect(messageModel.getMetaData()).toEqual({ - test: 123, - }); - }); - }); -}); diff --git a/tests/unit/Model/Status.model.test.js b/tests/unit/Model/Status.model.test.js deleted file mode 100644 index 62d34d72..00000000 --- a/tests/unit/Model/Status.model.test.js +++ /dev/null @@ -1,22 +0,0 @@ -import StatusModel, { STATUS_TYPES } from '../../../src/Model/Status.model'; - -// Test definitions. -describe('Model/StatusModel', () => { - describe('Ensure setting and getting of variables', () => { - const service = 'test'; - const status = STATUS_TYPES.OK; - const statusModel = new StatusModel(service, status); - - it('should set ang get the service', () => { - expect(statusModel.getService()).toEqual(service); - }); - - it('should set and get the status', () => { - expect(statusModel.getStatus()).toEqual(status); - }); - - it('should throw an error when trying to set an invalid status', () => { - expect(() => statusModel.setStatus('invalid')).toThrow('StatusModel - invalid is not a valid status type'); - }); - }); -}); diff --git a/tests/unit/Service/BaseConfig.service.test.js b/tests/unit/Service/BaseConfig.service.test.js deleted file mode 100644 index d8b5496c..00000000 --- a/tests/unit/Service/BaseConfig.service.test.js +++ /dev/null @@ -1,270 +0,0 @@ -import { S3 } from 'aws-sdk'; - -import DependencyInjection from '../../../src/DependencyInjection/DependencyInjection.class'; -import BaseConfigService, { S3_NO_SUCH_KEY_ERROR_CODE, ServiceStates, ServiceStatesHttpCodes } from '../../../src/Service/BaseConfig.service'; - -const createAsyncMock = (returnValue) => { - const mockedValue = returnValue instanceof Error - ? Promise.reject(returnValue) - : Promise.resolve(returnValue); - - return jest.fn().mockReturnValue({ promise: () => mockedValue }); -}; - -/** - * Generates a BaseConfigService - * - * @param {*} param0 - * @returns {BaseConfigService} - */ -const getService = ({ getObject = null, putObject = null, deleteObject = null } = {}) => { - const di = new DependencyInjection({}, {}, {}); - const service = new BaseConfigService(di); - const client = { - getObject: createAsyncMock(getObject), - putObject: createAsyncMock(putObject), - deleteObject: createAsyncMock(deleteObject), - }; - - jest.spyOn(service, 'client', 'get').mockReturnValue(client); - - return service; -}; - -const BaseConfigUnitTests = (serviceGenerator: (...args) => BaseConfigService) => { - afterEach(() => { - jest.resetAllMocks(); - }); - - describe('defaultConfig', () => { - it('is a valid object', () => { - const service = serviceGenerator(); - const isValidObject = typeof service.constructor.defaultConfig === 'object' && service.constructor.defaultConfig !== null; - - expect(isValidObject).toEqual(true); - }); - - it('has state defined', () => { - const service = serviceGenerator(); - const defaultConfig = service.constructor.defaultConfig; - - expect('state' in defaultConfig).toEqual(true); - }); - }); - - describe('s3config', () => { - it('is a valid object', () => { - const service = serviceGenerator(); - const isValidObject = typeof service.constructor.s3config === 'object' && service.constructor.s3config !== null; - - expect(isValidObject).toEqual(true); - }); - - it('has Bucket and Key defined', () => { - const service = serviceGenerator(); - const s3config = service.constructor.s3config; - - expect('Bucket' in s3config).toEqual(true); - expect('Key' in s3config).toEqual(true); - }); - }); - - describe('delete', () => { - it('calls client.deleteObject', async () => { - const service = serviceGenerator(); - await service.delete(); - - expect(service.client.deleteObject).toHaveBeenCalledTimes(1); - }); - }); - - describe('put', () => { - it('calls client.putObject', async () => { - const expected = Symbol('put'); - const service = serviceGenerator(); - await service.put(expected); - - expect(service.client.putObject).toHaveBeenCalledTimes(1); - }); - - it('returns the provided config unchanged', async () => { - const expected = Symbol('put'); - const service = serviceGenerator(); - const config = await service.put(expected); - - expect(config).toEqual(expected); - }); - }); - - describe('get', () => { - it('gets an existing config', async () => { - const expected = { a: 1 }; - const service = serviceGenerator({ getObject: { Body: JSON.stringify(expected) } }); - const config = await service.get(); - - expect(config).toEqual(expected); - }); - - it('refuses empty configurations', async () => { - const service = serviceGenerator({ getObject: { Body: '' } }); - - await expect(service.get()).rejects.toThrowErrorMatchingSnapshot(); - }); - - it('refuses invalid configurations', async () => { - const service = serviceGenerator({ getObject: { Body: '{ "a": 1' } }); - - await expect(service.get()).rejects.toThrowErrorMatchingSnapshot(); - }); - - it('propagates the 404', async () => { - const error = new Error('404'); - error.code = S3_NO_SUCH_KEY_ERROR_CODE; - - const service = serviceGenerator({ getObject: error }); - - await expect(service.get()).rejects.toThrowErrorMatchingSnapshot(); - }); - }); - - describe('getOrCreate', () => { - it('uploads the defaultConfig with a 404 error', async () => { - const error = new Error('404'); - error.code = S3_NO_SUCH_KEY_ERROR_CODE; - - const service = serviceGenerator({ getObject: error }); - const config = await service.getOrCreate(); - - expect(config).toEqual(service.constructor.defaultConfig); - }); - - it('throws any non-404 error', async () => { - const error = new Error('Bad error'); - error.code = 'another'; - - const service = serviceGenerator({ getObject: error }); - - await expect(service.getOrCreate()).rejects.toThrowErrorMatchingSnapshot(); - }); - }); - - describe('patch', () => { - it('uses the existing config if an existing config is found', async () => { - const existing = { a: 1 }; - const service = serviceGenerator({ getObject: { Body: JSON.stringify(existing) } }); - - const additional = { b: 2 }; - const expected = { ...existing, ...additional }; - const config = await service.patch(additional); - - expect(config).toEqual(expected); - }); - - it('uses the base config if no existing config is found', async () => { - const error = new Error('404'); - error.code = S3_NO_SUCH_KEY_ERROR_CODE; - const service = serviceGenerator({ getObject: error }); - - const existing = service.constructor.defaultConfig; - const additional = { b: 2 }; - const expected = { ...existing, ...additional }; - const config = await service.patch(additional); - - expect(config).toEqual(expected); - }); - - it('throws any non-404 error', async () => { - const error = new Error('Bad error'); - error.code = 'another'; - - const service = serviceGenerator({ getObject: error }); - - await expect(service.patch({ b: 1 })).rejects.toThrowErrorMatchingSnapshot(); - }); - }); - - describe('healthCheck', () => { - Object.values(ServiceStates).forEach((state) => { - describe(state, () => { - it('Returns the expected HTTP code with the given config', async () => { - const config = { state }; - const service = serviceGenerator(); - const statusCode = await service.healthCheck(config); - const expected = ServiceStatesHttpCodes[state]; - - expect(statusCode).toEqual(expected); - }); - - it('Returns the expected HTTP code with the existing config', async () => { - const config = { state }; - const service = serviceGenerator({ getObject: { Body: JSON.stringify(config) } }); - const statusCode = await service.healthCheck(); - const expected = ServiceStatesHttpCodes[state]; - - expect(statusCode).toEqual(expected); - }); - }); - }); - - describe('Unknown state', () => { - it('Returns 500 with the given config', async () => { - const config = { state: 'Unknown' }; - const service = serviceGenerator(); - const statusCode = await service.healthCheck(config); - const expected = 500; - - expect(statusCode).toEqual(expected); - }); - - it('Returns 500 with the existing config', async () => { - const config = { state: 'Unknown' }; - const service = serviceGenerator({ getObject: { Body: JSON.stringify(config) } }); - const statusCode = await service.healthCheck(); - const expected = 500; - - expect(statusCode).toEqual(expected); - }); - }); - }); - - describe('ensureHealthy', () => { - [200, 201, 202, 204, 300, 301, 399].forEach((statusCode) => { - describe(statusCode, () => { - it('is healthy', async () => { - const service = serviceGenerator(); - jest.spyOn(service, 'healthCheck').mockImplementation(() => Promise.resolve(statusCode)); - - await expect(service.ensureHealthy()).resolves.toEqual(statusCode); - }); - }); - }); - - [400, 401, 403, 404, 409, 499, 500, 501, 502, 503, 504, 'Dante Alighieri'].forEach((statusCode) => { - describe(statusCode, () => { - it('throws a LambdaTermination', async () => { - const service = serviceGenerator(); - jest.spyOn(service, 'healthCheck').mockImplementation(() => Promise.resolve(statusCode)); - - await expect(service.ensureHealthy()).rejects.toThrowErrorMatchingSnapshot(); - }); - }); - }); - }); -}; - -describe('Service/BaseConfigService', () => { - BaseConfigUnitTests(getService); - - describe('client', () => { - it('Returns an s3 instance (static)', () => { - expect(BaseConfigService.client instanceof S3).toEqual(true); - }); - - it('Returns an s3 instance', () => { - const di = new DependencyInjection({}, {}, {}); - const service = new BaseConfigService(di); - - expect(service.client instanceof S3).toEqual(true); - }); - }); -}); diff --git a/tests/unit/Service/HTTP.service.test.js b/tests/unit/Service/HTTP.service.test.js deleted file mode 100644 index e9ab0101..00000000 --- a/tests/unit/Service/HTTP.service.test.js +++ /dev/null @@ -1,62 +0,0 @@ -import axios from 'axios'; - -import CONFIGURATION from '../../../src/Config/Dependencies'; -import DependencyInjection from '../../../src/DependencyInjection/DependencyInjection.class'; -import HTTPService, { COMICRELIEF_TEST_METADATA_HEADER } from '../../../src/Service/HTTP.service'; -import getEvent from '../../mocks/aws/event.json'; - -const getContext = { invokedFunctionArn: 'my-function' }; - -const getService = (event = getEvent, context = getContext) => new HTTPService(new DependencyInjection(CONFIGURATION, event, context)); - -describe('Service/HTTPService', () => { - afterEach(() => jest.clearAllMocks()); - - describe('request', () => { - const testCases = { - 'GET Request': { method: 'GET', url: '/' }, - 'POST Request': { method: 'POST', url: '/' }, - 'PUT Request': { method: 'PUT', url: '/' }, - 'PATCH Request': { method: 'PATCH', url: '/' }, - 'HEAD Request': { method: 'HEAD', url: '/' }, - 'DELETE Request': { method: 'DELETE', url: '/' }, - 'with URL': { url: '/some/nested/path' }, - 'with baseURL': { baseUrl: 'https://comicrelief.com/test', url: '/additional/url' }, - 'overriding timeout': { timeout: 99 }, - 'with headers': { headers: { Authorization: 'Bearer test' } }, - 'with undefined headers': { headers: undefined }, - }; - - Object.entries(testCases).forEach(([description, config]) => { - it(description, async () => { - const expected = { response: {} }; - const mock = jest.spyOn(axios, 'request').mockResolvedValue(expected); - const service = getService(); - - const response = await service.request(config); - - expect(response).toEqual(expected); - expect(mock.mock.calls).toMatchSnapshot('config'); - }); - - it(`adds the test header, ${description}`, async () => { - const metadata = JSON.stringify({ user: 'Dante Alighieri' }); - const event = { - ...getEvent, - headers: { - ...getEvent.headers, - [COMICRELIEF_TEST_METADATA_HEADER]: metadata, - }, - }; - const expected = { response: {} }; - const mock = jest.spyOn(axios, 'request').mockResolvedValue(expected); - const service = getService(event); - - const response = await service.request(config); - - expect(response).toEqual(expected); - expect(mock.mock.calls).toMatchSnapshot('config'); - }); - }); - }); -}); diff --git a/tests/unit/Service/Logger.service.test.js b/tests/unit/Service/Logger.service.test.js deleted file mode 100644 index 492534ce..00000000 --- a/tests/unit/Service/Logger.service.test.js +++ /dev/null @@ -1,191 +0,0 @@ -import Winston from 'winston'; - -import CONFIGURATION from '../../../src/Config/Dependencies'; -import DependencyInjection from '../../../src/DependencyInjection/DependencyInjection.class'; -import LoggerService from '../../../src/Service/Logger.service'; -import getEvent from '../../mocks/aws/event.json'; - -const getContext = { invokedFunctionArn: 'my-function' }; - -const getLogger = (event = getEvent, context = getContext) => new LoggerService(new DependencyInjection(CONFIGURATION, event, context)); - -describe('Service/LoggerService', () => { - const context = { invokedFunctionArn: 'my-function' }; - - const axiosResponses = { - UNDEFINED: undefined, - EMPTY: {}, - HTTP_417: { - status: 417, - data: { data: 1 }, - extra: 2, - }, - }; - - afterEach(() => jest.clearAllMocks()); - - describe('constructor', () => { - it('Creates a LoggerService instance', () => { - expect(getLogger()).toBeInstanceOf(LoggerService); - }); - }); - - describe('getLogger', () => { - it('Creates a logger instance', () => { - const logger = getLogger(undefined, context); - const winston = logger.getLogger(); - expect(winston.constructor.name).toEqual('DerivedLogger'); - }); - }); - - describe('logger', () => { - it('Starts as null', () => { - const logger = getLogger(undefined, context); - expect(logger.winston).toEqual(null); - }); - - it('Fetches a logger', () => { - const winston = Symbol('winston'); - const logger = getLogger(undefined, context); - jest.spyOn(Winston, 'createLogger').mockImplementation(() => winston); - - expect(logger.logger).toEqual(winston); - }); - - it("Doesn' call Winston.createLogger twice", () => { - const winston = Symbol('winston'); - const logger = getLogger(undefined, context); - jest.spyOn(Winston, 'createLogger').mockImplementation(() => winston); - - expect(logger.logger).toEqual(winston); - expect(logger.logger).toEqual(winston); - expect(logger.logger).toEqual(winston); - - expect(Winston.createLogger).toHaveBeenCalledTimes(1); - }); - }); - - describe('error', () => { - Object.entries(axiosResponses).forEach(([key, axiosResponse]) => { - it(`Trims down the axios error: ${key}`, () => { - const logger = getLogger(); - const log = jest.fn(); - - jest.spyOn(logger, 'logger', 'get').mockReturnValue({ log }); - - const error = { - isAxiosError: true, - raiseOnEpsagon: true, - config: { - url: 'http://localhost:9999', - method: 'get', - }, - extra: 1, - response: axiosResponse, - message: 'some-message', - }; - - logger.error(error); - - const loggerCall = log.mock.calls[0][2].error; - - expect(loggerCall).toMatchSnapshot(); - expect('extra' in loggerCall).toEqual(false); - - if (axiosResponse) { - expect('extra' in loggerCall.response).toEqual(false); - } - }); - }); - }); - - describe('info', () => { - Object.entries(axiosResponses).forEach(([key, axiosResponse]) => { - it(`Trims down the axios error: ${key}`, () => { - const logger = getLogger(); - const log = jest.fn(); - - jest.spyOn(logger, 'logger', 'get').mockReturnValue({ log }); - - const error = { - isAxiosError: true, - raiseOnEpsagon: true, - config: { - url: 'http://localhost:9999', - method: 'get', - }, - extra: 1, - response: axiosResponse, - message: 'some-message', - }; - - logger.info(error); - - const loggerCall = log.mock.calls[0][1]; - - expect(loggerCall).toMatchSnapshot(); - expect('extra' in loggerCall).toEqual(false); - - if (axiosResponse) { - expect('extra' in loggerCall.response).toEqual(false); - } - }); - }); - }); - - describe('warning', () => { - let LOGGER_SOFT_WARNING; - - beforeAll(() => { - LOGGER_SOFT_WARNING = process.env.LOGGER_SOFT_WARNING; - }); - - afterAll(() => { - process.env.LOGGER_SOFT_WARNING = LOGGER_SOFT_WARNING; - }); - - [ - ['', 'error'], - ['some-value', 'error'], - [false, 'error'], - ['false', 'error'], - ['0', 'error'], - ['1', 'info'], - [true, 'info'], - ['true', 'info'], - ].forEach(([loggerSoftWarning, func]) => { - it(`uses 'this.logger.${func}' in ${loggerSoftWarning}`, () => { - process.env.LOGGER_SOFT_WARNING = loggerSoftWarning; - const logger = getLogger(); - - jest.spyOn(logger, func).mockImplementation(() => {}); - - logger.warning({}); - - expect(logger[func]).toHaveBeenCalledTimes(1); - }); - }); - }); - - describe('object', () => { - ['error', 'warning', 'info'].forEach((level) => { - [ - null, - 'a string', - { a: 1 }, - { a: { b: null }, c: 'a string' }, - ].forEach((object) => { - it(`Logs a '${JSON.stringify(object)}' with level: '${level}'`, () => { - const logger = getLogger(); - let message; - - jest.spyOn(logger, level).mockImplementation((arg) => { message = arg; }); - - logger.object('My action', object, level); - expect(logger[level]).toHaveBeenCalledTimes(1); - expect(message).toMatchSnapshot(); - }); - }); - }); - }); -}); diff --git a/tests/unit/Service/Request.service.test.js b/tests/unit/Service/Request.service.test.js deleted file mode 100644 index ccdbab53..00000000 --- a/tests/unit/Service/Request.service.test.js +++ /dev/null @@ -1,243 +0,0 @@ -/* eslint-disable sonarjs/no-duplicate-string */ -import QueryString from 'querystring'; - -import CONFIGURATION from '../../../src/Config/Dependencies'; -import DependencyInjection from '../../../src/DependencyInjection/DependencyInjection.class'; -import RequestService, { HTTP_METHODS_WITHOUT_PAYLOADS, HTTP_METHODS_WITH_PAYLOADS } from '../../../src/Service/Request.service'; -import getContext from '../../mocks/aws/context.json'; -import baseEvent from '../../mocks/aws/event.json'; - -const getEvent = (overrides = {}) => JSON.parse(JSON.stringify(({ - ...baseEvent, - ...overrides, -}))); - -describe('Service/RequestService', () => { - afterEach(() => jest.resetAllMocks()); - - HTTP_METHODS_WITHOUT_PAYLOADS.forEach((httpMethod) => { - describe(`HTTP ${httpMethod}`, () => { - describe('.getAll', () => { - it('should return all get parameters as an object', () => { - const event = getEvent({ httpMethod }); - event.queryStringParameters.test = 123; - const request = new RequestService(new DependencyInjection(CONFIGURATION, event, getContext)); - - const params = request.getAll(); - expect(params.test).toEqual(123); - expect(params['array[]']).toEqual(['one', 'two', 'three']); - }); - }); - - describe('.get', () => { - it('should fetch a query parameter from an AWS event', () => { - const event = getEvent({ httpMethod }); - event.queryStringParameters.test = 123; - const request = new RequestService(new DependencyInjection(CONFIGURATION, event, getContext)); - - expect(request.get('test')).toEqual(event.queryStringParameters.test); - }); - - it('should fetch a query parameter from an AWS event when the request type is set', () => { - const event = getEvent({ httpMethod }); - event.queryStringParameters.test = 123; - const request = new RequestService(new DependencyInjection(CONFIGURATION, event, getContext)); - - expect(request.get('test', null, httpMethod)).toEqual(event.queryStringParameters.test); - }); - - it(`should return null from a non existent ${httpMethod} parameter from an AWS event`, () => { - const event = getEvent({ httpMethod }); - const request = new RequestService(new DependencyInjection(CONFIGURATION, event, getContext)); - - expect(request.get('fake')).toEqual(null); - }); - - it(`should return null from a non existent ${httpMethod} parameter from an AWS event when the request type is set`, () => { - const event = getEvent({ httpMethod }); - const request = new RequestService(new DependencyInjection(CONFIGURATION, event, getContext)); - - expect(request.get('fake', null, httpMethod)).toEqual(null); - }); - - it('should return an array-type query parameter if its name ends []', () => { - const event = getEvent({ httpMethod }); - const request = new RequestService(new DependencyInjection(CONFIGURATION, event, getContext)); - - expect(request.get('array[]')).toEqual(['one', 'two', 'three']); - }); - }); - - describe('.validateAgainstConstraints', () => { - const constraints = { - giftaid: { - numericality: true, - }, - }; - - beforeEach(() => { - // Mute Winston - // eslint-disable-next-line no-underscore-dangle - jest.spyOn(console._stdout, 'write').mockImplementation(() => {}); - }); - - it('should resolve if there are no validation errors', async () => { - const event = getEvent({ httpMethod }); - event.queryStringParameters.giftaid = 123; - const request = new RequestService(new DependencyInjection(CONFIGURATION, event, getContext)); - - await expect(request.validateAgainstConstraints(constraints)).resolves.toEqual(undefined); - }); - - it('should return a response containing validation errors if the data provided is incorrect', async () => { - const event = getEvent({ httpMethod }); - event.queryStringParameters.giftaid = 'abc'; - const request = new RequestService(new DependencyInjection(CONFIGURATION, event, getContext)); - - await expect(request.validateAgainstConstraints(constraints)).rejects.toMatchSnapshot(); - }); - }); - - describe('.getUserBrowserAndDevice', () => { - it('should return null with `headers === undefined`', () => { - const event = getEvent({ httpMethod, headers: undefined }); - const request = new RequestService(new DependencyInjection(CONFIGURATION, event, getContext)); - - expect(request.getUserBrowserAndDevice()).toEqual(null); - }); - - it('should return null with `headers === null`', () => { - const event = getEvent({ httpMethod, headers: null }); - const request = new RequestService(new DependencyInjection(CONFIGURATION, event, getContext)); - - expect(request.getUserBrowserAndDevice()).toEqual(null); - }); - - it('should return a prettified user agent', () => { - const event = getEvent({ httpMethod }); - const request = new RequestService(new DependencyInjection(CONFIGURATION, event, getContext)); - expect(request.getUserBrowserAndDevice()).toEqual({ - 'browser-type': 'Safari', - 'browser-version': '9.1.1', - 'device-type': 'Other', - 'operating-system': 'Mac OS X', - 'operating-system-version': '10.11.5', - }); - }); - }); - }); - }); - - HTTP_METHODS_WITH_PAYLOADS.forEach((httpMethod) => { - const getPayloadEvent = (overrides = {}) => { - const event = getEvent({ httpMethod }); - event.headers['Content-Type'] = 'application/x-www-form-urlencoded'; - event.body = 'grant_type=client_credentials&response_type=token&token_format=opaque'; - - return { ...event, ...overrides }; - }; - - const queryParameters = QueryString.parse(getPayloadEvent().body); - - describe(`HTTP ${httpMethod}`, () => { - describe('.getAll', () => { - it('should return all post parameters as an array', () => { - const event = getPayloadEvent(); - const request = new RequestService(new DependencyInjection(CONFIGURATION, event, getContext)); - - expect(request.getAll()).toEqual(queryParameters); - }); - }); - - describe('.get', () => { - it('should fetch a request body parameter from an AWS event', () => { - const event = getPayloadEvent(); - const request = new RequestService(new DependencyInjection(CONFIGURATION, event, getContext)); - - expect(request.get('grant_type')).toEqual(queryParameters.grant_type); - }); - - it('should fetch a request body parameter from an AWS event when the request type is set', () => { - const event = getPayloadEvent(); - const request = new RequestService(new DependencyInjection(CONFIGURATION, event, getContext)); - - expect(request.get('grant_type', null, httpMethod)).toEqual(queryParameters.grant_type); - }); - - it('should return null from a non existent request body parameter from an AWS event', () => { - const event = getPayloadEvent(); - const request = new RequestService(new DependencyInjection(CONFIGURATION, event, getContext)); - - expect(request.get('fake')).toEqual(null); - }); - - it('should return null from a non existent request body parameter from an AWS event when the request type is set', () => { - const event = getPayloadEvent(); - const request = new RequestService(new DependencyInjection(CONFIGURATION, event, getContext), getEvent); - - expect(request.get('fake', null, httpMethod)).toEqual(null); - }); - }); - - describe('.validateAgainstConstraints', () => { - const constraints = { - giftaid: { - numericality: true, - }, - }; - - beforeEach(() => { - // Mute Winston - // eslint-disable-next-line no-underscore-dangle - jest.spyOn(console._stdout, 'write').mockImplementation(() => {}); - }); - - it('should resolve if there are no validation errors', async () => { - const event = getPayloadEvent({ body: 'giftaid=123' }); - const request = new RequestService(new DependencyInjection(CONFIGURATION, event, getContext)); - - await expect(request.validateAgainstConstraints(constraints)).resolves.toEqual(undefined); - }); - - it('should return a response containing validation errors if the data provided is incorrect', async () => { - const event = getPayloadEvent({ body: 'giftaid=abc' }); - const request = new RequestService(new DependencyInjection(CONFIGURATION, event, getContext)); - - await expect(request.validateAgainstConstraints(constraints)).rejects.toMatchSnapshot(); - }); - }); - }); - }); - - describe('getAllHeaders()', () => { - const event = getEvent(); - const di = new DependencyInjection(CONFIGURATION, event, getContext); - const request = new RequestService(di); - - it('should return all headers from the event', () => { - expect(request.getAllHeaders()).toStrictEqual(event.headers); - }); - }); - - describe('getHeader()', () => { - const event = getEvent(); - const di = new DependencyInjection(CONFIGURATION, event, getContext); - const request = new RequestService(di); - - it('should return the specified header', () => { - expect(request.getHeader('Accept')).toEqual(event.headers.Accept); - }); - - it("should return '' by default if header is missing", () => { - expect(request.getHeader('Authorization')).toEqual(''); - }); - - it('should return `whenMissing` if header is missing', () => { - expect(request.getHeader('Authorization', 'none')).toEqual('none'); - }); - - it('should not be case-sensitive', () => { - expect(request.getHeader('accept')).toEqual(event.headers.Accept); - }); - }); -}); diff --git a/tests/unit/Service/SQS.service.test.js b/tests/unit/Service/SQS.service.test.js deleted file mode 100644 index 8ffd210a..00000000 --- a/tests/unit/Service/SQS.service.test.js +++ /dev/null @@ -1,269 +0,0 @@ -import { DEFINITIONS } from '../../../src/Config/Dependencies'; -import DependencyInjection from '../../../src/DependencyInjection/DependencyInjection.class'; -import { SQS_PUBLISH_FAILURE_MODES } from '../../../src/Service/SQS.service'; - -const createAsyncMock = (returnValue) => { - const mockedValue = returnValue instanceof Error - ? Promise.reject(returnValue) - : Promise.resolve(returnValue); - - return jest.fn().mockReturnValue({ promise: () => mockedValue }); -}; - -const TEST_QUEUE = 'TEST_QUEUE'; - -/** - * Generates a SQSService - * - * @param {*} param0 - * @param isOffline - * @returns {SQSService} - */ -const getService = ({ sendMessage = null, invoke = null } = {}, isOffline = false) => { - const di = new DependencyInjection({ - QUEUES: { [TEST_QUEUE]: 'QueueName' }, - QUEUE_CONSUMERS: { TEST_QUEUE }, - }, {}, { - invokedFunctionArn: isOffline ? 'offline' : 'arn:aws:lambda:eu-west-1:0123456789:test', - }); - - const logger = di.get(DEFINITIONS.LOGGER); - - jest.spyOn(logger, 'error').mockImplementation(); - - const service = di.get(DEFINITIONS.SQS); - const sqs = { - sendMessage: createAsyncMock(sendMessage), - }; - - const lambda = { - invoke: createAsyncMock(invoke), - }; - - jest.spyOn(service, 'sqs', 'get').mockReturnValue(sqs); - jest.spyOn(service, 'lambda', 'get').mockReturnValue(lambda); - - return service; -}; - -describe('Service/SQS', () => { - let envAccountId; - let envOfflineSqsMode; - let envOfflineSqsHost; - let envOfflineSqsPort; - let envRegion; - - beforeAll(() => { - envAccountId = process.env.AWS_ACCOUNT_ID; - envOfflineSqsMode = process.env.LAMBDA_WRAPPER_OFFLINE_SQS_MODE; - envOfflineSqsHost = process.env.LAMBDA_WRAPPER_OFFLINE_SQS_HOST; - envOfflineSqsPort = process.env.LAMBDA_WRAPPER_OFFLINE_SQS_PORT; - envRegion = process.env.REGION; - }); - - afterAll(() => { - process.env.AWS_ACCOUNT_ID = envAccountId; - process.env.LAMBDA_WRAPPER_OFFLINE_SQS_MODE = envOfflineSqsMode; - process.env.LAMBDA_WRAPPER_OFFLINE_SQS_HOST = envOfflineSqsHost; - process.env.LAMBDA_WRAPPER_OFFLINE_SQS_PORT = envOfflineSqsPort; - process.env.REGION = envRegion; - }); - - afterEach(() => { - jest.resetAllMocks(); - }); - - describe('publish', () => { - describe('when container.isOffline === false', () => { - [ - ['sends to SQS', undefined], - ['sends to SQS, even in "direct" offline mode', 'direct'], - ['sends to SQS, even in "local" offline mode', 'local'], - ['sends to SQS, even in "aws" offline mode', 'aws'], - ['sends to SQS, even in "invalid" offline mode', 'invalid'], - ].forEach(([description, offlineMode]) => { - it(description, async () => { - process.env.LAMBDA_WRAPPER_OFFLINE_SQS_MODE = offlineMode; - const service = getService({}, false); - - await service.publish(TEST_QUEUE, { test: 1 }); - - expect(service.sqs.sendMessage).toHaveBeenCalledTimes(1); - expect(service.lambda.invoke).toHaveBeenCalledTimes(0); - - const params = service.sqs.sendMessage.mock.calls[0][0]; - expect(params.QueueUrl).not.toContain('localhost'); - }); - }); - }); - - describe('when container.isOffline === true', () => { - it('sends a lambda request by default', async () => { - delete process.env.LAMBDA_WRAPPER_OFFLINE_SQS_MODE; - const service = getService({}, true); - - await service.publish(TEST_QUEUE, { test: 1 }); - - expect(service.sqs.sendMessage).toHaveBeenCalledTimes(0); - expect(service.lambda.invoke).toHaveBeenCalledTimes(1); - }); - - it('sends a lambda request in "direct" mode', async () => { - process.env.LAMBDA_WRAPPER_OFFLINE_SQS_MODE = 'direct'; - const service = getService({}, true); - - await service.publish(TEST_QUEUE, { test: 1 }); - - expect(service.sqs.sendMessage).toHaveBeenCalledTimes(0); - expect(service.lambda.invoke).toHaveBeenCalledTimes(1); - }); - - it('sends a local SQS request in "local" mode', async () => { - delete process.env.LAMBDA_WRAPPER_OFFLINE_SQS_HOST; - delete process.env.LAMBDA_WRAPPER_OFFLINE_SQS_PORT; - process.env.LAMBDA_WRAPPER_OFFLINE_SQS_MODE = 'local'; - const service = getService({}, true); - - await service.publish(TEST_QUEUE, { test: 1 }); - - expect(service.sqs.sendMessage).toHaveBeenCalledTimes(1); - expect(service.lambda.invoke).toHaveBeenCalledTimes(0); - - const params = service.sqs.sendMessage.mock.calls[0][0]; - expect(params.QueueUrl).toContain('localhost'); - expect(params.QueueUrl).toContain('4576'); - }); - - it('sends a normal SQS request in "aws" mode', async () => { - process.env.LAMBDA_WRAPPER_OFFLINE_SQS_MODE = 'aws'; - const service = getService({}, true); - - await service.publish(TEST_QUEUE, { test: 1 }); - - expect(service.sqs.sendMessage).toHaveBeenCalledTimes(1); - expect(service.lambda.invoke).toHaveBeenCalledTimes(0); - - const params = service.sqs.sendMessage.mock.calls[0][0]; - expect(params.QueueUrl).not.toContain('localhost'); - expect(params.QueueUrl).not.toContain('4576'); - }); - - it('throws an error for any other mode', async () => { - process.env.LAMBDA_WRAPPER_OFFLINE_SQS_MODE = 'invalid'; - expect(() => getService({}, true)).toThrow(); - }); - }); - - describe('queue URLs', () => { - describe('when container.isOffline === false', () => { - it('should use a correctly formed AWS queue URL', async () => { - delete process.env.LAMBDA_WRAPPER_OFFLINE_SQS_MODE; - process.env.REGION = 'eu-west-1'; - const service = getService({}, false); - - await service.publish(TEST_QUEUE, { test: 1 }); - - const params = service.sqs.sendMessage.mock.calls[0][0]; - expect(params.QueueUrl).toEqual('https://sqs.eu-west-1.amazonaws.com/0123456789/QueueName'); - }); - }); - - describe('when container.isOffline === true', () => { - it('should use a LocalStack URL in "local" mode', async () => { - delete process.env.LAMBDA_WRAPPER_OFFLINE_SQS_HOST; - delete process.env.LAMBDA_WRAPPER_OFFLINE_SQS_PORT; - process.env.LAMBDA_WRAPPER_OFFLINE_SQS_MODE = 'local'; - const service = getService({}, true); - - await service.publish(TEST_QUEUE, { test: 1 }); - - const params = service.sqs.sendMessage.mock.calls[0][0]; - expect(params.QueueUrl).toEqual('http://localhost:4576/queue/QueueName'); - }); - - it('should use a custom host in "local" mode', async () => { - delete process.env.LAMBDA_WRAPPER_OFFLINE_SQS_PORT; - process.env.LAMBDA_WRAPPER_OFFLINE_SQS_MODE = 'local'; - process.env.LAMBDA_WRAPPER_OFFLINE_SQS_HOST = 'custom-host'; - const service = getService({}, true); - - await service.publish(TEST_QUEUE, { test: 1 }); - - const params = service.sqs.sendMessage.mock.calls[0][0]; - expect(params.QueueUrl).toEqual('http://custom-host:4576/queue/QueueName'); - }); - - it('should use a custom port in "local" mode', async () => { - delete process.env.LAMBDA_WRAPPER_OFFLINE_SQS_HOST; - process.env.LAMBDA_WRAPPER_OFFLINE_SQS_MODE = 'local'; - process.env.LAMBDA_WRAPPER_OFFLINE_SQS_PORT = '4566'; - const service = getService({}, true); - - await service.publish(TEST_QUEUE, { test: 1 }); - - const params = service.sqs.sendMessage.mock.calls[0][0]; - expect(params.QueueUrl).toEqual('http://localhost:4566/queue/QueueName'); - }); - - it('should use a correctly formed AWS queue URL in "aws" mode', async () => { - // `AWS_ACCOUNT_ID` and `REGION` need to be set for this to work - process.env.LAMBDA_WRAPPER_OFFLINE_SQS_MODE = 'aws'; - process.env.AWS_ACCOUNT_ID = '0123456789'; - process.env.REGION = 'eu-west-1'; - const service = getService({}, true); - - await service.publish(TEST_QUEUE, { test: 1 }); - - const params = service.sqs.sendMessage.mock.calls[0][0]; - expect(params.QueueUrl).toEqual('https://sqs.eu-west-1.amazonaws.com/0123456789/QueueName'); - }); - }); - }); - - describe('failure modes', () => { - it(`catches the error if publish fails with failureMode === ${SQS_PUBLISH_FAILURE_MODES.CATCH}`, async () => { - const service = getService({ - sendMessage: new Error('SQS is down!'), - }, false); - - const promise = service.publish(TEST_QUEUE, { test: 1 }, null, SQS_PUBLISH_FAILURE_MODES.CATCH); - - await expect(promise).resolves.toEqual(null); - }); - - it('catches the error if publish fails with failureMode omitted', async () => { - const service = getService({ - sendMessage: new Error('SQS is down!'), - }, false); - - const promise = service.publish(TEST_QUEUE, { test: 1 }, null); - - await expect(promise).resolves.toEqual(null); - }); - - it(`throws an error if publish fails with failureMode === ${SQS_PUBLISH_FAILURE_MODES.THROW}`, async () => { - const service = getService({ - sendMessage: new Error('SQS is down!'), - }, false); - - const promise = service.publish(TEST_QUEUE, { test: 1 }, null, SQS_PUBLISH_FAILURE_MODES.THROW); - - await expect(promise).rejects.toThrowError('SQS is down!'); - }); - - [ - '', - null, - 'another-value', - ].forEach((invalidValue) => { - it(`throws an error with the invalid value: ${invalidValue}`, async () => { - const service = getService(); - - const promise = service.publish(TEST_QUEUE, { test: 1 }, null, invalidValue); - - await expect(promise).rejects.toThrowErrorMatchingSnapshot(); - }); - }); - }); - }); -}); diff --git a/tests/unit/Service/__snapshots__/BaseConfig.service.test.js.snap b/tests/unit/Service/__snapshots__/BaseConfig.service.test.js.snap deleted file mode 100644 index 37742866..00000000 --- a/tests/unit/Service/__snapshots__/BaseConfig.service.test.js.snap +++ /dev/null @@ -1,35 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Service/BaseConfigService ensureHealthy 400 throws a LambdaTermination 1`] = `"Application is not healthy."`; - -exports[`Service/BaseConfigService ensureHealthy 401 throws a LambdaTermination 1`] = `"Application is not healthy."`; - -exports[`Service/BaseConfigService ensureHealthy 403 throws a LambdaTermination 1`] = `"Application is not healthy."`; - -exports[`Service/BaseConfigService ensureHealthy 404 throws a LambdaTermination 1`] = `"Application is not healthy."`; - -exports[`Service/BaseConfigService ensureHealthy 409 throws a LambdaTermination 1`] = `"Application is not healthy."`; - -exports[`Service/BaseConfigService ensureHealthy 499 throws a LambdaTermination 1`] = `"Application is not healthy."`; - -exports[`Service/BaseConfigService ensureHealthy 500 throws a LambdaTermination 1`] = `"Application is not healthy."`; - -exports[`Service/BaseConfigService ensureHealthy 501 throws a LambdaTermination 1`] = `"Application is not healthy."`; - -exports[`Service/BaseConfigService ensureHealthy 502 throws a LambdaTermination 1`] = `"Application is not healthy."`; - -exports[`Service/BaseConfigService ensureHealthy 503 throws a LambdaTermination 1`] = `"Application is not healthy."`; - -exports[`Service/BaseConfigService ensureHealthy 504 throws a LambdaTermination 1`] = `"Application is not healthy."`; - -exports[`Service/BaseConfigService ensureHealthy Dante Alighieri throws a LambdaTermination 1`] = `"Application is not healthy."`; - -exports[`Service/BaseConfigService get propagates the 404 1`] = `"404"`; - -exports[`Service/BaseConfigService get refuses empty configurations 1`] = `"Configuration file is empty"`; - -exports[`Service/BaseConfigService get refuses invalid configurations 1`] = `"Invalid configuration file"`; - -exports[`Service/BaseConfigService getOrCreate throws any non-404 error 1`] = `"Bad error"`; - -exports[`Service/BaseConfigService patch throws any non-404 error 1`] = `"Bad error"`; diff --git a/tests/unit/Service/__snapshots__/HTTP.service.test.js.snap b/tests/unit/Service/__snapshots__/HTTP.service.test.js.snap deleted file mode 100644 index 0b933d4d..00000000 --- a/tests/unit/Service/__snapshots__/HTTP.service.test.js.snap +++ /dev/null @@ -1,298 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Service/HTTPService request DELETE Request: config 1`] = ` -Array [ - Array [ - Object { - "headers": Object {}, - "method": "DELETE", - "timeout": 10000, - "url": "/", - }, - ], -] -`; - -exports[`Service/HTTPService request GET Request: config 1`] = ` -Array [ - Array [ - Object { - "headers": Object {}, - "method": "GET", - "timeout": 10000, - "url": "/", - }, - ], -] -`; - -exports[`Service/HTTPService request HEAD Request: config 1`] = ` -Array [ - Array [ - Object { - "headers": Object {}, - "method": "HEAD", - "timeout": 10000, - "url": "/", - }, - ], -] -`; - -exports[`Service/HTTPService request PATCH Request: config 1`] = ` -Array [ - Array [ - Object { - "headers": Object {}, - "method": "PATCH", - "timeout": 10000, - "url": "/", - }, - ], -] -`; - -exports[`Service/HTTPService request POST Request: config 1`] = ` -Array [ - Array [ - Object { - "headers": Object {}, - "method": "POST", - "timeout": 10000, - "url": "/", - }, - ], -] -`; - -exports[`Service/HTTPService request PUT Request: config 1`] = ` -Array [ - Array [ - Object { - "headers": Object {}, - "method": "PUT", - "timeout": 10000, - "url": "/", - }, - ], -] -`; - -exports[`Service/HTTPService request adds the test header, DELETE Request: config 1`] = ` -Array [ - Array [ - Object { - "headers": Object { - "x-comicrelief-test-metadata": "{\\"user\\":\\"Dante Alighieri\\"}", - }, - "method": "DELETE", - "timeout": 10000, - "url": "/", - }, - ], -] -`; - -exports[`Service/HTTPService request adds the test header, GET Request: config 1`] = ` -Array [ - Array [ - Object { - "headers": Object { - "x-comicrelief-test-metadata": "{\\"user\\":\\"Dante Alighieri\\"}", - }, - "method": "GET", - "timeout": 10000, - "url": "/", - }, - ], -] -`; - -exports[`Service/HTTPService request adds the test header, HEAD Request: config 1`] = ` -Array [ - Array [ - Object { - "headers": Object { - "x-comicrelief-test-metadata": "{\\"user\\":\\"Dante Alighieri\\"}", - }, - "method": "HEAD", - "timeout": 10000, - "url": "/", - }, - ], -] -`; - -exports[`Service/HTTPService request adds the test header, PATCH Request: config 1`] = ` -Array [ - Array [ - Object { - "headers": Object { - "x-comicrelief-test-metadata": "{\\"user\\":\\"Dante Alighieri\\"}", - }, - "method": "PATCH", - "timeout": 10000, - "url": "/", - }, - ], -] -`; - -exports[`Service/HTTPService request adds the test header, POST Request: config 1`] = ` -Array [ - Array [ - Object { - "headers": Object { - "x-comicrelief-test-metadata": "{\\"user\\":\\"Dante Alighieri\\"}", - }, - "method": "POST", - "timeout": 10000, - "url": "/", - }, - ], -] -`; - -exports[`Service/HTTPService request adds the test header, PUT Request: config 1`] = ` -Array [ - Array [ - Object { - "headers": Object { - "x-comicrelief-test-metadata": "{\\"user\\":\\"Dante Alighieri\\"}", - }, - "method": "PUT", - "timeout": 10000, - "url": "/", - }, - ], -] -`; - -exports[`Service/HTTPService request adds the test header, overriding timeout: config 1`] = ` -Array [ - Array [ - Object { - "headers": Object { - "x-comicrelief-test-metadata": "{\\"user\\":\\"Dante Alighieri\\"}", - }, - "timeout": 99, - }, - ], -] -`; - -exports[`Service/HTTPService request adds the test header, with URL: config 1`] = ` -Array [ - Array [ - Object { - "headers": Object { - "x-comicrelief-test-metadata": "{\\"user\\":\\"Dante Alighieri\\"}", - }, - "timeout": 10000, - "url": "/some/nested/path", - }, - ], -] -`; - -exports[`Service/HTTPService request adds the test header, with baseURL: config 1`] = ` -Array [ - Array [ - Object { - "baseUrl": "https://comicrelief.com/test", - "headers": Object { - "x-comicrelief-test-metadata": "{\\"user\\":\\"Dante Alighieri\\"}", - }, - "timeout": 10000, - "url": "/additional/url", - }, - ], -] -`; - -exports[`Service/HTTPService request adds the test header, with headers: config 1`] = ` -Array [ - Array [ - Object { - "headers": Object { - "Authorization": "Bearer test", - "x-comicrelief-test-metadata": "{\\"user\\":\\"Dante Alighieri\\"}", - }, - "timeout": 10000, - }, - ], -] -`; - -exports[`Service/HTTPService request adds the test header, with undefined headers: config 1`] = ` -Array [ - Array [ - Object { - "headers": Object { - "x-comicrelief-test-metadata": "{\\"user\\":\\"Dante Alighieri\\"}", - }, - "timeout": 10000, - }, - ], -] -`; - -exports[`Service/HTTPService request overriding timeout: config 1`] = ` -Array [ - Array [ - Object { - "headers": Object {}, - "timeout": 99, - }, - ], -] -`; - -exports[`Service/HTTPService request with URL: config 1`] = ` -Array [ - Array [ - Object { - "headers": Object {}, - "timeout": 10000, - "url": "/some/nested/path", - }, - ], -] -`; - -exports[`Service/HTTPService request with baseURL: config 1`] = ` -Array [ - Array [ - Object { - "baseUrl": "https://comicrelief.com/test", - "headers": Object {}, - "timeout": 10000, - "url": "/additional/url", - }, - ], -] -`; - -exports[`Service/HTTPService request with headers: config 1`] = ` -Array [ - Array [ - Object { - "headers": Object { - "Authorization": "Bearer test", - }, - "timeout": 10000, - }, - ], -] -`; - -exports[`Service/HTTPService request with undefined headers: config 1`] = ` -Array [ - Array [ - Object { - "headers": undefined, - "timeout": 10000, - }, - ], -] -`; diff --git a/tests/unit/Service/__snapshots__/Logger.service.test.js.snap b/tests/unit/Service/__snapshots__/Logger.service.test.js.snap deleted file mode 100644 index 2afbafa3..00000000 --- a/tests/unit/Service/__snapshots__/Logger.service.test.js.snap +++ /dev/null @@ -1,138 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Service/LoggerService error Trims down the axios error: EMPTY 1`] = ` -Object { - "config": Object { - "method": "get", - "url": "http://localhost:9999", - }, - "message": "some-message", - "response": Object { - "data": undefined, - "status": undefined, - }, -} -`; - -exports[`Service/LoggerService error Trims down the axios error: HTTP_417 1`] = ` -Object { - "config": Object { - "method": "get", - "url": "http://localhost:9999", - }, - "message": "some-message", - "response": Object { - "data": Object { - "data": 1, - }, - "status": 417, - }, -} -`; - -exports[`Service/LoggerService error Trims down the axios error: UNDEFINED 1`] = ` -Object { - "config": Object { - "method": "get", - "url": "http://localhost:9999", - }, - "message": "some-message", -} -`; - -exports[`Service/LoggerService info Trims down the axios error: EMPTY 1`] = ` -Object { - "config": Object { - "method": "get", - "url": "http://localhost:9999", - }, - "message": "some-message", - "response": Object { - "data": undefined, - "status": undefined, - }, -} -`; - -exports[`Service/LoggerService info Trims down the axios error: HTTP_417 1`] = ` -Object { - "config": Object { - "method": "get", - "url": "http://localhost:9999", - }, - "message": "some-message", - "response": Object { - "data": Object { - "data": 1, - }, - "status": 417, - }, -} -`; - -exports[`Service/LoggerService info Trims down the axios error: UNDEFINED 1`] = ` -Object { - "config": Object { - "method": "get", - "url": "http://localhost:9999", - }, - "message": "some-message", -} -`; - -exports[`Service/LoggerService object Logs a '"a string"' with level: 'error' 1`] = `"My action: '\\"a string\\"'"`; - -exports[`Service/LoggerService object Logs a '"a string"' with level: 'info' 1`] = `"My action: '\\"a string\\"'"`; - -exports[`Service/LoggerService object Logs a '"a string"' with level: 'warning' 1`] = `"My action: '\\"a string\\"'"`; - -exports[`Service/LoggerService object Logs a '{"a":{"b":null},"c":"a string"}' with level: 'error' 1`] = ` -"My action: '{ - \\"a\\": { - \\"b\\": null - }, - \\"c\\": \\"a string\\" -}'" -`; - -exports[`Service/LoggerService object Logs a '{"a":{"b":null},"c":"a string"}' with level: 'info' 1`] = ` -"My action: '{ - \\"a\\": { - \\"b\\": null - }, - \\"c\\": \\"a string\\" -}'" -`; - -exports[`Service/LoggerService object Logs a '{"a":{"b":null},"c":"a string"}' with level: 'warning' 1`] = ` -"My action: '{ - \\"a\\": { - \\"b\\": null - }, - \\"c\\": \\"a string\\" -}'" -`; - -exports[`Service/LoggerService object Logs a '{"a":1}' with level: 'error' 1`] = ` -"My action: '{ - \\"a\\": 1 -}'" -`; - -exports[`Service/LoggerService object Logs a '{"a":1}' with level: 'info' 1`] = ` -"My action: '{ - \\"a\\": 1 -}'" -`; - -exports[`Service/LoggerService object Logs a '{"a":1}' with level: 'warning' 1`] = ` -"My action: '{ - \\"a\\": 1 -}'" -`; - -exports[`Service/LoggerService object Logs a 'null' with level: 'error' 1`] = `"My action: 'null'"`; - -exports[`Service/LoggerService object Logs a 'null' with level: 'info' 1`] = `"My action: 'null'"`; - -exports[`Service/LoggerService object Logs a 'null' with level: 'warning' 1`] = `"My action: 'null'"`; diff --git a/tests/unit/Service/__snapshots__/Request.service.test.js.snap b/tests/unit/Service/__snapshots__/Request.service.test.js.snap deleted file mode 100644 index 868fb53d..00000000 --- a/tests/unit/Service/__snapshots__/Request.service.test.js.snap +++ /dev/null @@ -1,106 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Service/RequestService HTTP DELETE .validateAgainstConstraints should return a response containing validation errors if the data provided is incorrect 1`] = ` -ResponseModel { - "body": Object { - "data": Object {}, - "message": "required fields are missing", - "validation_errors": Object { - "giftaid": Array [ - "Giftaid is not a number", - ], - }, - }, - "code": 400, -} -`; - -exports[`Service/RequestService HTTP GET .validateAgainstConstraints should return a response containing validation errors if the data provided is incorrect 1`] = ` -ResponseModel { - "body": Object { - "data": Object {}, - "message": "required fields are missing", - "validation_errors": Object { - "giftaid": Array [ - "Giftaid is not a number", - ], - }, - }, - "code": 400, -} -`; - -exports[`Service/RequestService HTTP HEAD .validateAgainstConstraints should return a response containing validation errors if the data provided is incorrect 1`] = ` -ResponseModel { - "body": Object { - "data": Object {}, - "message": "required fields are missing", - "validation_errors": Object { - "giftaid": Array [ - "Giftaid is not a number", - ], - }, - }, - "code": 400, -} -`; - -exports[`Service/RequestService HTTP OPTIONS .validateAgainstConstraints should return a response containing validation errors if the data provided is incorrect 1`] = ` -ResponseModel { - "body": Object { - "data": Object {}, - "message": "required fields are missing", - "validation_errors": Object { - "giftaid": Array [ - "Giftaid is not a number", - ], - }, - }, - "code": 400, -} -`; - -exports[`Service/RequestService HTTP PATCH .validateAgainstConstraints should return a response containing validation errors if the data provided is incorrect 1`] = ` -ResponseModel { - "body": Object { - "data": Object {}, - "message": "required fields are missing", - "validation_errors": Object { - "giftaid": Array [ - "Giftaid is not a number", - ], - }, - }, - "code": 400, -} -`; - -exports[`Service/RequestService HTTP POST .validateAgainstConstraints should return a response containing validation errors if the data provided is incorrect 1`] = ` -ResponseModel { - "body": Object { - "data": Object {}, - "message": "required fields are missing", - "validation_errors": Object { - "giftaid": Array [ - "Giftaid is not a number", - ], - }, - }, - "code": 400, -} -`; - -exports[`Service/RequestService HTTP PUT .validateAgainstConstraints should return a response containing validation errors if the data provided is incorrect 1`] = ` -ResponseModel { - "body": Object { - "data": Object {}, - "message": "required fields are missing", - "validation_errors": Object { - "giftaid": Array [ - "Giftaid is not a number", - ], - }, - }, - "code": 400, -} -`; diff --git a/tests/unit/Service/__snapshots__/SQS.service.test.js.snap b/tests/unit/Service/__snapshots__/SQS.service.test.js.snap deleted file mode 100644 index 82939d44..00000000 --- a/tests/unit/Service/__snapshots__/SQS.service.test.js.snap +++ /dev/null @@ -1,7 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Service/SQS publish failure modes throws an error with the invalid value: 1`] = `"Invalid value for 'failureMode': "`; - -exports[`Service/SQS publish failure modes throws an error with the invalid value: another-value 1`] = `"Invalid value for 'failureMode': another-value"`; - -exports[`Service/SQS publish failure modes throws an error with the invalid value: null 1`] = `"Invalid value for 'failureMode': null"`; diff --git a/tests/unit/Wrapper/LambdaTermination.test.js b/tests/unit/Wrapper/LambdaTermination.test.js deleted file mode 100644 index dee0d9c6..00000000 --- a/tests/unit/Wrapper/LambdaTermination.test.js +++ /dev/null @@ -1,38 +0,0 @@ -import LambdaTermination from '../../../src/Wrapper/LambdaTermination'; - -describe('Wrapper/LambdaTermination', () => { - describe('Stores the custom fields', () => { - const properties = { - internal: 'INTERNAL', - code: 401, - body: 'BODY', - }; - - const lt = new LambdaTermination(properties.internal, properties.code, properties.body); - - Object.entries(properties).forEach(([key, value]) => { - it(`Exposes '${key}'`, () => { - expect(lt[key]).toEqual(value); - }); - }); - }); - - it('Generates an error', () => { - const lt = new LambdaTermination('internal'); - expect(lt instanceof Error).toEqual(true); - }); - - describe('Passes a prop to the superclass that', () => { - it('Becomes Error.message', () => { - const lt = new LambdaTermination('abc'); - expect(lt.message).toEqual('abc'); - }); - - it('Is stringified when an object', () => { - const details = { a: 1 }; - const stringified = JSON.stringify(details); - const lt = new LambdaTermination(details); - expect(lt.message).toEqual(stringified); - }); - }); -}); diff --git a/tests/unit/Wrapper/LambdaWrapper.test.js b/tests/unit/Wrapper/LambdaWrapper.test.js deleted file mode 100644 index 0a7f4102..00000000 --- a/tests/unit/Wrapper/LambdaWrapper.test.js +++ /dev/null @@ -1,256 +0,0 @@ -/* eslint-disable sonarjs/no-duplicate-string */ -import { DEFINITIONS } from '../../../src/Config/Dependencies'; -import DependencyInjection from '../../../src/DependencyInjection/DependencyInjection.class'; -import ResponseModel from '../../../src/Model/Response.model'; -import RequestService, { REQUEST_TYPES } from '../../../src/Service/Request.service'; -import LambdaTermination from '../../../src/Wrapper/LambdaTermination'; -import LambdaWrapper, { handleError } from '../../../src/Wrapper/LambdaWrapper'; -import { getMockedDi } from '../../lib/mocks'; -import getContext from '../../mocks/aws/context.json'; -import getEvent from '../../mocks/aws/event.json'; - -const handlers = { - SYNC_SUCCESS: () => ResponseModel.generate({ x: 'success' }, 200, 'ok'), - SYNC_THROWING: (di) => { - jest.spyOn(di.dependencies[DEFINITIONS.LOGGER], 'error'); - jest.spyOn(di.dependencies[DEFINITIONS.LOGGER], 'metric'); - - throw new LambdaTermination('SYNC_THROWING', 403, 'external'); - }, - ASYNC_SUCCESS: () => Promise.resolve(ResponseModel.generate({ x: 'success' }, 200, 'ok')), - ASYNC_THROWING: (di) => new Promise(() => { - jest.spyOn(di.dependencies[DEFINITIONS.LOGGER], 'error'); - jest.spyOn(di.dependencies[DEFINITIONS.LOGGER], 'metric'); - - throw new LambdaTermination('ASYNC_THROWING', 403, 'external'); - }), -}; - -describe('Wrapper/LambdaWrapper', () => { - let dependencyInjection = {}; - let requestService = {}; - - const configuration = { - DEFINITIONS: {}, - DEPENDENCIES: {}, - }; - - beforeEach(() => { - // Mute Winston - // eslint-disable-next-line no-underscore-dangle - jest.spyOn(console._stdout, 'write').mockImplementation(() => {}); - }); - - afterEach(() => jest.resetAllMocks()); - - describe('handleError', () => { - [ - [undefined, 400, 0], - [false, 400, 0], - [true, 400, 1], - [undefined, undefined, 1], - [undefined, false, 1], - [undefined, 500, 1], - [true, 500, 1], - ].forEach(([raiseOnEpsagon, code, expected]) => { - it(`error.raiseOnEpsagon = '${raiseOnEpsagon}', code = '${code}' logger.error called ${expected} times`, () => { - const di = getMockedDi(); - const logger = di.get(DEFINITIONS.LOGGER); - const error = { raiseOnEpsagon, code }; - - handleError(di, error); - - expect(logger.error).toHaveBeenCalledTimes(expected); - }); - - [undefined, { data: 1 }].forEach((body) => { - it('Generates a response object', () => { - const di = getMockedDi(); - const error = { raiseOnEpsagon, code, body }; - - const response = handleError(di, error); - - expect(response).toMatchSnapshot(); - }); - }); - }); - }); - - describe('LambdaWrapper', () => { - describe('executes the wrapped function', () => { - it('when it is sync', () => { - const lambda = LambdaWrapper(configuration, handlers.SYNC_SUCCESS); - expect(lambda(getEvent, getContext)).toMatchSnapshot(); - }); - - it('when it is async', async () => { - const lambda = LambdaWrapper(configuration, handlers.ASYNC_SUCCESS); - await expect(lambda(getEvent, getContext)).resolves.toMatchSnapshot(); - }); - }); - - describe('should inject dependency injection into the function', () => { - LambdaWrapper(configuration, (di, request) => { - dependencyInjection = di; - requestService = request; - })(getEvent, getContext); - - it('dependency injection variables should be an instance of the dependency injection class', () => { - expect(dependencyInjection instanceof DependencyInjection).toEqual(true); - }); - - it('dependency injection should output the event that was provided to it', () => { - expect(dependencyInjection.getEvent()).toEqual(getEvent); - }); - - it('dependency injection should output the event that was provided to it', () => { - expect(dependencyInjection.getContext()).toEqual(getContext); - }); - }); - - describe('should inject the request service into the function', () => { - LambdaWrapper(configuration, (di, request) => { - dependencyInjection = di; - requestService = request; - })(getEvent, getContext); - - it('request service variables should be an instance of the dependency injection class', () => { - expect(requestService instanceof RequestService).toEqual(true); - }); - - it('request service should contain variables that were sent to it via the event', () => { - expect(requestService.get('test', null, REQUEST_TYPES.GET)).toEqual(getEvent.queryStringParameters.test); - }); - }); - - describe('should catch exceptions and generate appropriate responses', () => { - it('Logs.error the error without error code', () => { - let infoStub; - let errorStub; - let metricStub; - - const lambda = LambdaWrapper(configuration, (di) => { - infoStub = jest.spyOn(di.dependencies[DEFINITIONS.LOGGER], 'info'); - errorStub = jest.spyOn(di.dependencies[DEFINITIONS.LOGGER], 'error'); - metricStub = jest.spyOn(di.dependencies[DEFINITIONS.LOGGER], 'metric'); - throw new Error('Undefined error'); - }); - - lambda(getEvent, getContext); - - expect(infoStub).not.toHaveBeenCalled(); - expect(errorStub).toHaveBeenCalled(); - expect(metricStub).nthCalledWith(1, 'lambda.statusCode', 500); - }); - - [400, 401, 403, 404, 409, 419, 421, 423, 499].forEach((errorCode) => { - it(`Logs.info the error with code ${errorCode}`, () => { - let infoStub; - let errorStub; - let metricStub; - - const lambda = LambdaWrapper(configuration, (di) => { - infoStub = jest.spyOn(di.dependencies[DEFINITIONS.LOGGER], 'info'); - errorStub = jest.spyOn(di.dependencies[DEFINITIONS.LOGGER], 'error'); - metricStub = jest.spyOn(di.dependencies[DEFINITIONS.LOGGER], 'metric'); - - const error = new Error('4xx error'); - error.code = errorCode; - throw error; - }); - - lambda(getEvent, getContext); - - expect(infoStub).toHaveBeenCalled(); - expect(errorStub).not.toHaveBeenCalled(); - expect(metricStub).nthCalledWith(1, 'lambda.statusCode', errorCode); - }); - }); - - [500, 501, 502, 503].forEach((errorCode) => { - it(`Logs.error the error with code ${errorCode}`, () => { - let infoStub; - let errorStub; - let metricStub; - - const lambda = LambdaWrapper(configuration, (di) => { - infoStub = jest.spyOn(di.dependencies[DEFINITIONS.LOGGER], 'info'); - errorStub = jest.spyOn(di.dependencies[DEFINITIONS.LOGGER], 'error'); - metricStub = jest.spyOn(di.dependencies[DEFINITIONS.LOGGER], 'metric'); - - const error = new Error('5xx error'); - error.code = errorCode; - throw error; - }); - - lambda(getEvent, getContext); - - expect(infoStub).not.toHaveBeenCalled(); - expect(errorStub).toHaveBeenCalled(); - expect(metricStub).nthCalledWith(1, 'lambda.statusCode', errorCode); - }); - }); - - it('Returns 500 exception with a common error', () => { - const lambda = LambdaWrapper(configuration, (di) => { - jest.spyOn(di.dependencies[DEFINITIONS.LOGGER], 'error'); - jest.spyOn(di.dependencies[DEFINITIONS.LOGGER], 'metric'); - throw new Error('Some error'); - }); - - const response = lambda(getEvent, getContext); - const body = JSON.parse(response.body); - - expect(response.statusCode).toEqual(500); - expect(body.message).toEqual('unknown error'); - }); - - it('Returns a response generated by LambdaTermination', () => { - const lambda = LambdaWrapper(configuration, (di) => { - jest.spyOn(di.dependencies[DEFINITIONS.LOGGER], 'error'); - jest.spyOn(di.dependencies[DEFINITIONS.LOGGER], 'metric'); - throw new LambdaTermination('internal', 403, 'external', 'some message'); - }); - - const response = lambda(getEvent, getContext); - const body = JSON.parse(response.body); - - expect(response.statusCode).toEqual(403); - expect(body.data).toEqual('external'); - expect(body.message).toEqual('some message'); - }); - - describe('catches sync errors', () => { - it('returns an error http response with throwError === false', () => { - const lambda = LambdaWrapper(configuration, handlers.SYNC_THROWING, false); - const outcome = lambda(getEvent, getContext); - expect(outcome).toMatchSnapshot(); - }); - - it('returns a raw error with throwError === true', () => { - const lambda = LambdaWrapper(configuration, handlers.SYNC_THROWING, true); - const outcome = lambda(getEvent, getContext); - expect(outcome).toMatchSnapshot(); - - // Be absolutely sure we got an Error object or the lambda will not count as failed - expect(outcome instanceof LambdaTermination).toEqual(true); - expect(outcome instanceof Error).toEqual(true); - }); - }); - - describe('catches async errors', () => { - it('resolves an error http response with throwError === false', async () => { - const lambda = LambdaWrapper(configuration, handlers.ASYNC_THROWING, false); - await expect(lambda(getEvent, getContext)).resolves.toMatchSnapshot(); - }); - - it('rejects the promise with throwError === true', async () => { - const lambda = LambdaWrapper(configuration, handlers.ASYNC_THROWING, true); - - // Be absolutely sure we got a rejection or the lambda will not count as failed - await expect(lambda(getEvent, getContext)).rejects.toThrowErrorMatchingSnapshot(); - }); - }); - }); - }); -}); diff --git a/tests/unit/Wrapper/__snapshots__/LambdaWrapper.test.js.snap b/tests/unit/Wrapper/__snapshots__/LambdaWrapper.test.js.snap deleted file mode 100644 index 7003622e..00000000 --- a/tests/unit/Wrapper/__snapshots__/LambdaWrapper.test.js.snap +++ /dev/null @@ -1,221 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Wrapper/LambdaWrapper LambdaWrapper executes the wrapped function when it is async 1`] = ` -Object { - "body": "{\\"data\\":{\\"x\\":\\"success\\"},\\"message\\":\\"ok\\"}", - "headers": Object { - "Access-Control-Allow-Credentials": true, - "Access-Control-Allow-Origin": "*", - "Content-Type": "application/json", - }, - "statusCode": 200, -} -`; - -exports[`Wrapper/LambdaWrapper LambdaWrapper executes the wrapped function when it is sync 1`] = ` -Object { - "body": "{\\"data\\":{\\"x\\":\\"success\\"},\\"message\\":\\"ok\\"}", - "headers": Object { - "Access-Control-Allow-Credentials": true, - "Access-Control-Allow-Origin": "*", - "Content-Type": "application/json", - }, - "statusCode": 200, -} -`; - -exports[`Wrapper/LambdaWrapper LambdaWrapper should catch exceptions and generate appropriate responses catches async errors rejects the promise with throwError === true 1`] = `"ASYNC_THROWING"`; - -exports[`Wrapper/LambdaWrapper LambdaWrapper should catch exceptions and generate appropriate responses catches async errors resolves an error http response with throwError === false 1`] = ` -Object { - "body": "{\\"data\\":\\"external\\",\\"message\\":\\"unknown error\\"}", - "headers": Object { - "Access-Control-Allow-Credentials": true, - "Access-Control-Allow-Origin": "*", - "Content-Type": "application/json", - }, - "statusCode": 403, -} -`; - -exports[`Wrapper/LambdaWrapper LambdaWrapper should catch exceptions and generate appropriate responses catches sync errors returns a raw error with throwError === true 1`] = `[Error: SYNC_THROWING]`; - -exports[`Wrapper/LambdaWrapper LambdaWrapper should catch exceptions and generate appropriate responses catches sync errors returns an error http response with throwError === false 1`] = ` -Object { - "body": "{\\"data\\":\\"external\\",\\"message\\":\\"unknown error\\"}", - "headers": Object { - "Access-Control-Allow-Credentials": true, - "Access-Control-Allow-Origin": "*", - "Content-Type": "application/json", - }, - "statusCode": 403, -} -`; - -exports[`Wrapper/LambdaWrapper handleError Generates a response object 1`] = ` -Object { - "body": "{\\"data\\":{},\\"message\\":\\"unknown error\\"}", - "headers": Object { - "Access-Control-Allow-Credentials": true, - "Access-Control-Allow-Origin": "*", - "Content-Type": "application/json", - }, - "statusCode": 400, -} -`; - -exports[`Wrapper/LambdaWrapper handleError Generates a response object 2`] = ` -Object { - "body": "{\\"data\\":{\\"data\\":1},\\"message\\":\\"unknown error\\"}", - "headers": Object { - "Access-Control-Allow-Credentials": true, - "Access-Control-Allow-Origin": "*", - "Content-Type": "application/json", - }, - "statusCode": 400, -} -`; - -exports[`Wrapper/LambdaWrapper handleError Generates a response object 3`] = ` -Object { - "body": "{\\"data\\":{},\\"message\\":\\"unknown error\\"}", - "headers": Object { - "Access-Control-Allow-Credentials": true, - "Access-Control-Allow-Origin": "*", - "Content-Type": "application/json", - }, - "statusCode": 400, -} -`; - -exports[`Wrapper/LambdaWrapper handleError Generates a response object 4`] = ` -Object { - "body": "{\\"data\\":{\\"data\\":1},\\"message\\":\\"unknown error\\"}", - "headers": Object { - "Access-Control-Allow-Credentials": true, - "Access-Control-Allow-Origin": "*", - "Content-Type": "application/json", - }, - "statusCode": 400, -} -`; - -exports[`Wrapper/LambdaWrapper handleError Generates a response object 5`] = ` -Object { - "body": "{\\"data\\":{},\\"message\\":\\"unknown error\\"}", - "headers": Object { - "Access-Control-Allow-Credentials": true, - "Access-Control-Allow-Origin": "*", - "Content-Type": "application/json", - }, - "statusCode": 400, -} -`; - -exports[`Wrapper/LambdaWrapper handleError Generates a response object 6`] = ` -Object { - "body": "{\\"data\\":{\\"data\\":1},\\"message\\":\\"unknown error\\"}", - "headers": Object { - "Access-Control-Allow-Credentials": true, - "Access-Control-Allow-Origin": "*", - "Content-Type": "application/json", - }, - "statusCode": 400, -} -`; - -exports[`Wrapper/LambdaWrapper handleError Generates a response object 7`] = ` -Object { - "body": "{\\"data\\":{},\\"message\\":\\"unknown error\\"}", - "headers": Object { - "Access-Control-Allow-Credentials": true, - "Access-Control-Allow-Origin": "*", - "Content-Type": "application/json", - }, - "statusCode": 500, -} -`; - -exports[`Wrapper/LambdaWrapper handleError Generates a response object 8`] = ` -Object { - "body": "{\\"data\\":{\\"data\\":1},\\"message\\":\\"unknown error\\"}", - "headers": Object { - "Access-Control-Allow-Credentials": true, - "Access-Control-Allow-Origin": "*", - "Content-Type": "application/json", - }, - "statusCode": 500, -} -`; - -exports[`Wrapper/LambdaWrapper handleError Generates a response object 9`] = ` -Object { - "body": "{\\"data\\":{},\\"message\\":\\"unknown error\\"}", - "headers": Object { - "Access-Control-Allow-Credentials": true, - "Access-Control-Allow-Origin": "*", - "Content-Type": "application/json", - }, - "statusCode": 500, -} -`; - -exports[`Wrapper/LambdaWrapper handleError Generates a response object 10`] = ` -Object { - "body": "{\\"data\\":{\\"data\\":1},\\"message\\":\\"unknown error\\"}", - "headers": Object { - "Access-Control-Allow-Credentials": true, - "Access-Control-Allow-Origin": "*", - "Content-Type": "application/json", - }, - "statusCode": 500, -} -`; - -exports[`Wrapper/LambdaWrapper handleError Generates a response object 11`] = ` -Object { - "body": "{\\"data\\":{},\\"message\\":\\"unknown error\\"}", - "headers": Object { - "Access-Control-Allow-Credentials": true, - "Access-Control-Allow-Origin": "*", - "Content-Type": "application/json", - }, - "statusCode": 500, -} -`; - -exports[`Wrapper/LambdaWrapper handleError Generates a response object 12`] = ` -Object { - "body": "{\\"data\\":{\\"data\\":1},\\"message\\":\\"unknown error\\"}", - "headers": Object { - "Access-Control-Allow-Credentials": true, - "Access-Control-Allow-Origin": "*", - "Content-Type": "application/json", - }, - "statusCode": 500, -} -`; - -exports[`Wrapper/LambdaWrapper handleError Generates a response object 13`] = ` -Object { - "body": "{\\"data\\":{},\\"message\\":\\"unknown error\\"}", - "headers": Object { - "Access-Control-Allow-Credentials": true, - "Access-Control-Allow-Origin": "*", - "Content-Type": "application/json", - }, - "statusCode": 500, -} -`; - -exports[`Wrapper/LambdaWrapper handleError Generates a response object 14`] = ` -Object { - "body": "{\\"data\\":{\\"data\\":1},\\"message\\":\\"unknown error\\"}", - "headers": Object { - "Access-Control-Allow-Credentials": true, - "Access-Control-Allow-Origin": "*", - "Content-Type": "application/json", - }, - "statusCode": 500, -} -`; From e22f03ac0970f3ad013a94e326f0beed0fd55806 Mon Sep 17 00:00:00 2001 From: Seb Aebischer Date: Wed, 17 Aug 2022 10:06:10 +0100 Subject: [PATCH 03/39] Implement type-safe dependency injection --- .eslintrc.yml | 1 + package.json | 1 + src/core/config.ts | 30 +++++ src/core/dependency-base.ts | 17 +++ src/core/dependency-injection.ts | 109 +++++++++++++++++++ src/core/lambda-wrapper.ts | 48 ++++++++ src/index.ts | 25 +++++ src/services/SQSService.ts | 27 +++++ tests/.eslintrc.yml | 3 + tests/unit/core/dependency-base.spec.ts | 11 ++ tests/unit/core/dependency-injection.spec.ts | 51 +++++++++ tests/unit/index.spec.ts | 17 +++ tsconfig.json | 1 + yarn.lock | 5 + 14 files changed, 346 insertions(+) create mode 100644 src/core/config.ts create mode 100644 src/core/dependency-base.ts create mode 100644 src/core/dependency-injection.ts create mode 100644 src/core/lambda-wrapper.ts create mode 100644 src/services/SQSService.ts create mode 100644 tests/unit/core/dependency-base.spec.ts create mode 100644 tests/unit/core/dependency-injection.spec.ts create mode 100644 tests/unit/index.spec.ts diff --git a/.eslintrc.yml b/.eslintrc.yml index 79e22cda..794163b6 100644 --- a/.eslintrc.yml +++ b/.eslintrc.yml @@ -12,3 +12,4 @@ ignorePatterns: rules: unicorn/prefer-node-protocol: off + '@typescript-eslint/no-explicit-any': off diff --git a/package.json b/package.json index 7d422b37..0b76c760 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "devDependencies": { "@comicrelief/eslint-config": "^2.0.3", "@istanbuljs/nyc-config-typescript": "^1.0.2", + "@types/aws-lambda": "^8.10.102", "@types/jest": "^28.1.6", "@types/node": "14", "@typescript-eslint/eslint-plugin": "^5.33.0", diff --git a/src/core/config.ts b/src/core/config.ts new file mode 100644 index 00000000..9eb01e95 --- /dev/null +++ b/src/core/config.ts @@ -0,0 +1,30 @@ +import DependencyAwareClass from './dependency-base'; + +/** + * Config for Lambda Wrapper defining dependencies and their configuration. + */ +export interface LambdaWrapperConfig { + /** + * Dependencies to be provided by dependency injection. + * + * TODO: should this just be a list instead? keys are currently unused + */ + dependencies: Record; +} + +/** + * Combine two Lambda Wrapper configs. + * + * @param old Current config. + * @param new_ New config that will override the old. + */ +export function mergeConfig(old: LambdaWrapperConfig, new_: LambdaWrapperConfig): LambdaWrapperConfig { + return { + ...old, + ...new_, + dependencies: { + ...old.dependencies, + ...new_.dependencies, + }, + }; +} diff --git a/src/core/dependency-base.ts b/src/core/dependency-base.ts new file mode 100644 index 00000000..45881e85 --- /dev/null +++ b/src/core/dependency-base.ts @@ -0,0 +1,17 @@ +import DependencyInjection from './dependency-injection'; + +/** + * Base class for dependencies. + */ +export default class DependencyAwareClass { + constructor(readonly di: DependencyInjection) {} + + /** + * Get dependency injection container. + * + * @deprecated Use `this.di` instead. + */ + getContainer(): DependencyInjection { + return this.di; + } +} diff --git a/src/core/dependency-injection.ts b/src/core/dependency-injection.ts new file mode 100644 index 00000000..d324d87b --- /dev/null +++ b/src/core/dependency-injection.ts @@ -0,0 +1,109 @@ +import { Context } from 'aws-lambda'; + +import { LambdaWrapperConfig } from './config'; +import DependencyAwareClass from './dependency-base'; + +// eslint-disable-next-line no-use-before-define +type Class = new (di: DependencyInjection) => T; + +/** + * Dependency injection container. + * + * Dependencies (singleton instances of dependency-aware classes) are provided + * to the main Lambda handler and other dependencies via this class. + */ +export default class DependencyInjection { + /** + * Instantiated dependencies. + */ + readonly dependencies: Record; + + /** + * True until all dependencies have been constructed. + */ + private isConstructing = true; + + constructor( + readonly config: LambdaWrapperConfig, + readonly event: any, + readonly context: Context, + ) { + this.dependencies = Object.fromEntries( + Object.entries(config.dependencies) + .map(([, Constructor]) => [Constructor.name, new Constructor(this)]), + ); + + this.isConstructing = false; + } + + /** + * Get the singleton instance of the given dependency. + * + * @param dependency + */ + get(dependency: Class): T { + if (this.isConstructing) { + throw new Error( + 'Dependencies are not available in dependency class constructors.\n\n' + + 'To fix this, call `di.get` in the function where the dependency is' + + 'used instead of inside your constructor.', + ); + } + + const name = dependency.name; + + if (!this.dependencies[name]) { + throw new Error( + `${name} does not exist in dependency container\n\n` + + `Make sure you've included ${name} in the 'dependencies' key of your ` + + 'Lambda Wrapper config.', + ); + } + + return this.dependencies[name] as T; + } + + /** + * Get the event passed to AWS Lambda. + * + * @deprecated Use `di.event` instead. + */ + getEvent() { + return this.event; + } + + /** + * Get the AWS Lambda context object. + * + * @deprecated Use `di.context` instead. + */ + getContext() { + return this.context; + } + + /** + * Get Lambda Wrapper configuration. + * + * @deprecated Use `di.config` instead. + */ + getConfiguration() { + return this.config; + } + + /** + * True if the function is being executed in `serverless-offline`. + * + * We use the following checks for this: + * + * - if there is no function ARN, or the ARN includes 'offline' + * - if `process.env.USE_SERVERLESS_OFFLINE` is set + * + * TODO: This is nothing to do with dependency injection and should be moved + * somewhere else! Any ideas? + */ + get isOffline(): boolean { + return !this.context.invokedFunctionArn + || this.context.invokedFunctionArn.includes('offline') + || !!process.env.USE_SERVERLESS_OFFLINE; + } +} diff --git a/src/core/lambda-wrapper.ts b/src/core/lambda-wrapper.ts new file mode 100644 index 00000000..e8f733b3 --- /dev/null +++ b/src/core/lambda-wrapper.ts @@ -0,0 +1,48 @@ +import { Context, Handler } from 'aws-lambda'; +import Epsagon from 'epsagon'; + +import { LambdaWrapperConfig, mergeConfig } from './config'; +import DependencyInjection from './dependency-injection'; + +export default class LambdaWrapper { + constructor(readonly config: LambdaWrapperConfig) {} + + /** + * Returns a new Lambda Wrapper with the given configuration applied. + * + * TODO: shall we call this `extend` instead? + * + * @param config + */ + configure(config: LambdaWrapperConfig) { + return new LambdaWrapper(mergeConfig(this.config, config)); + } + + /** + * Wrap the given function. + */ + wrap(handler: (di: DependencyInjection) => Promise): Handler { + let wrapper = async (event: any, context: Context) => { + const di = new DependencyInjection(this.config, event, context); + + // If the event is a warmup, don't bother running the function + if (di.event.source === 'serverless-plugin-warmup') { + return 'Lambda is warm!'; + } + + return handler(di); + }; + + // If Epsagon is enabled, wrap the instance in the Epsagon wrapper + if (process.env.EPSAGON_TOKEN && process.env.EPSAGON_SERVICE_NAME) { + Epsagon.init({ + token: process.env.EPSAGON_TOKEN, + appName: process.env.EPSAGON_SERVICE_NAME, + }); + + wrapper = Epsagon.lambdaWrapper(wrapper); + } + + return wrapper; + } +} diff --git a/src/index.ts b/src/index.ts index e69de29b..593af2ef 100644 --- a/src/index.ts +++ b/src/index.ts @@ -0,0 +1,25 @@ +import LambdaWrapper from './core/lambda-wrapper'; +import SQSService from './services/SQSService'; + +/** + * Lambda Wrapper preconfigured with our core services that can be used + * straight out of the box. + * + * Use `lambdaWrapper.configure()` to add your own dependencies. + */ +const lambdaWrapper = new LambdaWrapper({ + dependencies: { + SQSService, + }, +}); + +export default lambdaWrapper; + +export { Context, Handler } from 'aws-lambda'; + +export { LambdaWrapperConfig } from './core/config'; +export { default as DependencyAwareClass } from './core/dependency-base'; +export { default as DependencyInjection } from './core/dependency-injection'; +export { default as LambdaWrapper } from './core/lambda-wrapper'; + +export { default as SQSService } from './services/SQSService'; diff --git a/src/services/SQSService.ts b/src/services/SQSService.ts new file mode 100644 index 00000000..f5fd70ec --- /dev/null +++ b/src/services/SQSService.ts @@ -0,0 +1,27 @@ +import { SQS } from 'aws-sdk'; + +import DependencyAwareClass from '../core/dependency-base'; + +/** + * Helper service for working with SQS. + * + * Config for this service goes in the `sqs` key of your Lambda Wrapper config. + * TODO: more about config + */ +export default class SQSService extends DependencyAwareClass { + private readonly sqs = new SQS({ + region: process.env.REGION, + httpOptions: { + connectTimeout: 8 * 1000, // longest publish on NOTV took 5 seconds + timeout: 8 * 1000, + }, + maxRetries: 3, // default is 3, we can change that + }); + + async send(queue: string, message: any): Promise { + await this.sqs.sendMessage({ + QueueUrl: queue, + MessageBody: message, + }).promise(); + } +} diff --git a/tests/.eslintrc.yml b/tests/.eslintrc.yml index 446a915a..091b3d95 100644 --- a/tests/.eslintrc.yml +++ b/tests/.eslintrc.yml @@ -1,2 +1,5 @@ extends: - '@comicrelief/eslint-config/mixins/jest' + +rules: + max-classes-per-file: off diff --git a/tests/unit/core/dependency-base.spec.ts b/tests/unit/core/dependency-base.spec.ts new file mode 100644 index 00000000..de8f26ce --- /dev/null +++ b/tests/unit/core/dependency-base.spec.ts @@ -0,0 +1,11 @@ +import { Context, DependencyAwareClass, DependencyInjection } from '@/src'; + +describe('unit.core.DependencyAwareClass', () => { + describe('getContainer', () => { + it('should return the DependencyInjection instance', () => { + const di = new DependencyInjection({ dependencies: {} }, {}, {} as Context); + const dep = new DependencyAwareClass(di); + expect(dep.getContainer()).toBe(di); + }); + }); +}); diff --git a/tests/unit/core/dependency-injection.spec.ts b/tests/unit/core/dependency-injection.spec.ts new file mode 100644 index 00000000..205edd85 --- /dev/null +++ b/tests/unit/core/dependency-injection.spec.ts @@ -0,0 +1,51 @@ +import { Context, DependencyAwareClass, DependencyInjection } from '@/src'; +import mockContext from '@/tests/mocks/aws/context.json'; +import mockEvent from '@/tests/mocks/aws/event.json'; + +class A extends DependencyAwareClass {} + +class B extends DependencyAwareClass {} + +class C extends DependencyAwareClass {} + +describe('unit.core.DependencyInjection', () => { + const mockConfig = { + dependencies: { + A, + B, + }, + }; + const di = new DependencyInjection(mockConfig, mockEvent, mockContext as Context); + + describe('get', () => { + it('should return an instance of A, given A', () => { + expect(di.get(A)).toBeInstanceOf(A); + }); + + it('should return an instance of B, given B', () => { + expect(di.get(B)).toBeInstanceOf(B); + }); + + it('should throw, given an unknown dependency', () => { + expect(() => di.get(C)).toThrow('C does not exist in dependency container'); + }); + }); + + describe('getEvent', () => { + it('should return the event', () => { + expect(di.getEvent()).toBe(mockEvent); + }); + }); + + describe('getContext', () => { + it('should return the Lambda context', () => { + expect(di.getContext()).toBe(mockContext); + }); + }); + + describe('getConfiguration', () => { + it('should return the config object', () => { + expect(di.getConfiguration()).toBe(mockConfig); + }); + }); +}); diff --git a/tests/unit/index.spec.ts b/tests/unit/index.spec.ts new file mode 100644 index 00000000..f206c71f --- /dev/null +++ b/tests/unit/index.spec.ts @@ -0,0 +1,17 @@ +import lambdaWrapper, { + LambdaWrapper, + SQSService, +} from '@/src'; + +describe('unit.index', () => { + describe('default export', () => { + it('should be a LambdaWrapper instance', () => { + expect(lambdaWrapper).toBeInstanceOf(LambdaWrapper); + }); + + it('should be configured with SQSService', () => { + const deps = Object.values(lambdaWrapper.config.dependencies); + expect(deps).toContain(SQSService); + }); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 17d11e6b..8ecd2c59 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,6 +2,7 @@ "extends": "./tsconfig-base.json", "compilerOptions": { "rootDir": ".", + "resolveJsonModule": true, }, "include": [ "src/**/*.ts", diff --git a/yarn.lock b/yarn.lock index b06fcbc4..9bca783e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1748,6 +1748,11 @@ resolved "https://registry.yarnpkg.com/@tsconfig/node16/-/node16-1.0.3.tgz#472eaab5f15c1ffdd7f8628bd4c4f753995ec79e" integrity sha512-yOlFc+7UtL/89t2ZhjPvvB/DeAr3r+Dq58IgzsFkOAvVC6NMJXmCGjbptdXdR9qsX7pKcTL+s87FtYREi2dEEQ== +"@types/aws-lambda@^8.10.102": + version "8.10.102" + resolved "https://registry.yarnpkg.com/@types/aws-lambda/-/aws-lambda-8.10.102.tgz#d2402224ec30cdddfb669005c25b6ee01fd6f5be" + integrity sha512-BT05v46n9KtSHa9SgGuOvm49eSruJ9utD8iNXpdpuUVYk8wOcqmm1LEzpNRkrXxD0CULc38sdLpk6q3Wa2WOwg== + "@types/babel__core@^7.1.14": version "7.1.19" resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.1.19.tgz#7b497495b7d1b4812bdb9d02804d0576f43ee460" From b9a9f83c90237e963199bbcdd82ae0808bed8a68 Mon Sep 17 00:00:00 2001 From: Seb Aebischer Date: Wed, 17 Aug 2022 11:16:38 +0100 Subject: [PATCH 04/39] Configure Jest --- jest.config.js | 9 +++++++++ package.json | 8 ++++++++ yarn.lock | 46 ++++++++++++++++++++++++++++++++++++---------- 3 files changed, 53 insertions(+), 10 deletions(-) create mode 100644 jest.config.js diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 00000000..4ce18349 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,9 @@ +/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + testMatch: ['/tests/unit/**/*.spec.ts'], + moduleNameMapper: { + '^@/(.*)$': '/$1', + }, +}; diff --git a/package.json b/package.json index 0b76c760..2318bf45 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,13 @@ "main": "dist/index.js", "author": "Adam Clark", "license": "ISC", + "repository": { + "type": "git", + "url": "git+https://github.com/comicrelief/lambda-wrapper.git" + }, + "files": [ + "dist" + ], "scripts": { "prepare": "yarn clean && yarn build", "build": "tsc -p tsconfig-build.json", @@ -28,6 +35,7 @@ "jest": "^28.1.3", "nyc": "^15.1.0", "semantic-release": "^19.0.3", + "ts-jest": "^28.0.8", "ts-node": "^10.9.1", "tsconfig-paths": "^4.1.0", "typescript": "^4.7.4" diff --git a/yarn.lock b/yarn.lock index 9bca783e..068141bb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2348,6 +2348,13 @@ browserslist@^4.20.2: node-releases "^2.0.6" update-browserslist-db "^1.0.5" +bs-logger@0.x: + version "0.2.6" + resolved "https://registry.yarnpkg.com/bs-logger/-/bs-logger-0.2.6.tgz#eb7d365307a72cf974cc6cda76b68354ad336bd8" + integrity sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog== + dependencies: + fast-json-stable-stringify "2.x" + bser@2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/bser/-/bser-2.1.1.tgz#e6787da20ece9d07998533cfd9de6f5c38f4bc05" @@ -3354,7 +3361,7 @@ fast-glob@^3.2.9: merge2 "^1.3.0" micromatch "^4.0.4" -fast-json-stable-stringify@^2.0.0: +fast-json-stable-stringify@2.x, fast-json-stable-stringify@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633" integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== @@ -4579,7 +4586,7 @@ jest-snapshot@^28.1.3: pretty-format "^28.1.3" semver "^7.3.5" -jest-util@^28.1.3: +jest-util@^28.0.0, jest-util@^28.1.3: version "28.1.3" resolved "https://registry.yarnpkg.com/jest-util/-/jest-util-28.1.3.tgz#f4f932aa0074f0679943220ff9cbba7e497028b0" integrity sha512-XdqfpHwpcSRko/C35uLYFM2emRAltIIKZiJ9eAmhjsj0CqZMa0p1ib0R5fWIqGhn1a103DebTbpqIaP1qCQ6tQ== @@ -4945,6 +4952,11 @@ lodash.isstring@^4.0.1: resolved "https://registry.yarnpkg.com/lodash.isstring/-/lodash.isstring-4.0.1.tgz#d527dfb5456eca7cc9bb95d5daeaf88ba54a5451" integrity sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw== +lodash.memoize@4.x: + version "4.1.2" + resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe" + integrity sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag== + lodash.merge@^4.6.2: version "4.6.2" resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a" @@ -5003,7 +5015,7 @@ make-dir@^3.0.0, make-dir@^3.0.2: dependencies: semver "^6.0.0" -make-error@^1.1.1: +make-error@1.x, make-error@^1.1.1: version "1.3.6" resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.6.tgz#2eb2e37ea9b67c4891f684a1394799af484cf7a2" integrity sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw== @@ -6334,18 +6346,18 @@ semver-regex@^3.1.2: resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7" integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ== -semver@^6.0.0, semver@^6.3.0: - version "6.3.0" - resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d" - integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw== - -semver@^7.0.0, semver@^7.1.1, semver@^7.1.2, semver@^7.3.2, semver@^7.3.4, semver@^7.3.5, semver@^7.3.7: +semver@7.x, semver@^7.0.0, semver@^7.1.1, semver@^7.1.2, semver@^7.3.2, semver@^7.3.4, semver@^7.3.5, semver@^7.3.7: version "7.3.7" resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.7.tgz#12c5b649afdbf9049707796e22a4028814ce523f" integrity sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g== dependencies: lru-cache "^6.0.0" +semver@^6.0.0, semver@^6.3.0: + version "6.3.0" + resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d" + integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw== + set-blocking@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7" @@ -6804,6 +6816,20 @@ triple-beam@^1.3.0: resolved "https://registry.yarnpkg.com/triple-beam/-/triple-beam-1.3.0.tgz#a595214c7298db8339eeeee083e4d10bd8cb8dd9" integrity sha512-XrHUvV5HpdLmIj4uVMxHggLbFSZYIn7HEWsqePZcI50pco+MPqJ50wMGY794X7AOOhxOBAjbkqfAbEe/QMp2Lw== +ts-jest@^28.0.8: + version "28.0.8" + resolved "https://registry.yarnpkg.com/ts-jest/-/ts-jest-28.0.8.tgz#cd204b8e7a2f78da32cf6c95c9a6165c5b99cc73" + integrity sha512-5FaG0lXmRPzApix8oFG8RKjAz4ehtm8yMKOTy5HX3fY6W8kmvOrmcY0hKDElW52FJov+clhUbrKAqofnj4mXTg== + dependencies: + bs-logger "0.x" + fast-json-stable-stringify "2.x" + jest-util "^28.0.0" + json5 "^2.2.1" + lodash.memoize "4.x" + make-error "1.x" + semver "7.x" + yargs-parser "^21.0.1" + ts-node@^10.9.1: version "10.9.1" resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-10.9.1.tgz#e73de9102958af9e1f0b168a6ff320e25adcff4b" @@ -7295,7 +7321,7 @@ yargs-parser@^20.2.2, yargs-parser@^20.2.3: resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-20.2.9.tgz#2eb7dc3b0289718fc295f362753845c41a0c94ee" integrity sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w== -yargs-parser@^21.0.0: +yargs-parser@^21.0.0, yargs-parser@^21.0.1: version "21.1.1" resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-21.1.1.tgz#9096bceebf990d21bb31fa9516e0ede294a77d35" integrity sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw== From ef4c36e6abfba25c9dea50276b938fc3e8afa539 Mon Sep 17 00:00:00 2001 From: Seb Aebischer Date: Thu, 18 Aug 2022 10:48:13 +0100 Subject: [PATCH 05/39] Add tests to cover package exports --- tests/unit/index.spec.ts | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/unit/index.spec.ts b/tests/unit/index.spec.ts index f206c71f..21c606c9 100644 --- a/tests/unit/index.spec.ts +++ b/tests/unit/index.spec.ts @@ -1,4 +1,11 @@ +import _DependencyAwareClass from '@/src/core/dependency-base'; +import _DependencyInjection from '@/src/core/dependency-injection'; +import _LambdaWrapper from '@/src/core/lambda-wrapper'; +import _SQSService from '@/src/services/SQSService'; + import lambdaWrapper, { + DependencyAwareClass, + DependencyInjection, LambdaWrapper, SQSService, } from '@/src'; @@ -14,4 +21,22 @@ describe('unit.index', () => { expect(deps).toContain(SQSService); }); }); + + // these tests prevent accidental removal of exports + + it('should export DependencyAwareClass', () => { + expect(DependencyAwareClass).toBe(_DependencyAwareClass); + }); + + it('should export DependencyInjection', () => { + expect(DependencyInjection).toBe(_DependencyInjection); + }); + + it('should export LambdaWrapper', () => { + expect(LambdaWrapper).toBe(_LambdaWrapper); + }); + + it('should export SQSService', () => { + expect(SQSService).toBe(_SQSService); + }); }); From 5de714e686920e5921860df7bf8435fa08aca3d9 Mon Sep 17 00:00:00 2001 From: Seb Aebischer Date: Thu, 18 Aug 2022 10:49:44 +0100 Subject: [PATCH 06/39] Use lambda types exported from package root --- src/core/lambda-wrapper.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/lambda-wrapper.ts b/src/core/lambda-wrapper.ts index e8f733b3..9a6167fa 100644 --- a/src/core/lambda-wrapper.ts +++ b/src/core/lambda-wrapper.ts @@ -1,6 +1,6 @@ -import { Context, Handler } from 'aws-lambda'; import Epsagon from 'epsagon'; +import { Context, Handler } from '../index'; import { LambdaWrapperConfig, mergeConfig } from './config'; import DependencyInjection from './dependency-injection'; From 15f6a9664e2460e5a019a8561358b0e07f743e06 Mon Sep 17 00:00:00 2001 From: Seb Aebischer Date: Thu, 18 Aug 2022 21:57:46 +0100 Subject: [PATCH 07/39] Add configuration for SQSService --- src/index.ts | 6 ++++- src/services/SQSService.ts | 22 ++++++++++++++++++ tests/unit/services/SQSService.spec.ts | 31 ++++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 tests/unit/services/SQSService.spec.ts diff --git a/src/index.ts b/src/index.ts index 593af2ef..b82d8d36 100644 --- a/src/index.ts +++ b/src/index.ts @@ -22,4 +22,8 @@ export { default as DependencyAwareClass } from './core/dependency-base'; export { default as DependencyInjection } from './core/dependency-injection'; export { default as LambdaWrapper } from './core/lambda-wrapper'; -export { default as SQSService } from './services/SQSService'; +export { + default as SQSService, + SQSServiceConfig, + WithSQSServiceConfig, +} from './services/SQSService'; diff --git a/src/services/SQSService.ts b/src/services/SQSService.ts index f5fd70ec..a757e58b 100644 --- a/src/services/SQSService.ts +++ b/src/services/SQSService.ts @@ -1,6 +1,16 @@ import { SQS } from 'aws-sdk'; import DependencyAwareClass from '../core/dependency-base'; +import DependencyInjection from '../core/dependency-injection'; + +export interface SQSServiceConfig { + queues?: Record; + queueConsumers?: Record; +} + +export interface WithSQSServiceConfig { + sqs?: SQSServiceConfig; +} /** * Helper service for working with SQS. @@ -18,6 +28,18 @@ export default class SQSService extends DependencyAwareClass { maxRetries: 3, // default is 3, we can change that }); + readonly queues: Record; + + readonly queueConsumers: Record; + + constructor(di: DependencyInjection) { + super(di); + + const config = (this.di.config as WithSQSServiceConfig).sqs; + this.queues = config?.queues || {}; + this.queueConsumers = config?.queueConsumers || {}; + } + async send(queue: string, message: any): Promise { await this.sqs.sendMessage({ QueueUrl: queue, diff --git a/tests/unit/services/SQSService.spec.ts b/tests/unit/services/SQSService.spec.ts new file mode 100644 index 00000000..40bfa7b4 --- /dev/null +++ b/tests/unit/services/SQSService.spec.ts @@ -0,0 +1,31 @@ +import { + Context, + DependencyInjection, + LambdaWrapperConfig, + SQSService, + WithSQSServiceConfig, +} from '@/src'; + +const config: LambdaWrapperConfig & WithSQSServiceConfig = { + dependencies: { + SQSService, + }, + sqs: { + queues: { + submissions: 'service-name-stage-submissions.fifo', + }, + queueConsumers: { + submissions: 'SubmissionsConsumer', + }, + }, +}; + +const di = new DependencyInjection(config, {}, {} as Context); +const sqs = di.get(SQSService); + +describe('unit.service.SQSService', () => { + it('should load config from the `sqs` key', () => { + expect(sqs.queues).toEqual(config.sqs?.queues); + expect(sqs.queueConsumers).toEqual(config.sqs?.queueConsumers); + }); +}); From 76a9cfc1068b9e4fec917f7294c855187f3b4d86 Mon Sep 17 00:00:00 2001 From: Seb Aebischer Date: Thu, 18 Aug 2022 22:50:16 +0100 Subject: [PATCH 08/39] Update docs with dependency and config examples --- README.md | 138 +++++++++++++++++++++++------------- docs/services/SQSService.md | 107 ++++++++++++++++++++++++++++ 2 files changed, 197 insertions(+), 48 deletions(-) create mode 100644 docs/services/SQSService.md diff --git a/README.md b/README.md index fab1d61f..f02e6c2b 100644 --- a/README.md +++ b/README.md @@ -4,95 +4,137 @@ [![semantic-release](https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg)](https://github.com/semantic-release/semantic-release) [![semantic-release](https://badge.fury.io/js/%40comicrelief%2Flambda-wrapper.svg)](https://www.npmjs.com/package/@comicrelief/lambda-wrapper) -When writing Serverless endpoints, we have found ourselves replicating a lot of boiler plate code to do basic actions, such as reading request variables or writing to SQS. The aim of this package is to provide a wrapper for our Lambda functions, to provide some level of dependency and configuration injection and to reduce time spent on project setup. +When writing Serverless applications, we have found ourselves replicating a lot of boilerplate code to do basic actions, such as reading request data or sending messages to SQS. The aim of this package is to provide a wrapper for our Lambda functions, to provide some level of dependency and configuration injection and to reduce time spent on project setup. -## Installation & usage +## Getting started -Install via npm: - -```bash -npm install --save @comicrelief/lambda-wrapper -``` - -Or via yarn: +Install via npm or Yarn: ```bash +npm i @comicrelief/lambda-wrapper +# or yarn add @comicrelief/lambda-wrapper ``` -You can then wrap your lambdas as follows. +You can then wrap your Lambda handler functions like this: -```js -import { - LambdaWrapper, +```ts +// src/Action/Hello.ts +import lambdaWrapper, { ResponseModel, RequestService, } from '@comicrelief/lambda-wrapper'; -export default LambdaWrapper({}, (di, request, done) => { - const response = new ResponseModel({}, 200, `hello ${request.get('name', 'nobody')}`); - done(null, response.generate()); +export default lambdaWrapper.wrap(async (di) => { + const request = di.get(RequestService); + return ResponseModel.generate( + {}, + 200, + `hello ${request.get('name', 'nobody')}`, + ); }); ``` -## Serverless Offline & SQS Emulation +Here we've used the default export `lambdaWrapper` which is a preconfigured instance that can be used out of the box. However, you'll likely want to add your own dependencies and service config using the `configure` method: -Serverless Offline only emulates API Gateway and Lambda, so publishing an SQS message would use the real SQS queue and trigger the consumer function (if any) in AWS. When working with offline code, you often want the local functions to be invoked instead. +```ts +// src/Config/LambdaWrapper.ts +import lambdaWrapper from '@comicrelief/lambda-wrapper'; -Offline SQS behaviour can be configured by setting the `LAMBDA_WRAPPER_OFFLINE_SQS_MODE` environment variable. Available modes are: +export default lambdaWrapper.configure({ + // your config goes here +}); +``` -- `direct` (the default): invokes the consumer function directly via an offline Lambda endpoint -- `local`: send messages to an offline SQS endpoint, such as Localstack -- `aws`: no special handling of SQS offline; messages will be sent to AWS +`configure` returns a new Lambda Wrapper instance with the given configuration. You'll want to export it and then use this when wrapping your handler functions. -Details of each mode are documented in the sections below. When you send a message using `SQSService.prototype.publish`, it will check which mode to use and dispatch the message appropriately. These modes take effect only when running offline (as defined by `DependencyInjection.prototype.isOffline`). In a deployed environment, SQS messages will always be sent to AWS SQS. +Read the next section to see what goes inside the config object! -### Direct Lambda mode +## Dependencies -This is the default mode if `LAMBDA_WRAPPER_OFFLINE_SQS_MODE` is not set. A Lambda client will be created and the message will be delivered to the offline Lambda endpoint, effectively running the consumer function _immediately_ as part of the original Lambda invocation. This works very well in the offline environment because invoking a Lambda function will trigger its whole (local) execution tree. +Lambda Wrapper comes with some commonly used dependencies built in: -To take advantage of SQS emulation, you will need to define the following in the implementing service: +- [SQSService](docs/services/SQSService.md) -**QUEUE_CONSUMERS** +Access these via dependency injection. You've already seen an example of this. Pass the dependency class to `di.get()` to get its instance. -In your `src/Config/Configuration` define a `QUEUE_CONSUMERS` object. `QUEUE_CONSUMERS` will map the queue name to the fully qualified `FunctionName` that we want to trigger when messages are published to that queue. +```ts +export default lambdaWrapper.wrap(async (di) => { + const request = di.get(RequestService); + const sqs = di.get(SQSService); + // ... +}); +``` -You will need to export `QUEUE_CONSUMERS` as part of your default export, alongside `DEFINITIONS`, `DEPENDENCIES`, `QUEUES`, `QUEUE_DEFINITIONS`, etc. +To add your own dependencies, first extend `DependencyAwareClass`. -A `Configuration` example can be found in the `serverless-prize-platform` repository [here](https://github.com/comicrelief/serverless-prize-platform/blob/master/src/Config/Configuration.js). +```ts +// src/Service/MyService.ts +import { DependencyAwareClass } from '@comicrelief/lambda-wrapper'; -**process.env.SERVICE_LAMBDA_URL** +export default class MyService extends DependencyAwareClass { + doSomething() { + // ... + } +} +``` -While creating the Lambda client, we need to point it to our offline environment. LambdaWrapper will take care of the specifics, but it will need to know the Lambda endpoint URL. This _can_ and _must_ be specified via the `SERVICE_LAMBDA_URL` environment variable. +Then add it to your Lambda Wrapper configuration in the `dependencies` key. -The URL is likely to be your localhost URL and the next available port from the offline API Gateway. So, if you are running Serverless Offline on `http://localhost:3001`, the Lambda URL is likely to be `http://localhost:3002`. You can check the port in the output during Serverless Offline startup by looking for the following line: +```ts +// src/Config/LambdaWrapper.ts +import lambdaWrapper from '@comicrelief/lambda-wrapper'; +import MyService from '../Service/MyService'; - offline: Offline [http for lambda] listening on http://localhost:3002 +export default lambdaWrapper.configure({ + dependencies: { + MyService, + }, +}); +``` + +Now you can use it inside your handler functions! + +```ts +// src/Action/DoSomething.ts +import lambdaWrapper from '../Config/LambdaWrapper'; +import MyService from '../Sevice/MyService'; -#### Caveats +export default lambdaWrapper.wrap(async (di) => { + di.get(MyService).doSomething(); +}); +``` -1. You will be running the SQS-triggered lambdas in the same Serverless Offline context as your triggering lambda. Expect logs from both lambdas in the Serverless Offline output. +## Service config -2. If you await `sqs.publish` you will effectively wait until all SQS-triggered lambdas (and possibly their own SQS-triggered lambdas) have all completed. This is necessary to avoid any pending execution (i.e. the lambda terminating before its async processes are completed). +Some dependencies need their own config. This goes in per-service keys within your Lambda Wrapper config. For an example, see [SQSService](docs/services/SQSService.md) which uses the `sqs` key. -3. If the triggered lambda incurs an exception, this will be propagated upstream, effectively killing the execution of the calling lambda. +```ts +export default lambdaWrapper.configure({ + dependencies: { + // your dependencies + }, + sqs: { + // your SQSService config + }, + // ... other configs ... +}); +``` -### Local SQS mode +## Development -Use this mode by setting `LAMBDA_WRAPPER_OFFLINE_SQS_MODE=local`. Messages will still be sent to an SQS queue, but using a locally simulated version instead of AWS. This allows you to test your service using a tool like Localstack. +### Testing -By default, messages will be sent to a SQS service running on `localhost:4576`. If you need to change the hostname, you can set `process.env.LAMBDA_WRAPPER_OFFLINE_SQS_HOST`. -Also, if you need to change the port, you can set `process.env.LAMBDA_WRAPPER_OFFLINE_SQS_PORT`. +Run `yarn test` to run the unit tests. -### AWS SQS mode +When writing a bugfix, start by writing a test that reproduces the problem. It should fail with the current version of Lambda Wrapper, and pass once you've implemented the fix. -Use this mode by setting `LAMBDA_WRAPPER_OFFLINE_SQS_MODE=aws`. Messages will be sent to the real queue in AWS. This mode is useful when a queue is consumed by an external service, rather than another function in the service under test. +When adding a feature, ensure it's covered by tests that adequately define its behaviour. -In order for queue URLs to be correctly constructed, you must either: +### Linting -- set `AWS_ACCOUNT_ID` to the account ID that hosts your queue; or -- invoke offline functions via the Lambda API, passing a context that contains a realistic `invokedFunctionArn` including the account ID. +Run `yarn lint` to check code style complies to our standard. Many problems can be auto-fixed using `yarn lint --fix`. -## Semantic release +### Releases Release management is automated using [semantic-release](https://www.npmjs.com/package/semantic-release). diff --git a/docs/services/SQSService.md b/docs/services/SQSService.md new file mode 100644 index 00000000..f82c0ae7 --- /dev/null +++ b/docs/services/SQSService.md @@ -0,0 +1,107 @@ +# SQSService + +## Usage + +SQS queues are configured inside an `sqs` key in your Lambda Wrapper config. + +The `queues` key maps short friendly names to the full SQS queue name. Usually we define queue names in our `serverless.yml` and provide them to the application via environment variables. + +```ts +const lambdaWrapper = lw.configure({ + sqs: { + queues: { + // add an entry for each queue mapping to its AWS name + submissions: process.env.SQS_QUEUE_SUBMISSIONS, + }, + }, +}); +``` + +This config is optional – not every application uses SQS! + +You can then send messages to a queue within your Lambda handler using the `send` method. + +```ts +export default lambdaWrapper.wrap(async (di) => { + const sqs = di.get(SQSService); + const message = { data: 'Hello SQS!' }; + await sqs.send('submissions', message); +}); +``` + +## Serverless Offline & SQS Emulation + +Serverless Offline only emulates API Gateway and Lambda, so sending an SQS message would use the real SQS queue and trigger the consumer function (if any) in AWS. When working with offline code, you often want the local functions to be invoked instead. + +Offline SQS behaviour can be configured by setting the `LAMBDA_WRAPPER_OFFLINE_SQS_MODE` environment variable. Available modes are: + +- `direct` (the default): invokes the consumer function directly via an offline Lambda endpoint +- `local`: send messages to an offline SQS endpoint, such as Localstack +- `aws`: no special handling of SQS offline; messages will be sent to AWS + +Details of each mode are documented in the sections below. When you send a message using `SQSService.prototype.send`, it will check which mode to use and dispatch the message appropriately. These modes take effect only when running offline (as defined by `DependencyInjection.prototype.isOffline`). In a deployed environment, SQS messages will always be sent to AWS SQS. + +### Direct Lambda mode + +This is the default mode if `LAMBDA_WRAPPER_OFFLINE_SQS_MODE` is not set. A Lambda client will be created and the message will be delivered to the offline Lambda endpoint, effectively running the consumer function _immediately_ as part of the original Lambda invocation. This works very well in the offline environment because invoking a Lambda function will trigger its whole (local) execution tree. + +To take advantage of SQS emulation, you will need to do the following in your project: + +- Include the `queueConsumers` key in your `SQSService` config. + + This maps the queue name to the fully qualified `FunctionName` that we want to trigger when messages are sent to that queue. + + Extending the example from above, your config might look like this: + + ```ts + const lambdaWrapper = lw.configure({ + sqs: { + queues: { + // Add an entry for each queue with its AWS name. + // Usually we define queue names in our serverless.yml and provide them + // to the application via environment variables. + submissions: process.env.SQS_QUEUE_SUBMISSIONS, + }, + queueConsumers: { + // See section below about offline SQS emulation. + submissions: 'SubmissionConsumer', + }, + } + }); + ``` + + Now when a message is sent using `sqs.send('submissions', message)`, the `SubmissionConsumer` function will be directly invoked to consume the message. + +- Set `process.env.SERVICE_LAMBDA_URL`. + + While creating the Lambda client, we need to point it to our offline environment. Lambda Wrapper will take care of the specifics, but it will need to know the Lambda endpoint URL. This _can_ and _must_ be specified via the `SERVICE_LAMBDA_URL` environment variable. + + The URL is likely to be your localhost URL and the next available port from the offline API Gateway. So, if you are running Serverless Offline on `http://localhost:3001`, the Lambda URL is likely to be `http://localhost:3002`. You can check the port in the output during Serverless Offline startup by looking for the following line: + + ```plaintext + offline: Offline [http for lambda] listening on http://localhost:3002 + ``` + +#### Caveats + +1. You will be running the SQS-triggered lambdas in the same Serverless Offline context as your triggering lambda. Expect logs from both lambdas in the Serverless Offline output. + +2. If you await `sqs.send` you will effectively wait until all SQS-triggered lambdas (and possibly their own SQS-triggered lambdas) have all completed. This is necessary to avoid any pending execution (i.e. the lambda terminating before its async processes are completed). + +3. If the triggered lambda incurs an exception, this will be propagated upstream, effectively killing the execution of the calling lambda. + +### Local SQS mode + +Use this mode by setting `LAMBDA_WRAPPER_OFFLINE_SQS_MODE=local`. Messages will still be sent to an SQS queue, but using a locally simulated version instead of AWS. This allows you to test your service using a tool like Localstack. + +By default, messages will be sent to a SQS service running on `localhost:4576`. If you need to change the hostname, you can set `process.env.LAMBDA_WRAPPER_OFFLINE_SQS_HOST`. +Also, if you need to change the port, you can set `process.env.LAMBDA_WRAPPER_OFFLINE_SQS_PORT`. + +### AWS SQS mode + +Use this mode by setting `LAMBDA_WRAPPER_OFFLINE_SQS_MODE=aws`. Messages will be sent to the real queue in AWS. This mode is useful when a queue is consumed by an external service, rather than another function in the service under test. + +In order for queue URLs to be correctly constructed, you must either: + +- set `AWS_ACCOUNT_ID` to the account ID that hosts your queue; or +- invoke offline functions via the Lambda API, passing a context that contains a realistic `invokedFunctionArn` including the account ID. From aa5b8793abc6a643da1330bc8e9532a23e1524f5 Mon Sep 17 00:00:00 2001 From: Seb Aebischer Date: Thu, 18 Aug 2022 22:55:57 +0100 Subject: [PATCH 09/39] Accept partial configs in `configure` You might want to use the default `lambdaWrapper` instance and only configure `SQSService` without adding new dependencies. --- src/core/config.ts | 2 +- src/core/lambda-wrapper.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/config.ts b/src/core/config.ts index 9eb01e95..0c568d39 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -18,7 +18,7 @@ export interface LambdaWrapperConfig { * @param old Current config. * @param new_ New config that will override the old. */ -export function mergeConfig(old: LambdaWrapperConfig, new_: LambdaWrapperConfig): LambdaWrapperConfig { +export function mergeConfig(old: LambdaWrapperConfig, new_: Partial): LambdaWrapperConfig { return { ...old, ...new_, diff --git a/src/core/lambda-wrapper.ts b/src/core/lambda-wrapper.ts index 9a6167fa..9a9c8d4d 100644 --- a/src/core/lambda-wrapper.ts +++ b/src/core/lambda-wrapper.ts @@ -14,7 +14,7 @@ export default class LambdaWrapper { * * @param config */ - configure(config: LambdaWrapperConfig) { + configure(config: Partial) { return new LambdaWrapper(mergeConfig(this.config, config)); } From d671144bd6f9e46026e2cdda33e3891005eb2dc2 Mon Sep 17 00:00:00 2001 From: Seb Aebischer Date: Fri, 19 Aug 2022 11:37:59 +0100 Subject: [PATCH 10/39] Type-check custom config keys --- README.md | 78 +++++++++++++++++++++++++++++++++++++- src/core/config.ts | 8 +++- src/core/lambda-wrapper.ts | 6 +-- src/index.ts | 5 ++- 4 files changed, 90 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index f02e6c2b..58b9893c 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ export default lambdaWrapper.wrap(async (di) => { }); ``` -Here we've used the default export `lambdaWrapper` which is a preconfigured instance that can be used out of the box. However, you'll likely want to add your own dependencies and service config using the `configure` method: +Here we've used the default export `lambdaWrapper` which is a preconfigured instance that can be used out of the box. You'll likely want to add your own dependencies and service config using the `configure` method: ```ts // src/Config/LambdaWrapper.ts @@ -50,6 +50,17 @@ export default lambdaWrapper.configure({ Read the next section to see what goes inside the config object! +If you want to start from scratch without the built-in dependencies, you can use the `LambdaWrapper` constructor directly. + +```ts +// src/Config/LambdaWrapper.ts +import { LambdaWrapper } from '@comicrelief/lambda-wrapper'; + +export default new LambdaWrapper({ + // your config goes here +}); +``` + ## Dependencies Lambda Wrapper comes with some commonly used dependencies built in: @@ -121,6 +132,71 @@ export default lambdaWrapper.configure({ }); ``` +To use config with your own dependencies, you need to do three things: + +1. Define the key and type of your config object. + + Using `SQSService` as an example, we have the `sqs` key which has the `SQSServiceConfig` type: + + ```ts + export interface SQSServiceConfig { + queues?: Record; + queueConsumers?: Record; + } + ``` + +2. Define a type that can be applied to a Lambda Wrapper config. + + This simply combines the key and type defined in step 1. Conventionally we name these `With...` types. + + ```ts + export interface WithSQSServiceConfig { + sqs?: SQSServiceConfig; + } + ``` + + In the case of `SQSService`, the `sqs` key is optional because this dependency is included by default and not all applications need it. If your dependency requires config in order to work, you can make this a required key. + +3. In your dependency constructor, cast the config to this type. + + ```ts + export default class SQSService extends DependencyAwareClass { + constructor(di: DependencyInjection) { + super(di); + + const config = (this.di.config as WithSQSServiceConfig).sqs; + // Bear in mind that because the `sqs` key is optional, the type of + // `config` will be `SQSServiceConfig | undefined`. Take care when + // accessing its properties! You can use optional chaining: + const queues = config?.queues || {}; + // ... + } + } + ``` + +When you go to configure your Lambda Wrapper, you can now include your dependency's config type in the generic for `configure` to get IntelliSense completions and type checking for your config keys. + +```ts +lambdaWrapper.configure({ + sqs: { + queues: 42 // Oops! This will be flaggeed as a type error by TypeScript + }, +}); +``` + +You can combine types for multiple dependencies if needed using `&`: + +```ts +lambdaWrapper.configure({ + sqs: { + // SQSService config + }, + other: { + // OtherService config + }, +}); +``` + ## Development ### Testing diff --git a/src/core/config.ts b/src/core/config.ts index 0c568d39..b5d3ecd4 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -18,7 +18,13 @@ export interface LambdaWrapperConfig { * @param old Current config. * @param new_ New config that will override the old. */ -export function mergeConfig(old: LambdaWrapperConfig, new_: Partial): LambdaWrapperConfig { +export function mergeConfig< + A extends LambdaWrapperConfig, + B extends Partial, +>( + old: A, + new_: B, +): A & B { return { ...old, ...new_, diff --git a/src/core/lambda-wrapper.ts b/src/core/lambda-wrapper.ts index 9a9c8d4d..eef7f5a1 100644 --- a/src/core/lambda-wrapper.ts +++ b/src/core/lambda-wrapper.ts @@ -4,8 +4,8 @@ import { Context, Handler } from '../index'; import { LambdaWrapperConfig, mergeConfig } from './config'; import DependencyInjection from './dependency-injection'; -export default class LambdaWrapper { - constructor(readonly config: LambdaWrapperConfig) {} +export default class LambdaWrapper { + constructor(readonly config: TConfig) {} /** * Returns a new Lambda Wrapper with the given configuration applied. @@ -14,7 +14,7 @@ export default class LambdaWrapper { * * @param config */ - configure(config: Partial) { + configure(config: Partial & TMoreConfig): LambdaWrapper { return new LambdaWrapper(mergeConfig(this.config, config)); } diff --git a/src/index.ts b/src/index.ts index b82d8d36..71ef3236 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,6 @@ +import { LambdaWrapperConfig } from './core/config'; import LambdaWrapper from './core/lambda-wrapper'; -import SQSService from './services/SQSService'; +import SQSService, { WithSQSServiceConfig } from './services/SQSService'; /** * Lambda Wrapper preconfigured with our core services that can be used @@ -7,7 +8,7 @@ import SQSService from './services/SQSService'; * * Use `lambdaWrapper.configure()` to add your own dependencies. */ -const lambdaWrapper = new LambdaWrapper({ +const lambdaWrapper = new LambdaWrapper({ dependencies: { SQSService, }, From 7dbe20bd00ea9e9fbfced76cae5a5da8bd608392 Mon Sep 17 00:00:00 2001 From: Seb Aebischer Date: Fri, 19 Aug 2022 11:52:01 +0100 Subject: [PATCH 11/39] Add JSDoc to SQSService --- src/services/SQSService.ts | 55 +++++++++++++++++++++++++++++++++++++- 1 file changed, 54 insertions(+), 1 deletion(-) diff --git a/src/services/SQSService.ts b/src/services/SQSService.ts index a757e58b..9bc17927 100644 --- a/src/services/SQSService.ts +++ b/src/services/SQSService.ts @@ -4,7 +4,36 @@ import DependencyAwareClass from '../core/dependency-base'; import DependencyInjection from '../core/dependency-injection'; export interface SQSServiceConfig { + /** + * Maps short friendly queue names to the full SQS queue name. + * + * Usually we define queue names in our `serverless.yml` and provide them to + * the application via environment variables. Example: + * + * ```ts + * { + * queues: { + * submissions: process.env.SQS_QUEUE_SUBMISSIONS, + * } + * } + * ``` + */ queues?: Record; + /** + * Maps short friendly queue names to the queue consumer function name, for + * use with offline SQS emulation. Example: + * + * ```ts + * { + * queueConsumers: { + * submissions: 'SubmissionConsumer', + * } + * } + * ``` + * + * See the [SQSService docs](../../docs/services/SQSService.md) for details + * about how this works. + */ queueConsumers?: Record; } @@ -16,7 +45,31 @@ export interface WithSQSServiceConfig { * Helper service for working with SQS. * * Config for this service goes in the `sqs` key of your Lambda Wrapper config. - * TODO: more about config + * The `queues` key maps short friendly names to the full SQS queue name. + * Usually we define queue names in our `serverless.yml` and provide them to + * the application via environment variables. + * + * ```ts + * const lambdaWrapper = lw.configure({ + * sqs: { + * queues: { + * // add an entry for each queue mapping to its AWS name + * submissions: process.env.SQS_QUEUE_SUBMISSIONS, + * }, + * }, + * }); + * ``` + * + * You can then send messages to a queue within your Lambda handler using the + * `send` method. + * + * ```ts + * export default lambdaWrapper.wrap(async (di) => { + * const sqs = di.get(SQSService); + * const message = { data: 'Hello SQS!' }; + * await sqs.send('submissions', message); + * }); + * ``` */ export default class SQSService extends DependencyAwareClass { private readonly sqs = new SQS({ From 1b4d37108ba95ac413266fee336115db9f0eef8f Mon Sep 17 00:00:00 2001 From: Seb Aebischer Date: Fri, 19 Aug 2022 11:52:51 +0100 Subject: [PATCH 12/39] Remove TODO comment Let's stick with `configure` as this feels most intuitive. --- src/core/lambda-wrapper.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/core/lambda-wrapper.ts b/src/core/lambda-wrapper.ts index eef7f5a1..9ab0d0d6 100644 --- a/src/core/lambda-wrapper.ts +++ b/src/core/lambda-wrapper.ts @@ -10,8 +10,6 @@ export default class LambdaWrapper(config: Partial & TMoreConfig): LambdaWrapper { From 912887a45d254586a0269dcd9d114c2c7f3acd95 Mon Sep 17 00:00:00 2001 From: Seb Aebischer Date: Fri, 19 Aug 2022 15:13:58 +0100 Subject: [PATCH 13/39] Migrate LoggerService --- README.md | 1 + docs/services/LoggerService.md | 56 ++++ src/index.ts | 7 + src/services/LoggerService.ts | 276 ++++++++++++++++++ tests/unit/services/LoggerService.spec.ts | 182 ++++++++++++ .../__snapshots__/LoggerService.spec.ts.snap | 138 +++++++++ 6 files changed, 660 insertions(+) create mode 100644 docs/services/LoggerService.md create mode 100644 src/services/LoggerService.ts create mode 100644 tests/unit/services/LoggerService.spec.ts create mode 100644 tests/unit/services/__snapshots__/LoggerService.spec.ts.snap diff --git a/README.md b/README.md index 58b9893c..b849dd39 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,7 @@ export default new LambdaWrapper({ Lambda Wrapper comes with some commonly used dependencies built in: +- [LoggerService](docs/services/LoggerService.md) - [SQSService](docs/services/SQSService.md) Access these via dependency injection. You've already seen an example of this. Pass the dependency class to `di.get()` to get its instance. diff --git a/docs/services/LoggerService.md b/docs/services/LoggerService.md new file mode 100644 index 00000000..5c877590 --- /dev/null +++ b/docs/services/LoggerService.md @@ -0,0 +1,56 @@ +# LoggerService + +Provides logging and integrations with our monitoring tools. + +For logging we use [Winston](https://github.com/winstonjs/winston). Errors will also be sent to [Sentry](https://sentry.io/) and [Epsagon](https://epsagon.com/) if those are configured. + +## Usage + +The logger exposes various methods that you can pass messages or objects to for logging: + +```ts +import lambdaWrapper, { LoggerService } from '@comicrelief/lambda-wrapper'; + +export default lambdaWrapper.wrap(async (di) => { + const logger = di.get(LoggerService); + + // general log message + logger.info('Doing something'); + + // tag the trace so we can find certain tracess more easily in Epsagon + logger.label('flag'); + logger.metric('transactionId', value); + + try { + // do something that might throw an error... + } catch (error) { + // log the error and flag the trace on Epsagon and Sentry + logger.error(error); + + // alternatively, use `warning` if this error is not relevant in staging + // (see Soft Warnings below) + logger.warning(error); + } +}); +``` + +## Configuration + +### Soft warnings + +The `warning` method is equivalent to `error` by default, but can be switched to use `info` by setting `LOGGER_SOFT_WARNING=1` in the environment. + +This is handy for muting certain errors in staging, where we expect our integration tests to cause a lot of errors deliberately that would otherwise spam us with Epsagon alerts. + +### Epsagon + +To configure Epsagon, set the following environment variables: + +- `EPSAGON_TOKEN` – your access token +- `EPSAGON_SERVICE_NAME` – the application name (including stage) to record traces under + +### Sentry + +To configure Sentry, set the following environment variables: + +- `RAVEN_DSN` – your Sentry DSN URL diff --git a/src/index.ts b/src/index.ts index 71ef3236..b59e7191 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,8 @@ import { LambdaWrapperConfig } from './core/config'; import LambdaWrapper from './core/lambda-wrapper'; +import LoggerService from './services/LoggerService'; import SQSService, { WithSQSServiceConfig } from './services/SQSService'; +import TimerService from './services/TimerService'; /** * Lambda Wrapper preconfigured with our core services that can be used @@ -10,7 +12,9 @@ import SQSService, { WithSQSServiceConfig } from './services/SQSService'; */ const lambdaWrapper = new LambdaWrapper({ dependencies: { + LoggerService, SQSService, + TimerService, }, }); @@ -23,6 +27,9 @@ export { default as DependencyAwareClass } from './core/dependency-base'; export { default as DependencyInjection } from './core/dependency-injection'; export { default as LambdaWrapper } from './core/lambda-wrapper'; +export { + default as LoggerService, +} from './services/LoggerService'; export { default as SQSService, SQSServiceConfig, diff --git a/src/services/LoggerService.ts b/src/services/LoggerService.ts new file mode 100644 index 00000000..2aeb2f33 --- /dev/null +++ b/src/services/LoggerService.ts @@ -0,0 +1,276 @@ +import * as Sentry from '@sentry/node'; +import { AxiosError } from 'axios'; +import Epsagon from 'epsagon'; +import Winston from 'winston'; + +import DependencyAwareClass from '../core/dependency-base'; +import DependencyInjection from '../core/dependency-injection'; + +const sentryIsAvailable = typeof process.env.RAVEN_DSN !== 'undefined' && typeof process.env.RAVEN_DSN === 'string' && process.env.RAVEN_DSN !== 'undefined'; + +// initialise the Sentry client if available +if (sentryIsAvailable) { + Sentry.init({ + dsn: process.env.RAVEN_DSN, + shutdownTimeout: 5, + environment: process.env.STAGE, + }); +} + +/** + * Provides logging and integrations with our monitoring tools. + * + * For logging we use [Winston](https://github.com/winstonjs/winston). + * Errors will also be sent to [Sentry](https://sentry.io/) and + * [Epsagon](https://epsagon.com/) if those are available. + */ +export default class LoggerService extends DependencyAwareClass { + private sentry: typeof Sentry | null; + + private winston: Winston.Logger | null; + + constructor(di: DependencyInjection) { + super(di); + + this.sentry = null; + this.winston = null; + + const { event, context } = this.di; + + if (sentryIsAvailable && !di.isOffline) { + Sentry.configureScope((scope) => { + scope.setTags({ + Event: event, + Context: context as any, + }); + scope.setExtras({ + lambda: context.functionName, + memory_size: context.memoryLimitInMB, + log_group: context.logGroupName, + log_stream: context.logStreamName, + stage: process.env.STAGE, + path: event.path, + httpMethod: event.httpMethod, + }); + }); + + this.sentry = Sentry; + } + } + + /** + * Returns a Winston logger configured for our lambdas. + * + * Note: If the lambda is executed in a `serverless-offline` context, the + * log output to console will be pretty-printed. + */ + getLogger() { + const loggerFormats = [ + Winston.format.json({ + replacer: (key, value) => { + if (value instanceof Buffer) { + return value.toString('base64'); + } + if (value instanceof Error) { + return Object.fromEntries( + Object.getOwnPropertyNames(value) + .map((errorKey) => [errorKey, (value as any)[errorKey]]), + ); + } + return value; + }, + }), + ]; + + if (this.di.isOffline) { + loggerFormats.push(Winston.format.prettyPrint()); + } + + return Winston.createLogger({ + level: 'info', + format: Winston.format.combine(...loggerFormats), + transports: [new Winston.transports.Console()], + }); + } + + /** + * Returns the logger. + * + * Uses a cached Winston logger if it has been already created, otherwise it + * creates one. + */ + get logger() { + if (!this.winston) { + this.winston = this.getLogger(); + } + + return this.winston; + } + + /** + * Get Sentry client. + */ + getSentry() { + return this.sentry; + } + + /** + * While logging an error, we should recognise axios errors and trim down the + * information to only what is useful for debugging. + * + * Keep the following keys: + * - message.config + * - message.message + * - message.response?.status + * - message.response?.data + * + * @param {object} error + */ + static processAxiosError(error: AxiosError) { + const processed: any = { + config: error.config, + message: error.message, + }; + + // It's pretty common for axios errors to not have a `response`, + // for example if there was a network error or timeout. + if (error.response) { + processed.response = { + status: error.response.status, + data: error.response.data, + }; + } + + return processed; + } + + /** + * Transform the original message before it is passed to the logger. + * + * @param message + */ + static processMessage(message: any) { + let processed = message; + + if (processed?.isAxiosError) { + processed = LoggerService.processAxiosError(processed); + } + + return processed; + } + + /** + * Log Error Message + * + * @param error object + * @param message string + */ + error(error: any, message = '') { + if (sentryIsAvailable && error instanceof Error) { + Sentry.captureException(error); + } + + if ( + typeof process.env.EPSAGON_TOKEN === 'string' + && process.env.EPSAGON_TOKEN !== 'undefined' + && typeof process.env.EPSAGON_SERVICE_NAME === 'string' + && process.env.EPSAGON_SERVICE_NAME !== 'undefined' + && error instanceof Error + ) { + Epsagon.setError(error); + } + + this.logger.log('error', message, { error: LoggerService.processMessage(error) }); + this.label('error', true); + this.metric('error', 'error', true); + } + + /** + * Log an informational message. + * + * @param message + */ + info(message: any) { + this.logger.log('info', LoggerService.processMessage(message)); + } + + /** + * Log an error, using `LoggerService.error` or `LoggerService.info` based + * on `process.env.LOGGER_SOFT_WARNING`. + * + * Please note that `LoggerService.error` and `LoggerService.info` have + * different signatures. The function uses the shared argument instead of + * introducing ambiguity. + * + * @param error + */ + warning(error: any) { + const softWarningValues = ['true', '1']; + + if (softWarningValues.includes(process.env.LOGGER_SOFT_WARNING || '')) { + return this.info(error); + } + + return this.error(error); + } + + /** + * Add a label to the function's Epsagon trace. + * + * @param descriptor + * @param silent If `false`, the label will also be logged. (default: false) + */ + label(descriptor: string, silent = false) { + if ( + typeof process.env.EPSAGON_TOKEN === 'string' + && process.env.EPSAGON_TOKEN !== 'undefined' + && typeof process.env.EPSAGON_SERVICE_NAME === 'string' + && process.env.EPSAGON_SERVICE_NAME !== 'undefined' + ) { + Epsagon.label(descriptor, true); + } + + if (!silent) { + this.logger.log('info', `label - ${descriptor}`); + } + } + + /** + * Add a metric to the function's Epsagon trace. + * + * @param descriptor + * @param stat + * @param silent If `false`, the metric will also be logged. (default: false) + */ + metric(descriptor: string, stat: number | string, silent = false) { + if ( + typeof process.env.EPSAGON_TOKEN === 'string' + && process.env.EPSAGON_TOKEN !== 'undefined' + && typeof process.env.EPSAGON_SERVICE_NAME === 'string' + && process.env.EPSAGON_SERVICE_NAME !== 'undefined' + ) { + Epsagon.label(descriptor, stat); + } + + if (silent === false) { + this.logger.log('info', `metric - ${descriptor} - ${stat}`); + } + } + + /** + * Log an object so that it can be inspected. + * + * @param action What are we doing with the object, e.g. 'Processing' + * @param object The object to be stored in logs + * @param level 'error', 'warning' or 'info' + */ + object(action: string, object: any, level: 'error' | 'warning' | 'info' = 'info') { + if (!(['error', 'warning', 'info'].includes(level))) { + throw new Error('Unrecognised log level'); + } + + const payload = JSON.stringify(object, null, 4); + + return this[level](`${action}: '${payload}'`); + } +} diff --git a/tests/unit/services/LoggerService.spec.ts b/tests/unit/services/LoggerService.spec.ts new file mode 100644 index 00000000..22259c49 --- /dev/null +++ b/tests/unit/services/LoggerService.spec.ts @@ -0,0 +1,182 @@ +import Winston from 'winston'; + +import { + Context, + DependencyInjection, + LoggerService, +} from '@/src'; +import mockEvent from '@/tests/mocks/aws/event.json'; + +const mockContext = { invokedFunctionArn: 'my-function' } as Context; + +const getLogger = (event = mockEvent, context = mockContext) => { + const di = new DependencyInjection({ dependencies: { LoggerService } }, event, context); + return new LoggerService(di); +}; + +describe('unit.services.LoggerService', () => { + const context = { invokedFunctionArn: 'my-function' } as Context; + + const axiosResponses = { + UNDEFINED: undefined, + EMPTY: {}, + HTTP_417: { + status: 417, + data: { data: 1 }, + extra: 2, + }, + }; + + afterEach(() => jest.clearAllMocks()); + + describe('logger', () => { + it('should return a logger', () => { + const logger = getLogger(undefined, context); + expect(logger.logger.constructor.name).toEqual('DerivedLogger'); + }); + + it('should not call `Winston.createLogger` twice', () => { + const winston = Symbol('winston') as unknown as Winston.Logger; + const logger = getLogger(undefined, context); + + jest.spyOn(Winston, 'createLogger').mockImplementation(() => winston); + + // use the getter several times + expect(logger.logger).toEqual(winston); + expect(logger.logger).toEqual(winston); + expect(logger.logger).toEqual(winston); + + expect(Winston.createLogger).toHaveBeenCalledTimes(1); + }); + }); + + describe('error', () => { + Object.entries(axiosResponses).forEach(([key, axiosResponse]) => { + it(`Trims down the axios error: ${key}`, () => { + const logger = getLogger(); + const log = jest.fn(); + const fakeLogger = { log } as unknown as Winston.Logger; + + jest.spyOn(logger, 'logger', 'get').mockReturnValue(fakeLogger); + + const error = { + isAxiosError: true, + raiseOnEpsagon: true, + config: { + url: 'http://localhost:9999', + method: 'get', + }, + extra: 1, + response: axiosResponse, + message: 'some-message', + }; + + logger.error(error); + + const loggerCall = log.mock.calls[0][2].error; + + expect(loggerCall).toMatchSnapshot(); + expect('extra' in loggerCall).toEqual(false); + + if (axiosResponse) { + expect('extra' in loggerCall.response).toEqual(false); + } + }); + }); + }); + + describe('info', () => { + Object.entries(axiosResponses).forEach(([key, axiosResponse]) => { + it(`Trims down the axios error: ${key}`, () => { + const logger = getLogger(); + const log = jest.fn(); + const fakeLogger = { log } as unknown as Winston.Logger; + + jest.spyOn(logger, 'logger', 'get').mockReturnValue(fakeLogger); + + const error = { + isAxiosError: true, + raiseOnEpsagon: true, + config: { + url: 'http://localhost:9999', + method: 'get', + }, + extra: 1, + response: axiosResponse, + message: 'some-message', + }; + + logger.info(error); + + const loggerCall = log.mock.calls[0][1]; + + expect(loggerCall).toMatchSnapshot(); + expect('extra' in loggerCall).toEqual(false); + + if (axiosResponse) { + expect('extra' in loggerCall.response).toEqual(false); + } + }); + }); + }); + + describe('warning', () => { + let LOGGER_SOFT_WARNING: string | undefined; + + beforeAll(() => { + LOGGER_SOFT_WARNING = process.env.LOGGER_SOFT_WARNING; + }); + + afterAll(() => { + process.env.LOGGER_SOFT_WARNING = LOGGER_SOFT_WARNING; + }); + + ([ + ['', 'error'], + ['some-value', 'error'], + ['false', 'error'], + ['0', 'error'], + ['1', 'info'], + ['true', 'info'], + ] as [string, 'error' | 'info'][]).forEach(([loggerSoftWarning, func]) => { + it(`uses 'this.logger.${func}' in ${loggerSoftWarning}`, () => { + process.env.LOGGER_SOFT_WARNING = loggerSoftWarning; + const logger = getLogger(); + + jest.spyOn(logger, func).mockImplementation(() => { /* no-op */ }); + + logger.warning({}); + + expect(logger[func]).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('object', () => { + ([ + 'error', + 'warning', + 'info', + ] as const).forEach((level) => { + [ + null, + 'a string', + { a: 1 }, + { a: { b: null }, c: 'a string' }, + ].forEach((object) => { + it(`Logs a '${JSON.stringify(object)}' with level: '${level}'`, () => { + const logger = getLogger(); + let calledArgs: any[] = []; + const fakeLog = (...args: any[]) => { calledArgs = args; }; + + jest.spyOn(logger, level).mockImplementation(fakeLog); + + logger.object('My action', object, level); + + expect(logger[level]).toHaveBeenCalledTimes(1); + expect(calledArgs[0]).toMatchSnapshot(); + }); + }); + }); + }); +}); diff --git a/tests/unit/services/__snapshots__/LoggerService.spec.ts.snap b/tests/unit/services/__snapshots__/LoggerService.spec.ts.snap new file mode 100644 index 00000000..5033ec6a --- /dev/null +++ b/tests/unit/services/__snapshots__/LoggerService.spec.ts.snap @@ -0,0 +1,138 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`unit.services.LoggerService error Trims down the axios error: EMPTY 1`] = ` +Object { + "config": Object { + "method": "get", + "url": "http://localhost:9999", + }, + "message": "some-message", + "response": Object { + "data": undefined, + "status": undefined, + }, +} +`; + +exports[`unit.services.LoggerService error Trims down the axios error: HTTP_417 1`] = ` +Object { + "config": Object { + "method": "get", + "url": "http://localhost:9999", + }, + "message": "some-message", + "response": Object { + "data": Object { + "data": 1, + }, + "status": 417, + }, +} +`; + +exports[`unit.services.LoggerService error Trims down the axios error: UNDEFINED 1`] = ` +Object { + "config": Object { + "method": "get", + "url": "http://localhost:9999", + }, + "message": "some-message", +} +`; + +exports[`unit.services.LoggerService info Trims down the axios error: EMPTY 1`] = ` +Object { + "config": Object { + "method": "get", + "url": "http://localhost:9999", + }, + "message": "some-message", + "response": Object { + "data": undefined, + "status": undefined, + }, +} +`; + +exports[`unit.services.LoggerService info Trims down the axios error: HTTP_417 1`] = ` +Object { + "config": Object { + "method": "get", + "url": "http://localhost:9999", + }, + "message": "some-message", + "response": Object { + "data": Object { + "data": 1, + }, + "status": 417, + }, +} +`; + +exports[`unit.services.LoggerService info Trims down the axios error: UNDEFINED 1`] = ` +Object { + "config": Object { + "method": "get", + "url": "http://localhost:9999", + }, + "message": "some-message", +} +`; + +exports[`unit.services.LoggerService object Logs a '"a string"' with level: 'error' 1`] = `"My action: '\\"a string\\"'"`; + +exports[`unit.services.LoggerService object Logs a '"a string"' with level: 'info' 1`] = `"My action: '\\"a string\\"'"`; + +exports[`unit.services.LoggerService object Logs a '"a string"' with level: 'warning' 1`] = `"My action: '\\"a string\\"'"`; + +exports[`unit.services.LoggerService object Logs a '{"a":{"b":null},"c":"a string"}' with level: 'error' 1`] = ` +"My action: '{ + \\"a\\": { + \\"b\\": null + }, + \\"c\\": \\"a string\\" +}'" +`; + +exports[`unit.services.LoggerService object Logs a '{"a":{"b":null},"c":"a string"}' with level: 'info' 1`] = ` +"My action: '{ + \\"a\\": { + \\"b\\": null + }, + \\"c\\": \\"a string\\" +}'" +`; + +exports[`unit.services.LoggerService object Logs a '{"a":{"b":null},"c":"a string"}' with level: 'warning' 1`] = ` +"My action: '{ + \\"a\\": { + \\"b\\": null + }, + \\"c\\": \\"a string\\" +}'" +`; + +exports[`unit.services.LoggerService object Logs a '{"a":1}' with level: 'error' 1`] = ` +"My action: '{ + \\"a\\": 1 +}'" +`; + +exports[`unit.services.LoggerService object Logs a '{"a":1}' with level: 'info' 1`] = ` +"My action: '{ + \\"a\\": 1 +}'" +`; + +exports[`unit.services.LoggerService object Logs a '{"a":1}' with level: 'warning' 1`] = ` +"My action: '{ + \\"a\\": 1 +}'" +`; + +exports[`unit.services.LoggerService object Logs a 'null' with level: 'error' 1`] = `"My action: 'null'"`; + +exports[`unit.services.LoggerService object Logs a 'null' with level: 'info' 1`] = `"My action: 'null'"`; + +exports[`unit.services.LoggerService object Logs a 'null' with level: 'warning' 1`] = `"My action: 'null'"`; From b822a6cc2db3871ff679d46aae134fcf77561dbc Mon Sep 17 00:00:00 2001 From: Seb Aebischer Date: Fri, 19 Aug 2022 15:20:02 +0100 Subject: [PATCH 14/39] Improve clarity in Dependencies section of readme --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index b849dd39..aeb577b8 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ Lambda Wrapper comes with some commonly used dependencies built in: - [LoggerService](docs/services/LoggerService.md) - [SQSService](docs/services/SQSService.md) -Access these via dependency injection. You've already seen an example of this. Pass the dependency class to `di.get()` to get its instance. +Access these via dependency injection. You've already seen an example of this where we got `RequestService`. Pass the dependency class to `di.get()` to get its instance: ```ts export default lambdaWrapper.wrap(async (di) => { @@ -105,7 +105,7 @@ export default lambdaWrapper.configure({ }); ``` -Now you can use it inside your handler functions! +Now you can use it inside your handler functions and other dependencies! ```ts // src/Action/DoSomething.ts From e5b2420bdb91a147e70805a639a2a03a4b3fe6ed Mon Sep 17 00:00:00 2001 From: Seb Aebischer Date: Fri, 19 Aug 2022 16:19:28 +0100 Subject: [PATCH 15/39] Migrate TimerService and add tests --- README.md | 1 + docs/services/TimerService.md | 19 +++++++++++++ src/index.ts | 3 ++ src/services/TimerService.ts | 33 ++++++++++++++++++++++ tests/unit/services/TimerService.spec.ts | 36 ++++++++++++++++++++++++ 5 files changed, 92 insertions(+) create mode 100644 docs/services/TimerService.md create mode 100644 src/services/TimerService.ts create mode 100644 tests/unit/services/TimerService.spec.ts diff --git a/README.md b/README.md index aeb577b8..d6cb695f 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,7 @@ Lambda Wrapper comes with some commonly used dependencies built in: - [LoggerService](docs/services/LoggerService.md) - [SQSService](docs/services/SQSService.md) +- [TimerService](docs/services/TimerService.md) Access these via dependency injection. You've already seen an example of this where we got `RequestService`. Pass the dependency class to `di.get()` to get its instance: diff --git a/docs/services/TimerService.md b/docs/services/TimerService.md new file mode 100644 index 00000000..682da089 --- /dev/null +++ b/docs/services/TimerService.md @@ -0,0 +1,19 @@ +# TimerService + +Timer helper that can be used to measure how long operations take. + +## Usage + +Start and stop the timer using the `start` and `stop` methods. + +```ts +lambdaWrapper.wrap(async (di) => { + const timer = di.get(TimerService); + + const timerId = 'someLongSlowOperation'; + timer.start(timerId); + await someLongSlowOperation(); + timer.stop(timerId); + // logs 'someLongSlowOperation took 12345 ms to complete' +}) +``` diff --git a/src/index.ts b/src/index.ts index b59e7191..9af81415 100644 --- a/src/index.ts +++ b/src/index.ts @@ -35,3 +35,6 @@ export { SQSServiceConfig, WithSQSServiceConfig, } from './services/SQSService'; +export { + default as TimerService, +} from './services/TimerService'; diff --git a/src/services/TimerService.ts b/src/services/TimerService.ts new file mode 100644 index 00000000..ccddb0b9 --- /dev/null +++ b/src/services/TimerService.ts @@ -0,0 +1,33 @@ +import DependencyAwareClass from '../core/dependency-base'; +import LoggerService from './LoggerService'; + +/** + * Timer helper that can be used to measure how long operations take. + */ +export default class TimerService extends DependencyAwareClass { + timers: Record = {}; + + /** + * Start a timer. + * + * To stop the timer, call `stop()` with the same `identifier`. + * + * @param identifier + */ + start(identifier: string) { + this.timers[identifier] = Date.now(); + } + + /** + * Stop a timer and log the elapsed time. + * + * @param identifier + */ + stop(identifier: string) { + if (identifier in this.timers) { + const logger = this.di.get(LoggerService); + const duration = Date.now() - this.timers[identifier]; + logger.info(`Timing - ${identifier} took ${duration} ms to complete`); + } + } +} diff --git a/tests/unit/services/TimerService.spec.ts b/tests/unit/services/TimerService.spec.ts new file mode 100644 index 00000000..c4dcf82e --- /dev/null +++ b/tests/unit/services/TimerService.spec.ts @@ -0,0 +1,36 @@ +import { + Context, + DependencyInjection, + LoggerService, + TimerService, +} from '@/src'; + +describe('unit.services.TimerService', () => { + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + it('should measure time between start and stop', () => { + const di = new DependencyInjection({ + dependencies: { + TimerService, + LoggerService, + }, + }, {}, {} as Context); + const timer = di.get(TimerService); + const logger = di.get(LoggerService); + + let info = 'logger.info not called!'; + jest.spyOn(logger, 'info').mockImplementation((msg: any) => { info = msg; }); + + timer.start('test'); + jest.advanceTimersByTime(12345); + timer.stop('test'); + + expect(info).toContain('test took 12345 ms to complete'); + }); +}); From 5901efe1324d837719c60cbe41d63950aa589ed6 Mon Sep 17 00:00:00 2001 From: Seb Aebischer Date: Fri, 19 Aug 2022 18:22:40 +0100 Subject: [PATCH 16/39] Migrate ResponseModel --- src/index.ts | 4 ++ src/models/ResponseModel.ts | 109 ++++++++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+) create mode 100644 src/models/ResponseModel.ts diff --git a/src/index.ts b/src/index.ts index 9af81415..fd64ef23 100644 --- a/src/index.ts +++ b/src/index.ts @@ -27,6 +27,10 @@ export { default as DependencyAwareClass } from './core/dependency-base'; export { default as DependencyInjection } from './core/dependency-injection'; export { default as LambdaWrapper } from './core/lambda-wrapper'; +export { + default as ResponseModel, +} from './models/ResponseModel'; + export { default as LoggerService, } from './services/LoggerService'; diff --git a/src/models/ResponseModel.ts b/src/models/ResponseModel.ts new file mode 100644 index 00000000..c6697c8e --- /dev/null +++ b/src/models/ResponseModel.ts @@ -0,0 +1,109 @@ +/** + * HTTP headers to be included in all responses. + */ +export const RESPONSE_HEADERS = { + 'Content-Type': 'application/json', + /** Required for CORS support to work */ + 'Access-Control-Allow-Origin': '*', + /** Required for cookies, authorization headers with HTTPS */ + 'Access-Control-Allow-Credentials': true, +}; + +/** + * Default message provided as part of response. + */ +export const DEFAULT_MESSAGE = 'success'; + +/** + * Our standard response model for HTTP endpoints. + */ +export default class ResponseModel { + body: any; + + code: any; + + constructor(data?: any, code?: number, message?: string) { + this.body = { + data: data ?? {}, + message: message ?? DEFAULT_MESSAGE, + }; + this.code = code ?? {}; + } + + /** + * Add or update a body variable. + * + * @param key + * @param value + */ + setBodyVariable(key: string, value: any) { + this.body[key] = value; + } + + /** + * Set data. + * + * @param data + */ + setData(data: object) { + this.body.data = data; + } + + /** + * Set status code. + * + * @param code + */ + setCode(code: number) { + this.code = code; + } + + /** + * Get status code. + */ + getCode() { + return this.code; + } + + /** + * Set message. + * + * @param message + */ + setMessage(message: string) { + this.body.message = message; + } + + /** + * Get message. + */ + getMessage() { + return this.body.message; + } + + /** + * Geneate a response. + */ + generate() { + return { + statusCode: this.code, + headers: RESPONSE_HEADERS, + body: JSON.stringify(this.body), + }; + } + + /** + * Shorthand static method that generates the response immediately if no + * additional processing is required. + * + * Saves only 1 line of code but keeps code terse in a lot of places. + * + * @param data + * @param code + * @param message + */ + static generate(data?: any, code?: number, message?: string) { + const response = new this(data, code, message); + return response.generate(); + } +} From 86732e2be22193b976592032a73aeb9429bc6ac1 Mon Sep 17 00:00:00 2001 From: Seb Aebischer Date: Sat, 20 Aug 2022 10:15:55 +0100 Subject: [PATCH 17/39] Migrate RequestService --- README.md | 1 + docs/services/RequestService.md | 47 +++ package.json | 2 + src/index.ts | 7 + src/services/RequestService.ts | 344 ++++++++++++++++++ tests/unit/services/RequestService.spec.ts | 260 +++++++++++++ .../__snapshots__/RequestService.spec.ts.snap | 106 ++++++ yarn.lock | 12 + 8 files changed, 779 insertions(+) create mode 100644 docs/services/RequestService.md create mode 100644 src/services/RequestService.ts create mode 100644 tests/unit/services/RequestService.spec.ts create mode 100644 tests/unit/services/__snapshots__/RequestService.spec.ts.snap diff --git a/README.md b/README.md index d6cb695f..f29dd98d 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,7 @@ export default new LambdaWrapper({ Lambda Wrapper comes with some commonly used dependencies built in: - [LoggerService](docs/services/LoggerService.md) +- [RequestService](docs/services/RequestService.md) - [SQSService](docs/services/SQSService.md) - [TimerService](docs/services/TimerService.md) diff --git a/docs/services/RequestService.md b/docs/services/RequestService.md new file mode 100644 index 00000000..fdbafed8 --- /dev/null +++ b/docs/services/RequestService.md @@ -0,0 +1,47 @@ +# RequestService + +Provides access to components of the HTTP request being handled. + +## Usage + +Since Lambda Wrapper v2, the `RequestService` instance is no longer passed as an argument to your wrapped handler, and must be obtained via `di`. + +```ts +lambdaWrapper.wrap(async (di) => { + const request = di.get(RequestService); + // get the 'name' request parameter, defaulting to 'world' if not set + const name = request.get('name', 'world'); + return ResponseModel.generate({}, 200, `Hello, ${name}`); +}); +``` + +### Headers + +- `getAllHeaders` returns an object containing all headers +- `getHeader` returns the value of an HTTP header +- `getAuthorizationToken` extracts a Bearer token from the `Authorization` header + +### Body + +For requests that submit data in their body (POST, PATCH, PUT), + +- `getAll` parses the body according to the `Content-Type` header +- `get` fetches a single value from the body + +### URL parameters + +For other request methods without a body (GET, HEAD, DELETE), + +- `getAll` returns an object containing all query string parameters +- `get` fetches a single query string parameter + +For all requests, + +- `getPathParameter` fetches a path parameter value + +### Client info + +Some limited information about the client making the request is available. + +- `getIp` returns the request's source IP address +- `getUserBrowserAndDevice` returns user agent details diff --git a/package.json b/package.json index 2318bf45..7fdd4936 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,8 @@ "@types/aws-lambda": "^8.10.102", "@types/jest": "^28.1.6", "@types/node": "14", + "@types/useragent": "^2.3.1", + "@types/xml2js": "^0.4.11", "@typescript-eslint/eslint-plugin": "^5.33.0", "@typescript-eslint/parser": "^5.33.0", "aws-sdk": "^2.1194.0", diff --git a/src/index.ts b/src/index.ts index fd64ef23..31d797ac 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ import { LambdaWrapperConfig } from './core/config'; import LambdaWrapper from './core/lambda-wrapper'; import LoggerService from './services/LoggerService'; +import RequestService from './services/RequestService'; import SQSService, { WithSQSServiceConfig } from './services/SQSService'; import TimerService from './services/TimerService'; @@ -13,6 +14,7 @@ import TimerService from './services/TimerService'; const lambdaWrapper = new LambdaWrapper({ dependencies: { LoggerService, + RequestService, SQSService, TimerService, }, @@ -34,6 +36,11 @@ export { export { default as LoggerService, } from './services/LoggerService'; +export { + default as RequestService, + REQUEST_TYPES, + RequestFile, +} from './services/RequestService'; export { default as SQSService, SQSServiceConfig, diff --git a/src/services/RequestService.ts b/src/services/RequestService.ts new file mode 100644 index 00000000..74d8df0c --- /dev/null +++ b/src/services/RequestService.ts @@ -0,0 +1,344 @@ +import QueryString from 'querystring'; + +import { APIGatewayProxyEvent } from 'aws-lambda'; +import useragent from 'useragent'; +import validate from 'validate.js/validate'; +import XML2JS from 'xml2js'; + +import DependencyAwareClass from '../core/dependency-base'; +import ResponseModel from '../models/ResponseModel'; +import LoggerService from './LoggerService'; + +export const REQUEST_TYPES = { + DELETE: 'DELETE', + GET: 'GET', + HEAD: 'HEAD', + OPTIONS: 'OPTIONS', + PATCH: 'PATCH', + POST: 'POST', + PUT: 'PUT', +}; + +export const HTTP_METHODS_WITHOUT_PAYLOADS = [ + REQUEST_TYPES.DELETE, + REQUEST_TYPES.GET, + REQUEST_TYPES.HEAD, + REQUEST_TYPES.OPTIONS, +]; + +export const HTTP_METHODS_WITH_PAYLOADS = [ + REQUEST_TYPES.PATCH, + REQUEST_TYPES.POST, + REQUEST_TYPES.PUT, +]; + +// Define action specific error types +export const ERROR_TYPES = { + VALIDATION_ERROR: new ResponseModel({}, 400, 'required fields are missing'), +}; + +export type RequestFile = { + type: string; + filename: string; + contentType: string; + content: string | Buffer; +}; + +/** + * Provides access to components of the HTTP request being handled. + */ +export default class RequestService extends DependencyAwareClass { + /** + * Get a parameter from the request. + * + * @param parameter + * @param ifNull Value to return if the parameter is not set. + * @param requestType + */ + get(parameter: string, ifNull?: string | null, requestType?: string): string | string[] | null { + const queryParameters = this.getAll(requestType); + + if (queryParameters === null) { + return ifNull ?? null; + } + + return queryParameters[parameter] ?? ifNull ?? null; + } + + /** + * Get all HTTP headers included in the request. + * + * @returns An object with a key for each header. + */ + getAllHeaders() { + const event = this.getContainer().getEvent() as APIGatewayProxyEvent; + return { ...event.headers }; + } + + /** + * Get an HTTP header from the request. + * + * The header name is case-insensitive. + * + * @param name The name of the header. + * @param [whenMissing] Value to return if the header is missing. + * (default: empty string) + */ + getHeader(name: string, whenMissing = ''): string { + const headers = this.getAllHeaders(); + if (!headers) { + return whenMissing; + } + const lowerName = name.toLowerCase(); + const key = Object.keys(headers).find((k) => k.toLowerCase() === lowerName); + return (key && headers[key]) || whenMissing; + } + + /** + * Get an authorization token from the `Authorization` header. + */ + getAuthorizationToken(): string | null { + const authorization = this.getHeader('Authorization'); + if (!authorization) { + return null; + } + + const tokenParts = authorization.split(' '); + const tokenValue = tokenParts[1]; + + if (!(tokenParts[0].toLowerCase() === 'bearer' && tokenValue)) { + return null; + } + + return tokenValue; + } + + /** + * Get a path parameter, or all path parameters if no `parameter` is given. + * + * @param parameter + * @param ifNull Value to return if the parameter is not set. + */ + getPathParameter(parameter?: string, ifNull = {}): any { + const event = this.getContainer().getEvent() as APIGatewayProxyEvent; + + // If no parameter has been requested, return all path parameters + if (!parameter && typeof event.pathParameters === 'object') { + return event.pathParameters; + } + + // If a specifc parameter has been requested, return the parameter if it exists + if ( + parameter + && typeof event.pathParameters === 'object' + && event.pathParameters !== null + && typeof event.pathParameters[parameter] !== 'undefined' + ) { + return event.pathParameters[parameter]; + } + + return ifNull; + } + + /** + * Get all request parameters + * + * @param requestType + */ + getAll(requestType?: string): any { + const event = this.getContainer().getEvent() as APIGatewayProxyEvent; + + if ( + HTTP_METHODS_WITHOUT_PAYLOADS.includes(event.httpMethod) + || HTTP_METHODS_WITHOUT_PAYLOADS.includes(requestType || '') + ) { + // get simple parameters + const params: Record = { + ...event.queryStringParameters, + }; + // add array parameters as arrays + if (event.multiValueQueryStringParameters !== null) { + Object.keys(params) + .filter((key) => key.endsWith('[]')) + .forEach((key) => { + params[key] = event.multiValueQueryStringParameters?.[key]; + }); + } + return params; + } + + if ( + HTTP_METHODS_WITH_PAYLOADS.includes(event.httpMethod) + || HTTP_METHODS_WITH_PAYLOADS.includes(requestType || '') + ) { + const contentType = this.getHeader('Content-Type'); + let queryParameters = {}; + + if (contentType.includes('application/x-www-form-urlencoded')) { + queryParameters = QueryString.parse(event.body as string); + } + + if (contentType.includes('application/json')) { + try { + queryParameters = JSON.parse(event.body as string); + } catch { + queryParameters = {}; + } + } + + if (contentType.includes('text/xml')) { + XML2JS.parseString(event.body as string, (error, result) => { + queryParameters = error ? {} : result; + }); + } + + if (contentType.includes('multipart/form-data')) { + queryParameters = this.parseForm(true); + } + + return typeof queryParameters !== 'undefined' ? queryParameters : {}; + } + + return null; + } + + /** + * Fetch the request IP address + */ + getIp(): string | null { + const event = this.getContainer().getEvent(); + + if ( + typeof event.requestContext !== 'undefined' + && typeof event.requestContext.identity !== 'undefined' + && typeof event.requestContext.identity.sourceIp !== 'undefined' + ) { + return event.requestContext.identity.sourceIp; + } + + return null; + } + + /** + * Get user agent details from the `User-Agent` header. + */ + getUserBrowserAndDevice() { + const userAgent = this.getHeader('user-agent'); + if (!userAgent) { + return null; + } + + try { + const agent = useragent.parse(userAgent); + const os = agent.os.toJSON(); + + return { + 'browser-type': agent.family, + 'browser-version': agent.toVersion(), + 'device-type': agent.device.family, + 'operating-system': os.family, + 'operating-system-version': agent.os.toVersion(), + }; + } catch { + this.di.get(LoggerService).label('user-agent-parsing-failed'); + return null; + } + } + + /** + * Test a request against validation constraints. + * + * See [validate.js](https://validatejs.org/) for how to write constraints. + * + * @param constraints + */ + validateAgainstConstraints(constraints: object): Promise { + const logger = this.di.get(LoggerService); + + return new Promise((resolve, reject) => { + const validation = validate(this.getAll(), constraints); + + if (typeof validation === 'undefined') { + resolve(); + } else { + logger.label('request-validation-failed'); + const validationErrorResponse = ERROR_TYPES.VALIDATION_ERROR; + validationErrorResponse.setBodyVariable('validation_errors', validation); + reject(validationErrorResponse); + } + }); + } + + /** + * Fetch the request multipart form. + * + * @param useBuffer Whether to return file content as a `Buffer`. + */ + parseForm(useBuffer: boolean) { + // todo: rewrite this to use a dedicated package and add error handling + /* eslint-disable @typescript-eslint/no-non-null-assertion */ + + const event = this.getContainer().getEvent() as APIGatewayProxyEvent; + const boundary = RequestService.getBoundary(event) as string; + + const body = event.isBase64Encoded + ? Buffer.from(event.body as string, 'base64').toString('binary').trim() + : event.body as string; + + const result: Record = {}; + body.split(boundary).forEach((item) => { + if (/filename=".+"/g.test(item)) { + const name = item.match(/name=".+";/g)![0].slice(6, -2); + result[name] = { + type: 'file', + filename: item.match(/filename=".+"/g)![0].slice(10, -1), + contentType: item.match(/Content-Type:\s.+/g)![0].slice(14), + content: useBuffer + ? Buffer.from(item.slice(item.search(/Content-Type:\s.+/g) + item.match(/Content-Type:\s.+/g)![0].length + 4, -4), 'binary') + : item.slice(item.search(/Content-Type:\s.+/g) + item.match(/Content-Type:\s.+/g)![0].length + 4, -4), + }; + } else if (/name=".+"/g.test(item)) { + result[item.match(/name=".+"/g)![0].slice(6, -1)] = item.slice(item.search(/name=".+"/g) + item.match(/name=".+"/g)![0].length + 4, -4); + } + }); + + /* eslint-enable @typescript-eslint/no-non-null-assertion */ + + return result; + } + + /** + * Fetch the request AWS event Records + */ + getAWSRecords() { + const event = this.getContainer().getEvent(); + const eventRecord = event.Records && event.Records[0]; + + if (typeof event.Records !== 'undefined' && typeof event.Records[0] !== 'undefined' && typeof eventRecord.eventSource !== 'undefined') { + return eventRecord; + } + return null; + } + + /** + * Gets a value independently from the case of the key. + * + * @param object + * @param key + */ + static getValueIgnoringKeyCase(object: Record, key: string): string | undefined { + const foundKey = Object.keys(object) + .find((currentKey) => currentKey.toLocaleLowerCase() === key.toLowerCase()); + return foundKey && object[foundKey]; + } + + /** + * Returns the content type + * assoiated with the request + * + * @param event + */ + static getBoundary(event: APIGatewayProxyEvent): string | undefined { + return this.getValueIgnoringKeyCase(event.headers, 'Content-Type')?.split('=')?.[1]; + } +} diff --git a/tests/unit/services/RequestService.spec.ts b/tests/unit/services/RequestService.spec.ts new file mode 100644 index 00000000..ddb4a7ca --- /dev/null +++ b/tests/unit/services/RequestService.spec.ts @@ -0,0 +1,260 @@ +import QueryString from 'querystring'; + +import { + HTTP_METHODS_WITHOUT_PAYLOADS, + HTTP_METHODS_WITH_PAYLOADS, +} from '@/src/services/RequestService'; + +import { + Context, + DependencyInjection, + LoggerService, + RequestService, +} from '@/src'; +import mockContext from '@/tests/mocks/aws/context.json'; +import baseEvent from '@/tests/mocks/aws/event.json'; + +const getEvent = (overrides = {}) => JSON.parse(JSON.stringify(({ + ...baseEvent, + ...overrides, +}))); + +const getRequestService = (event: any, context: any = mockContext) => { + const di = new DependencyInjection({ + dependencies: { + RequestService, + LoggerService, + }, + }, event, context as Context); + return new RequestService(di); +}; + +describe('unit.services.RequestService', () => { + afterEach(() => jest.resetAllMocks()); + + HTTP_METHODS_WITHOUT_PAYLOADS.forEach((httpMethod) => { + describe(`HTTP ${httpMethod}`, () => { + describe('getAll', () => { + it('should return all query string parameters as an object', () => { + const event = getEvent({ httpMethod }); + event.queryStringParameters.test = '123'; + const request = getRequestService(event); + + const params = request.getAll(); + expect(params.test).toEqual('123'); + expect(params['array[]']).toEqual(['one', 'two', 'three']); + }); + }); + + describe('get', () => { + it('should fetch a query parameter', () => { + const event = getEvent({ httpMethod }); + event.queryStringParameters.test = '123'; + const request = getRequestService(event); + + expect(request.get('test')).toEqual('123'); + }); + + it('should fetch a query parameter when the request type is given', () => { + const event = getEvent({ httpMethod }); + event.queryStringParameters.test = 123; + const request = getRequestService(event); + + const param = request.get('test', null, httpMethod); + expect(param).toEqual(event.queryStringParameters.test); + }); + + it(`should return null from a nonexistent ${httpMethod} parameter`, () => { + const event = getEvent({ httpMethod }); + const request = getRequestService(event); + + const param = request.get('fake'); + expect(param).toBeNull(); + }); + + it(`should return null from a nonexistent ${httpMethod} parameter when the request type is given`, () => { + const event = getEvent({ httpMethod }); + const request = getRequestService(event); + + const param = request.get('fake', null, httpMethod); + expect(param).toBeNull(); + }); + + it('should return an array-type query parameter if its name ends []', () => { + const event = getEvent({ httpMethod }); + const request = getRequestService(event); + + const param = request.get('array[]'); + expect(param).toEqual(['one', 'two', 'three']); + }); + }); + + describe('validateAgainstConstraints', () => { + const constraints = { + giftaid: { + numericality: true, + }, + }; + + beforeEach(() => { + // Mute Winston + jest.spyOn(process.stdout, 'write').mockImplementation(() => false); + }); + + it('should resolve if there are no validation errors', async () => { + const event = getEvent({ httpMethod }); + event.queryStringParameters.giftaid = 123; + const request = getRequestService(event); + + await expect(request.validateAgainstConstraints(constraints)).resolves.toEqual(undefined); + }); + + it('should return a response containing validation errors if the data provided is incorrect', async () => { + const event = getEvent({ httpMethod }); + event.queryStringParameters.giftaid = 'abc'; + const request = getRequestService(event); + + await expect(request.validateAgainstConstraints(constraints)).rejects.toMatchSnapshot(); + }); + }); + + describe('getUserBrowserAndDevice', () => { + it('should return null with `headers === undefined`', () => { + const event = getEvent({ httpMethod, headers: undefined }); + const request = getRequestService(event); + + expect(request.getUserBrowserAndDevice()).toEqual(null); + }); + + it('should return null with `headers === null`', () => { + const event = getEvent({ httpMethod, headers: null }); + const request = getRequestService(event); + + expect(request.getUserBrowserAndDevice()).toEqual(null); + }); + + it('should return a prettified user agent', () => { + const event = getEvent({ httpMethod }); + const request = getRequestService(event); + + expect(request.getUserBrowserAndDevice()).toEqual({ + 'browser-type': 'Safari', + 'browser-version': '9.1.1', + 'device-type': 'Other', + 'operating-system': 'Mac OS X', + 'operating-system-version': '10.11.5', + }); + }); + }); + }); + }); + + HTTP_METHODS_WITH_PAYLOADS.forEach((httpMethod) => { + const getPayloadEvent = (overrides = {}) => { + const event = getEvent({ httpMethod }); + event.headers['Content-Type'] = 'application/x-www-form-urlencoded'; + event.body = 'grant_type=client_credentials&response_type=token&token_format=opaque'; + return { ...event, ...overrides }; + }; + + const queryParameters = QueryString.parse(getPayloadEvent().body); + + describe(`HTTP ${httpMethod}`, () => { + describe('getAll', () => { + it('should return all post parameters as an array', () => { + const event = getPayloadEvent(); + const request = getRequestService(event); + + expect(request.getAll()).toEqual(queryParameters); + }); + }); + + describe('get', () => { + it('should fetch a request body parameter from an AWS event', () => { + const event = getPayloadEvent(); + const request = getRequestService(event); + + expect(request.get('grant_type')).toEqual(queryParameters.grant_type); + }); + + it('should fetch a request body parameter from an AWS event when the request type is set', () => { + const event = getPayloadEvent(); + const request = getRequestService(event); + + expect(request.get('grant_type', null, httpMethod)).toEqual(queryParameters.grant_type); + }); + + it('should return null from a non existent request body parameter from an AWS event', () => { + const event = getPayloadEvent(); + const request = getRequestService(event); + + expect(request.get('fake')).toEqual(null); + }); + + it('should return null from a non existent request body parameter from an AWS event when the request type is set', () => { + const event = getPayloadEvent(); + const request = getRequestService(event); + + expect(request.get('fake', null, httpMethod)).toEqual(null); + }); + }); + + describe('validateAgainstConstraints', () => { + const constraints = { + giftaid: { + numericality: true, + }, + }; + + beforeEach(() => { + // Mute Winston + jest.spyOn(process.stdout, 'write').mockImplementation(() => false); + }); + + it('should resolve if there are no validation errors', async () => { + const event = getPayloadEvent({ body: 'giftaid=123' }); + const request = getRequestService(event); + + await expect(request.validateAgainstConstraints(constraints)).resolves.toEqual(undefined); + }); + + it('should return a response containing validation errors if the data provided is incorrect', async () => { + const event = getPayloadEvent({ body: 'giftaid=abc' }); + const request = getRequestService(event); + + await expect(request.validateAgainstConstraints(constraints)).rejects.toMatchSnapshot(); + }); + }); + }); + }); + + describe('getAllHeaders', () => { + const event = getEvent(); + const request = getRequestService(event); + + it('should return all headers from the event', () => { + expect(request.getAllHeaders()).toStrictEqual(event.headers); + }); + }); + + describe('getHeader', () => { + const event = getEvent(); + const request = getRequestService(event); + + it('should return the specified header', () => { + expect(request.getHeader('Accept')).toEqual(event.headers.Accept); + }); + + it("should return '' by default if header is missing", () => { + expect(request.getHeader('Authorization')).toEqual(''); + }); + + it('should return `whenMissing` if header is missing', () => { + expect(request.getHeader('Authorization', 'none')).toEqual('none'); + }); + + it('should not be case-sensitive', () => { + expect(request.getHeader('accept')).toEqual(event.headers.Accept); + }); + }); +}); diff --git a/tests/unit/services/__snapshots__/RequestService.spec.ts.snap b/tests/unit/services/__snapshots__/RequestService.spec.ts.snap new file mode 100644 index 00000000..5eaa46b7 --- /dev/null +++ b/tests/unit/services/__snapshots__/RequestService.spec.ts.snap @@ -0,0 +1,106 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`unit.services.RequestService HTTP DELETE validateAgainstConstraints should return a response containing validation errors if the data provided is incorrect 1`] = ` +ResponseModel { + "body": Object { + "data": Object {}, + "message": "required fields are missing", + "validation_errors": Object { + "giftaid": Array [ + "Giftaid is not a number", + ], + }, + }, + "code": 400, +} +`; + +exports[`unit.services.RequestService HTTP GET validateAgainstConstraints should return a response containing validation errors if the data provided is incorrect 1`] = ` +ResponseModel { + "body": Object { + "data": Object {}, + "message": "required fields are missing", + "validation_errors": Object { + "giftaid": Array [ + "Giftaid is not a number", + ], + }, + }, + "code": 400, +} +`; + +exports[`unit.services.RequestService HTTP HEAD validateAgainstConstraints should return a response containing validation errors if the data provided is incorrect 1`] = ` +ResponseModel { + "body": Object { + "data": Object {}, + "message": "required fields are missing", + "validation_errors": Object { + "giftaid": Array [ + "Giftaid is not a number", + ], + }, + }, + "code": 400, +} +`; + +exports[`unit.services.RequestService HTTP OPTIONS validateAgainstConstraints should return a response containing validation errors if the data provided is incorrect 1`] = ` +ResponseModel { + "body": Object { + "data": Object {}, + "message": "required fields are missing", + "validation_errors": Object { + "giftaid": Array [ + "Giftaid is not a number", + ], + }, + }, + "code": 400, +} +`; + +exports[`unit.services.RequestService HTTP PATCH validateAgainstConstraints should return a response containing validation errors if the data provided is incorrect 1`] = ` +ResponseModel { + "body": Object { + "data": Object {}, + "message": "required fields are missing", + "validation_errors": Object { + "giftaid": Array [ + "Giftaid is not a number", + ], + }, + }, + "code": 400, +} +`; + +exports[`unit.services.RequestService HTTP POST validateAgainstConstraints should return a response containing validation errors if the data provided is incorrect 1`] = ` +ResponseModel { + "body": Object { + "data": Object {}, + "message": "required fields are missing", + "validation_errors": Object { + "giftaid": Array [ + "Giftaid is not a number", + ], + }, + }, + "code": 400, +} +`; + +exports[`unit.services.RequestService HTTP PUT validateAgainstConstraints should return a response containing validation errors if the data provided is incorrect 1`] = ` +ResponseModel { + "body": Object { + "data": Object {}, + "message": "required fields are missing", + "validation_errors": Object { + "giftaid": Array [ + "Giftaid is not a number", + ], + }, + }, + "code": 400, +} +`; diff --git a/yarn.lock b/yarn.lock index 068141bb..d5372b97 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1870,6 +1870,18 @@ resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.1.tgz#20f18294f797f2209b5f65c8e3b5c8e8261d127c" integrity sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw== +"@types/useragent@^2.3.1": + version "2.3.1" + resolved "https://registry.yarnpkg.com/@types/useragent/-/useragent-2.3.1.tgz#c971243faa04f50df399da35d77538ab5fabae20" + integrity sha512-w70ziElAVDD8lEOQ2Id3YBDE0sn2DTVA1zLB59H4kFngYoOJAIlnMkndiZFrzzHE0jmFDZ9AEWNvmeTm6Rvj9A== + +"@types/xml2js@^0.4.11": + version "0.4.11" + resolved "https://registry.yarnpkg.com/@types/xml2js/-/xml2js-0.4.11.tgz#bf46a84ecc12c41159a7bd9cf51ae84129af0e79" + integrity sha512-JdigeAKmCyoJUiQljjr7tQG3if9NkqGUgwEUqBvV0N7LM4HyQk7UXCnusRa1lnvXAEYJ8mw8GtZWioagNztOwA== + dependencies: + "@types/node" "*" + "@types/yargs-parser@*": version "21.0.0" resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-21.0.0.tgz#0c60e537fa790f5f9472ed2776c2b71ec117351b" From 1222512e64607c2bca0d59bce5dd2a54cacc860d Mon Sep 17 00:00:00 2001 From: Seb Aebischer Date: Sat, 20 Aug 2022 14:12:21 +0100 Subject: [PATCH 18/39] Migrate StatusModel --- src/index.ts | 4 ++ src/models/StatusModel.ts | 61 +++++++++++++++++++++++++++ tests/unit/models/StatusModel.spec.ts | 39 +++++++++++++++++ 3 files changed, 104 insertions(+) create mode 100644 src/models/StatusModel.ts create mode 100644 tests/unit/models/StatusModel.spec.ts diff --git a/src/index.ts b/src/index.ts index 31d797ac..875966c9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -32,6 +32,10 @@ export { default as LambdaWrapper } from './core/lambda-wrapper'; export { default as ResponseModel, } from './models/ResponseModel'; +export { + default as StatusModel, + STATUS_TYPES, +} from './models/StatusModel'; export { default as LoggerService, diff --git a/src/models/StatusModel.ts b/src/models/StatusModel.ts new file mode 100644 index 00000000..942bc19d --- /dev/null +++ b/src/models/StatusModel.ts @@ -0,0 +1,61 @@ +export const STATUS_TYPES = { + OK: 'OK', + ACCEPTABLE_FAILURE: 'ACCEPTABLE_FAILURE', + APPLICATION_FAILURE: 'APPLICATION_FAILURE', +}; + +/** + * Model for our status check endpoints. + */ +export default class StatusModel { + /** + * Service name. + */ + service: string; + + /** + * One of the `STATUS_TYPES` values. + */ + status: string; + + constructor(service: string, status: string) { + this.service = service; + this.status = status; + } + + /** + * Get the service name. + */ + getService(): string { + return this.service; + } + + /** + * Set the service name. + * + * @param service + */ + setService(service: string) { + this.service = service; + } + + /** + * Set the status. + * + * @param status + */ + setStatus(status: string) { + if (!(status in STATUS_TYPES)) { + throw new TypeError(`${StatusModel.name} - ${status} is not a valid status type`); + } + + this.status = status; + } + + /** + * Get the status. + */ + getStatus(): string { + return this.status; + } +} diff --git a/tests/unit/models/StatusModel.spec.ts b/tests/unit/models/StatusModel.spec.ts new file mode 100644 index 00000000..ad141b56 --- /dev/null +++ b/tests/unit/models/StatusModel.spec.ts @@ -0,0 +1,39 @@ +import StatusModel, { STATUS_TYPES } from '@/src/models/StatusModel'; + +describe('unit.models.StatusModel', () => { + describe('getService', () => { + it('should return the service name', () => { + const statusModel = new StatusModel('test', STATUS_TYPES.OK); + expect(statusModel.getService()).toEqual('test'); + }); + }); + + describe('setService', () => { + it('should set the service name', () => { + const statusModel = new StatusModel('test', STATUS_TYPES.OK); + statusModel.setService('other'); + expect(statusModel.getService()).toEqual('other'); + }); + }); + + describe('getStatus', () => { + it('should return the status', () => { + const statusModel = new StatusModel('test', STATUS_TYPES.OK); + expect(statusModel.getStatus()).toEqual(STATUS_TYPES.OK); + }); + }); + + describe('setStatus', () => { + it('should set the status', () => { + const statusModel = new StatusModel('test', STATUS_TYPES.OK); + statusModel.setStatus(STATUS_TYPES.ACCEPTABLE_FAILURE); + expect(statusModel.getStatus()).toEqual(STATUS_TYPES.ACCEPTABLE_FAILURE); + }); + + it('should throw an error when trying to set an invalid status', () => { + const statusModel = new StatusModel('test', STATUS_TYPES.OK); + expect(() => statusModel.setStatus('invalid')) + .toThrow('StatusModel - invalid is not a valid status type'); + }); + }); +}); From e7c9a47fffe513d066682f6b6c91e641543aa323 Mon Sep 17 00:00:00 2001 From: Seb Aebischer Date: Sat, 20 Aug 2022 23:18:44 +0100 Subject: [PATCH 19/39] Migrate SQSService --- docs/services/SQSService.md | 10 +- package.json | 2 + src/index.ts | 5 + src/models/SQSMessageModel.ts | 79 ++++ src/services/SQSService.ts | 396 +++++++++++++++++- tests/unit/services/SQSService.spec.ts | 278 +++++++++++- .../__snapshots__/SQSService.spec.ts.snap | 7 + yarn.lock | 10 + 8 files changed, 760 insertions(+), 27 deletions(-) create mode 100644 src/models/SQSMessageModel.ts create mode 100644 tests/unit/services/__snapshots__/SQSService.spec.ts.snap diff --git a/docs/services/SQSService.md b/docs/services/SQSService.md index f82c0ae7..f836af9c 100644 --- a/docs/services/SQSService.md +++ b/docs/services/SQSService.md @@ -19,13 +19,13 @@ const lambdaWrapper = lw.configure({ This config is optional – not every application uses SQS! -You can then send messages to a queue within your Lambda handler using the `send` method. +You can then send messages to a queue within your Lambda handler using the `publish` method. ```ts export default lambdaWrapper.wrap(async (di) => { const sqs = di.get(SQSService); const message = { data: 'Hello SQS!' }; - await sqs.send('submissions', message); + await sqs.publish('submissions', message); }); ``` @@ -39,7 +39,7 @@ Offline SQS behaviour can be configured by setting the `LAMBDA_WRAPPER_OFFLINE_S - `local`: send messages to an offline SQS endpoint, such as Localstack - `aws`: no special handling of SQS offline; messages will be sent to AWS -Details of each mode are documented in the sections below. When you send a message using `SQSService.prototype.send`, it will check which mode to use and dispatch the message appropriately. These modes take effect only when running offline (as defined by `DependencyInjection.prototype.isOffline`). In a deployed environment, SQS messages will always be sent to AWS SQS. +Details of each mode are documented in the sections below. When you send a message using `SQSService.prototype.publish`, it will check which mode to use and dispatch the message appropriately. These modes take effect only when running offline (as defined by `DependencyInjection.prototype.isOffline`). In a deployed environment, SQS messages will always be sent to AWS SQS. ### Direct Lambda mode @@ -70,7 +70,7 @@ To take advantage of SQS emulation, you will need to do the following in your pr }); ``` - Now when a message is sent using `sqs.send('submissions', message)`, the `SubmissionConsumer` function will be directly invoked to consume the message. + Now when a message is sent using `sqs.publish('submissions', message)`, the `SubmissionConsumer` function will be directly invoked to consume the message. - Set `process.env.SERVICE_LAMBDA_URL`. @@ -86,7 +86,7 @@ To take advantage of SQS emulation, you will need to do the following in your pr 1. You will be running the SQS-triggered lambdas in the same Serverless Offline context as your triggering lambda. Expect logs from both lambdas in the Serverless Offline output. -2. If you await `sqs.send` you will effectively wait until all SQS-triggered lambdas (and possibly their own SQS-triggered lambdas) have all completed. This is necessary to avoid any pending execution (i.e. the lambda terminating before its async processes are completed). +2. If you await `sqs.publish` you will effectively wait until all SQS-triggered lambdas (and possibly their own SQS-triggered lambdas) have all completed. This is necessary to avoid any pending execution (i.e. the lambda terminating before its async processes are completed). 3. If the triggered lambda incurs an exception, this will be propagated upstream, effectively killing the execution of the calling lambda. diff --git a/package.json b/package.json index 7fdd4936..2a84cf35 100644 --- a/package.json +++ b/package.json @@ -23,10 +23,12 @@ "devDependencies": { "@comicrelief/eslint-config": "^2.0.3", "@istanbuljs/nyc-config-typescript": "^1.0.2", + "@types/async": "^3.2.15", "@types/aws-lambda": "^8.10.102", "@types/jest": "^28.1.6", "@types/node": "14", "@types/useragent": "^2.3.1", + "@types/uuid": "^8.3.4", "@types/xml2js": "^0.4.11", "@typescript-eslint/eslint-plugin": "^5.33.0", "@typescript-eslint/parser": "^5.33.0", diff --git a/src/index.ts b/src/index.ts index 875966c9..8518f7b0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -32,6 +32,9 @@ export { default as LambdaWrapper } from './core/lambda-wrapper'; export { default as ResponseModel, } from './models/ResponseModel'; +export { + default as SQSMessageModel, +} from './models/SQSMessageModel'; export { default as StatusModel, STATUS_TYPES, @@ -47,6 +50,8 @@ export { } from './services/RequestService'; export { default as SQSService, + SQS_OFFLINE_MODES, + SQS_PUBLISH_FAILURE_MODES, SQSServiceConfig, WithSQSServiceConfig, } from './services/SQSService'; diff --git a/src/models/SQSMessageModel.ts b/src/models/SQSMessageModel.ts new file mode 100644 index 00000000..58d00afe --- /dev/null +++ b/src/models/SQSMessageModel.ts @@ -0,0 +1,79 @@ +import { SQS } from 'aws-sdk'; + +/** + * Message model for SQS. + */ +export default class Message { + messageId: string; + + receiptHandle: string; + + body: string; + + forDeletion = false; + + metadata: Record = {}; + + constructor(message: SQS.Message) { + // todo: validate rather than assert the type + this.messageId = message.MessageId!; + this.receiptHandle = message.ReceiptHandle!; + this.body = JSON.parse(message.Body!); + } + + /** + * Get message ID. + */ + getMessageId() { + return this.messageId; + } + + /** + * Get message receipt handle. + */ + getReceiptHandle() { + return this.receiptHandle; + } + + /** + * Get message body. + */ + getBody() { + return this.body; + } + + /** + * Set for deletion status. + * + * @param forDeletion + */ + setForDeletion(forDeletion: boolean) { + this.forDeletion = forDeletion; + } + + /** + * Whether message is for deletion. + */ + isForDeletion() { + return this.forDeletion; + } + + /** + * Get all of the message metadata. + */ + getMetaData() { + return this.metadata; + } + + /** + * Set message metadata value + * + * @param key + * @param value + */ + setMetaData(key: string, value: any) { + this.metadata[key] = value; + + return this; + } +} diff --git a/src/services/SQSService.ts b/src/services/SQSService.ts index 9bc17927..ce0c3628 100644 --- a/src/services/SQSService.ts +++ b/src/services/SQSService.ts @@ -1,7 +1,14 @@ -import { SQS } from 'aws-sdk'; +import alai from 'alai'; +import { each } from 'async'; +import AWS from 'aws-sdk'; +import { v4 as uuid } from 'uuid'; import DependencyAwareClass from '../core/dependency-base'; import DependencyInjection from '../core/dependency-injection'; +import SQSMessageModel from '../models/SQSMessageModel'; +import StatusModel, { STATUS_TYPES } from '../models/StatusModel'; +import LoggerService from './LoggerService'; +import TimerService from './TimerService'; export interface SQSServiceConfig { /** @@ -41,6 +48,48 @@ export interface WithSQSServiceConfig { sqs?: SQSServiceConfig; } +/** + * Allowed values for `process.env.LAMBDA_WRAPPER_OFFLINE_SQS_MODE`. + */ +export const SQS_OFFLINE_MODES = { + /** + * When running offline, messages will trigger the consumer function directly + * via a Lambda endpoint, set using `process.env.SERVICE_LAMBDA_URL`. This is + * the default. + */ + DIRECT: 'direct', + + /** + * When running offline, send messages to an offline SQS service defined by + * `process.env.LAMBDA_WRAPPER_OFFLINE_SQS_HOST`. + */ + LOCAL: 'local', + + /** + * When running offline, send messages to AWS as normal. + */ + AWS: 'aws', +}; + +/** + * Defines the preferred behaviour for `SQSService.prototype.send` in case the + * AWS SQS call fails. + */ +export const SQS_PUBLISH_FAILURE_MODES = { + /** + * Catch the exception and logs it. + * + * This is the default behaviour for Lambda Wrapper v1.8.0 and below and for + * Lambda Wrapper v1.8.2 and above. + */ + CATCH: 'catch', + + /** + * Throw the exception so that the caller can handle it directly. + */ + THROW: 'throw', +} as const; + /** * Helper service for working with SQS. * @@ -61,42 +110,355 @@ export interface WithSQSServiceConfig { * ``` * * You can then send messages to a queue within your Lambda handler using the - * `send` method. + * `publish` method. * * ```ts * export default lambdaWrapper.wrap(async (di) => { * const sqs = di.get(SQSService); * const message = { data: 'Hello SQS!' }; - * await sqs.send('submissions', message); + * await sqs.publish('submissions', message); * }); * ``` */ export default class SQSService extends DependencyAwareClass { - private readonly sqs = new SQS({ - region: process.env.REGION, - httpOptions: { - connectTimeout: 8 * 1000, // longest publish on NOTV took 5 seconds - timeout: 8 * 1000, - }, - maxRetries: 3, // default is 3, we can change that - }); - readonly queues: Record; readonly queueConsumers: Record; + readonly queueUrls: Record; + + private $sqs?: AWS.SQS; + + private $lambda?: AWS.Lambda; + constructor(di: DependencyInjection) { super(di); const config = (this.di.config as WithSQSServiceConfig).sqs; this.queues = config?.queues || {}; this.queueConsumers = config?.queueConsumers || {}; + + const { + LAMBDA_WRAPPER_OFFLINE_SQS_HOST: offlineHost = 'localhost', + LAMBDA_WRAPPER_OFFLINE_SQS_PORT: offlinePort = '4576', + LAMBDA_WRAPPER_OFFLINE_SQS_MODE: offlineMode = SQS_OFFLINE_MODES.DIRECT, + AWS_ACCOUNT_ID, + REGION, + } = process.env; + + const accountId = (di.context.invokedFunctionArn && alai.parse(di.context)) + || AWS_ACCOUNT_ID; + + if (di.isOffline && !Object.values(SQS_OFFLINE_MODES).includes(offlineMode)) { + throw new Error(`Invalid LAMBDA_WRAPPER_OFFLINE_SQS_MODE: ${offlineMode}\n` + + `Please use one of: ${Object.values(SQS_OFFLINE_MODES).join(', ')}`); + } + + const useLocalQueues = di.isOffline && offlineMode === SQS_OFFLINE_MODES.LOCAL; + this.queueUrls = Object.fromEntries( + Object.entries(this.queues).map(( + ([key, queueName]) => [key, useLocalQueues + ? `http://${offlineHost}:${offlinePort}/queue/${queueName}` + : `https://sqs.${REGION}.amazonaws.com/${accountId}/${queueName}`] + )), + ); + } + + /** + * Returns an SQS client instance + */ + get sqs() { + if (!this.$sqs) { + this.$sqs = new AWS.SQS({ + region: process.env.REGION, + httpOptions: { + // longest publish on NOTV took 5 seconds + connectTimeout: 8 * 1000, + timeout: 8 * 1000, + }, + maxRetries: 3, // default is 3, we can change that + }); + } + + return this.$sqs; + } + + /** + * Returns a Lambda client instance + */ + get lambda() { + if (!this.$lambda) { + const endpoint = process.env.SERVICE_LAMBDA_URL; + + if (!endpoint) { + throw new Error('process.env.SERVICE_LAMBDA_URL must be defined.'); + } + + // move to subprocess + this.$lambda = new AWS.Lambda({ + region: process.env.AWS_REGION, + endpoint, + }); + } + + return this.$lambda; + } + + /** + * Returns the mode to use for offline SQS. + * + * This is configured by `process.env.LAMBDA_WRAPPER_OFFLINE_SQS_MODE`. The + * default is `SQS_OFFLINE_MODES.LAMBDA`. + */ + static get offlineMode() { + return process.env.LAMBDA_WRAPPER_OFFLINE_SQS_MODE || SQS_OFFLINE_MODES.DIRECT; + } + + /** + * Batch delete messages. + * + * @param queue + * @param messageModels + */ + batchDelete(queue: string, messageModels: SQSMessageModel[]): Promise { + const queueUrl = this.queueUrls[queue]; + const logger = this.di.get(LoggerService); + const timer = this.di.get(TimerService); + const timerId = `sqs-batch-delete-${uuid()} - Queue: '${queueUrl}'`; + + return new Promise((resolve) => { + const messagesForDeletion: { Id: string; ReceiptHandle: string }[] = []; + + timer.start(timerId); + // assuming openFiles is an array of file names + each( + messageModels, + (messageModel, callback) => { + if (messageModel instanceof SQSMessageModel && messageModel.isForDeletion() === true) { + messagesForDeletion.push({ + Id: messageModel.getMessageId(), + ReceiptHandle: messageModel.getReceiptHandle(), + }); + } + callback(); + }, + (loopError) => { + if (loopError) { + logger.error(loopError); + resolve(); + } + + this.sqs.deleteMessageBatch( + { + Entries: messagesForDeletion, + QueueUrl: queueUrl, + }, + (error) => { + timer.stop(timerId); + + if (error) { + logger.error(error); + } + + resolve(); + }, + ); + }, + ); + }); + } + + /** + * Check SQS status. + */ + checkStatus() { + const logger = this.di.get(LoggerService); + const timer = this.di.get(TimerService); + const timerId = `sqs-list-queues-${uuid()}`; + + return new Promise((resolve) => { + timer.start(timerId); + + this.sqs.listQueues({}, (error, data) => { + timer.stop(timerId); + + const statusModel = new StatusModel('SQS', STATUS_TYPES.OK); + + if (error) { + logger.error(error); + statusModel.setStatus(STATUS_TYPES.APPLICATION_FAILURE); + } + + if (typeof data.QueueUrls === 'undefined' || data.QueueUrls.length === 0) { + statusModel.setStatus(STATUS_TYPES.APPLICATION_FAILURE); + } + + resolve(statusModel); + }); + }); } - async send(queue: string, message: any): Promise { - await this.sqs.sendMessage({ - QueueUrl: queue, - MessageBody: message, - }).promise(); + /** + * Get the approximate number of messages in a queue. + * + * @param queue + */ + getMessageCount(queue: string): Promise { + const queueUrl = this.queueUrls[queue]; + const logger = this.di.get(LoggerService); + const timer = this.di.get(TimerService); + const timerId = `sqs-get-queue-attributes-${uuid()} - Queue: '${queueUrl}'`; + + return new Promise((resolve) => { + timer.start(timerId); + + this.sqs.getQueueAttributes( + { + AttributeNames: ['ApproximateNumberOfMessages'], + QueueUrl: queueUrl, + }, + (error, data) => { + timer.stop(timerId); + + if (error) { + logger.error(error); + resolve(0); + } + + const messageCount = data.Attributes?.ApproximateNumberOfMessages || '0'; + resolve(Number.parseInt(messageCount, 10)); + }, + ); + }); + } + + /** + * Publish to message queue. + * + * When running within serverless-offline, messages can be published to a + * local Lambda or SQS service instead of to AWS, depending on the offline + * mode specified by `process.env.LAMBDA_WRAPPER_OFFLINE_SQS_MODE`. + * + * @param queue string + * @param messageObject object + * @param messageGroupId string + * @param failureMode Choose how failures are handled: + * - `catch`: errors will be caught and logged. This is the default. + * - `throw`: errors will be thrown, causing promise to reject. + */ + async publish(queue: string, messageObject: object, messageGroupId = null, failureMode: 'catch' | 'throw' = SQS_PUBLISH_FAILURE_MODES.CATCH) { + if (!Object.values(SQS_PUBLISH_FAILURE_MODES).includes(failureMode)) { + throw new Error(`Invalid value for 'failureMode': ${failureMode}`); + } + + const queueUrl = this.queueUrls[queue]; + const timer = this.di.get(TimerService); + const timerId = `sqs-send-message-${uuid()} - Queue: '${queueUrl}'`; + + timer.start(timerId); + + const messageParameters: AWS.SQS.SendMessageRequest = { + MessageBody: JSON.stringify(messageObject), + QueueUrl: queueUrl, + }; + + if (queueUrl.includes('.fifo')) { + messageParameters.MessageDeduplicationId = uuid(); + messageParameters.MessageGroupId = messageGroupId !== null ? messageGroupId : uuid(); + } + + try { + if (this.di.isOffline && SQSService.offlineMode === SQS_OFFLINE_MODES.DIRECT) { + await this.publishOffline(queue, messageParameters); + } else { + await this.sqs.sendMessage(messageParameters).promise(); + } + } catch (error) { + if (failureMode === SQS_PUBLISH_FAILURE_MODES.CATCH) { + this.di.get(LoggerService).error(error); + return null; + } + throw error; + } + + return queue; + } + + /** + * Sends a message to a queue consumer running in serverless-offline. + * + * This method invokes the consumer function directly instead of sending the + * message to SQS, which requires a real or emulated SQS service not provided + * by serverless-offline. This works very well for local testing. + * + * @param queue + * @param messageParameters + */ + async publishOffline(queue: string, messageParameters: AWS.SQS.SendMessageRequest) { + if (!this.di.isOffline) { + throw new Error('Can only publishOffline while running serverless offline.'); + } + + const FunctionName = this.queueConsumers[queue]; + + if (!FunctionName) { + throw new Error( + `Queue consumer for queue ${queue} was not found. Please add it to ` + + 'the sqs.queueConsumers key in your Lambda Wrapper config.', + ); + } + + const InvocationType = 'RequestResponse'; + + const Payload = JSON.stringify({ + Records: [ + { + body: messageParameters.MessageBody, + }, + ], + }); + + const parameters = { FunctionName, InvocationType, Payload }; + + await this.lambda.invoke(parameters).promise(); + } + + /** + * Receive from message queue + * + * @param queue string + * @param timeout number + */ + receive(queue: string, timeout = 15): Promise { + const queueUrl = this.queueUrls[queue]; + const logger = this.di.get(LoggerService); + const timer = this.di.get(TimerService); + const timerId = `sqs-receive-message-${uuid()} - Queue: '${queueUrl}'`; + + return new Promise((resolve, reject) => { + timer.start(timerId); + + this.sqs.receiveMessage( + { + QueueUrl: queueUrl, + VisibilityTimeout: timeout, + MaxNumberOfMessages: 10, + }, + (error, data) => { + timer.stop(timerId); + + if (error) { + logger.error(error); + return reject(error); + } + + if (typeof data.Messages === 'undefined') { + return resolve([]); + } + + return resolve(data.Messages.map((message) => new SQSMessageModel(message))); + }, + ); + }); } } diff --git a/tests/unit/services/SQSService.spec.ts b/tests/unit/services/SQSService.spec.ts index 40bfa7b4..7e6838cd 100644 --- a/tests/unit/services/SQSService.spec.ts +++ b/tests/unit/services/SQSService.spec.ts @@ -2,30 +2,298 @@ import { Context, DependencyInjection, LambdaWrapperConfig, + LoggerService, SQSService, + SQS_PUBLISH_FAILURE_MODES, + TimerService, WithSQSServiceConfig, } from '@/src'; +const TEST_QUEUE = 'TEST_QUEUE'; + const config: LambdaWrapperConfig & WithSQSServiceConfig = { dependencies: { SQSService, + LoggerService, + TimerService, }, sqs: { queues: { - submissions: 'service-name-stage-submissions.fifo', + [TEST_QUEUE]: 'QueueName', }, queueConsumers: { - submissions: 'SubmissionsConsumer', + [TEST_QUEUE]: 'ConsumerFunctionName', }, }, }; -const di = new DependencyInjection(config, {}, {} as Context); -const sqs = di.get(SQSService); +const createAsyncMock = (returnValue: any) => { + const mockedValue = returnValue instanceof Error + ? Promise.reject(returnValue) + : Promise.resolve(returnValue); + + return jest.fn().mockReturnValue({ promise: () => mockedValue }); +}; + +/** + * Generates a SQSService + * + * @param param0 + * @param isOffline + */ +const getService = ( + { + sendMessage = null, + invoke = null, + }: any = {}, + isOffline = false, +): SQSService & { sqs: { sendMessage: jest.Mock }; lambda: { invoke: jest.Mock } } => { + const di = new DependencyInjection(config, {}, { + invokedFunctionArn: isOffline ? 'offline' : 'arn:aws:lambda:eu-west-1:0123456789:test', + } as Context); + + const logger = di.get(LoggerService); + jest.spyOn(logger, 'error').mockImplementation(); + + const service = di.get(SQSService); + const sqs = { + sendMessage: createAsyncMock(sendMessage), + } as unknown as AWS.SQS; + const lambda = { + invoke: createAsyncMock(invoke), + } as unknown as AWS.Lambda; + jest.spyOn(service, 'sqs', 'get').mockReturnValue(sqs); + jest.spyOn(service, 'lambda', 'get').mockReturnValue(lambda); + + return service as any; +}; + +describe('unit.services.SQSService', () => { + let envAccountId: string | undefined; + let envOfflineSqsMode: string | undefined; + let envOfflineSqsHost: string | undefined; + let envOfflineSqsPort: string | undefined; + let envRegion: string | undefined; + + beforeAll(() => { + envAccountId = process.env.AWS_ACCOUNT_ID; + envOfflineSqsMode = process.env.LAMBDA_WRAPPER_OFFLINE_SQS_MODE; + envOfflineSqsHost = process.env.LAMBDA_WRAPPER_OFFLINE_SQS_HOST; + envOfflineSqsPort = process.env.LAMBDA_WRAPPER_OFFLINE_SQS_PORT; + envRegion = process.env.REGION; + }); + + afterAll(() => { + process.env.AWS_ACCOUNT_ID = envAccountId; + process.env.LAMBDA_WRAPPER_OFFLINE_SQS_MODE = envOfflineSqsMode; + process.env.LAMBDA_WRAPPER_OFFLINE_SQS_HOST = envOfflineSqsHost; + process.env.LAMBDA_WRAPPER_OFFLINE_SQS_PORT = envOfflineSqsPort; + process.env.REGION = envRegion; + }); + + afterEach(() => { + jest.resetAllMocks(); + }); -describe('unit.service.SQSService', () => { it('should load config from the `sqs` key', () => { + const di = new DependencyInjection(config, {}, {} as Context); + const sqs = di.get(SQSService); + expect(sqs.queues).toEqual(config.sqs?.queues); expect(sqs.queueConsumers).toEqual(config.sqs?.queueConsumers); }); + + describe('publish', () => { + describe('when container.isOffline === false', () => { + ([ + ['sends to SQS', undefined], + ['sends to SQS, even in "direct" offline mode', 'direct'], + ['sends to SQS, even in "local" offline mode', 'local'], + ['sends to SQS, even in "aws" offline mode', 'aws'], + ['sends to SQS, even in "invalid" offline mode', 'invalid'], + ] as const).forEach(([description, offlineMode]) => { + it(description, async () => { + process.env.LAMBDA_WRAPPER_OFFLINE_SQS_MODE = offlineMode; + const service = getService({}, false); + + await service.publish(TEST_QUEUE, { test: 1 }); + + expect(service.sqs.sendMessage).toHaveBeenCalledTimes(1); + expect(service.lambda.invoke).toHaveBeenCalledTimes(0); + + const params = service.sqs.sendMessage.mock.calls[0][0]; + expect(params.QueueUrl).not.toContain('localhost'); + }); + }); + }); + + describe('when container.isOffline === true', () => { + it('sends a lambda request by default', async () => { + delete process.env.LAMBDA_WRAPPER_OFFLINE_SQS_MODE; + const service = getService({}, true); + + await service.publish(TEST_QUEUE, { test: 1 }); + + expect(service.sqs.sendMessage).toHaveBeenCalledTimes(0); + expect(service.lambda.invoke).toHaveBeenCalledTimes(1); + }); + + it('sends a lambda request in "direct" mode', async () => { + process.env.LAMBDA_WRAPPER_OFFLINE_SQS_MODE = 'direct'; + const service = getService({}, true); + + await service.publish(TEST_QUEUE, { test: 1 }); + + expect(service.sqs.sendMessage).toHaveBeenCalledTimes(0); + expect(service.lambda.invoke).toHaveBeenCalledTimes(1); + }); + + it('sends a local SQS request in "local" mode', async () => { + delete process.env.LAMBDA_WRAPPER_OFFLINE_SQS_HOST; + delete process.env.LAMBDA_WRAPPER_OFFLINE_SQS_PORT; + process.env.LAMBDA_WRAPPER_OFFLINE_SQS_MODE = 'local'; + const service = getService({}, true); + + await service.publish(TEST_QUEUE, { test: 1 }); + + expect(service.sqs.sendMessage).toHaveBeenCalledTimes(1); + expect(service.lambda.invoke).toHaveBeenCalledTimes(0); + + const params = service.sqs.sendMessage.mock.calls[0][0]; + expect(params.QueueUrl).toContain('localhost'); + expect(params.QueueUrl).toContain('4576'); + }); + + it('sends a normal SQS request in "aws" mode', async () => { + process.env.LAMBDA_WRAPPER_OFFLINE_SQS_MODE = 'aws'; + const service = getService({}, true); + + await service.publish(TEST_QUEUE, { test: 1 }); + + expect(service.sqs.sendMessage).toHaveBeenCalledTimes(1); + expect(service.lambda.invoke).toHaveBeenCalledTimes(0); + + const params = service.sqs.sendMessage.mock.calls[0][0]; + expect(params.QueueUrl).not.toContain('localhost'); + expect(params.QueueUrl).not.toContain('4576'); + }); + + it('throws an error for any other mode', async () => { + process.env.LAMBDA_WRAPPER_OFFLINE_SQS_MODE = 'invalid'; + expect(() => getService({}, true)).toThrow(); + }); + }); + + describe('queue URLs', () => { + describe('when container.isOffline === false', () => { + it('should use a correctly formed AWS queue URL', async () => { + delete process.env.LAMBDA_WRAPPER_OFFLINE_SQS_MODE; + process.env.REGION = 'eu-west-1'; + const service = getService({}, false); + + await service.publish(TEST_QUEUE, { test: 1 }); + + const params = service.sqs.sendMessage.mock.calls[0][0]; + expect(params.QueueUrl).toEqual('https://sqs.eu-west-1.amazonaws.com/0123456789/QueueName'); + }); + }); + + describe('when container.isOffline === true', () => { + it('should use a LocalStack URL in "local" mode', async () => { + delete process.env.LAMBDA_WRAPPER_OFFLINE_SQS_HOST; + delete process.env.LAMBDA_WRAPPER_OFFLINE_SQS_PORT; + process.env.LAMBDA_WRAPPER_OFFLINE_SQS_MODE = 'local'; + const service = getService({}, true); + + await service.publish(TEST_QUEUE, { test: 1 }); + + const params = service.sqs.sendMessage.mock.calls[0][0]; + expect(params.QueueUrl).toEqual('http://localhost:4576/queue/QueueName'); + }); + + it('should use a custom host in "local" mode', async () => { + delete process.env.LAMBDA_WRAPPER_OFFLINE_SQS_PORT; + process.env.LAMBDA_WRAPPER_OFFLINE_SQS_MODE = 'local'; + process.env.LAMBDA_WRAPPER_OFFLINE_SQS_HOST = 'custom-host'; + const service = getService({}, true); + + await service.publish(TEST_QUEUE, { test: 1 }); + + const params = service.sqs.sendMessage.mock.calls[0][0]; + expect(params.QueueUrl).toEqual('http://custom-host:4576/queue/QueueName'); + }); + + it('should use a custom port in "local" mode', async () => { + delete process.env.LAMBDA_WRAPPER_OFFLINE_SQS_HOST; + process.env.LAMBDA_WRAPPER_OFFLINE_SQS_MODE = 'local'; + process.env.LAMBDA_WRAPPER_OFFLINE_SQS_PORT = '4566'; + const service = getService({}, true); + + await service.publish(TEST_QUEUE, { test: 1 }); + + const params = service.sqs.sendMessage.mock.calls[0][0]; + expect(params.QueueUrl).toEqual('http://localhost:4566/queue/QueueName'); + }); + + it('should use a correctly formed AWS queue URL in "aws" mode', async () => { + // `AWS_ACCOUNT_ID` and `REGION` need to be set for this to work + process.env.LAMBDA_WRAPPER_OFFLINE_SQS_MODE = 'aws'; + process.env.AWS_ACCOUNT_ID = '0123456789'; + process.env.REGION = 'eu-west-1'; + const service = getService({}, true); + + await service.publish(TEST_QUEUE, { test: 1 }); + + const params = service.sqs.sendMessage.mock.calls[0][0]; + expect(params.QueueUrl).toEqual('https://sqs.eu-west-1.amazonaws.com/0123456789/QueueName'); + }); + }); + }); + + describe('failure modes', () => { + it(`catches the error if publish fails with failureMode === ${SQS_PUBLISH_FAILURE_MODES.CATCH}`, async () => { + const service = getService({ + sendMessage: new Error('SQS is down!'), + }, false); + + const promise = service.publish(TEST_QUEUE, { test: 1 }, null, SQS_PUBLISH_FAILURE_MODES.CATCH); + + await expect(promise).resolves.toEqual(null); + }); + + it('catches the error if publish fails with failureMode omitted', async () => { + const service = getService({ + sendMessage: new Error('SQS is down!'), + }, false); + + const promise = service.publish(TEST_QUEUE, { test: 1 }, null); + + await expect(promise).resolves.toEqual(null); + }); + + it(`throws an error if publish fails with failureMode === ${SQS_PUBLISH_FAILURE_MODES.THROW}`, async () => { + const service = getService({ + sendMessage: new Error('SQS is down!'), + }, false); + + const promise = service.publish(TEST_QUEUE, { test: 1 }, null, SQS_PUBLISH_FAILURE_MODES.THROW); + + await expect(promise).rejects.toThrowError('SQS is down!'); + }); + + [ + '', + null, + 'another-value', + ].forEach((invalidValue: any) => { + it(`throws an error with the invalid value: ${invalidValue}`, async () => { + const service = getService(); + + const promise = service.publish(TEST_QUEUE, { test: 1 }, null, invalidValue); + + await expect(promise).rejects.toThrowErrorMatchingSnapshot(); + }); + }); + }); + }); }); diff --git a/tests/unit/services/__snapshots__/SQSService.spec.ts.snap b/tests/unit/services/__snapshots__/SQSService.spec.ts.snap new file mode 100644 index 00000000..be64da51 --- /dev/null +++ b/tests/unit/services/__snapshots__/SQSService.spec.ts.snap @@ -0,0 +1,7 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`unit.services.SQSService publish failure modes throws an error with the invalid value: 1`] = `"Invalid value for 'failureMode': "`; + +exports[`unit.services.SQSService publish failure modes throws an error with the invalid value: another-value 1`] = `"Invalid value for 'failureMode': another-value"`; + +exports[`unit.services.SQSService publish failure modes throws an error with the invalid value: null 1`] = `"Invalid value for 'failureMode': null"`; diff --git a/yarn.lock b/yarn.lock index d5372b97..75682aad 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1748,6 +1748,11 @@ resolved "https://registry.yarnpkg.com/@tsconfig/node16/-/node16-1.0.3.tgz#472eaab5f15c1ffdd7f8628bd4c4f753995ec79e" integrity sha512-yOlFc+7UtL/89t2ZhjPvvB/DeAr3r+Dq58IgzsFkOAvVC6NMJXmCGjbptdXdR9qsX7pKcTL+s87FtYREi2dEEQ== +"@types/async@^3.2.15": + version "3.2.15" + resolved "https://registry.yarnpkg.com/@types/async/-/async-3.2.15.tgz#26d4768fdda0e466f18d6c9918ca28cc89a4e1fe" + integrity sha512-PAmPfzvFA31mRoqZyTVsgJMsvbynR429UTTxhmfsUCrWGh3/fxOrzqBtaTPJsn4UtzTv4Vb0+/O7CARWb69N4g== + "@types/aws-lambda@^8.10.102": version "8.10.102" resolved "https://registry.yarnpkg.com/@types/aws-lambda/-/aws-lambda-8.10.102.tgz#d2402224ec30cdddfb669005c25b6ee01fd6f5be" @@ -1875,6 +1880,11 @@ resolved "https://registry.yarnpkg.com/@types/useragent/-/useragent-2.3.1.tgz#c971243faa04f50df399da35d77538ab5fabae20" integrity sha512-w70ziElAVDD8lEOQ2Id3YBDE0sn2DTVA1zLB59H4kFngYoOJAIlnMkndiZFrzzHE0jmFDZ9AEWNvmeTm6Rvj9A== +"@types/uuid@^8.3.4": + version "8.3.4" + resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-8.3.4.tgz#bd86a43617df0594787d38b735f55c805becf1bc" + integrity sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw== + "@types/xml2js@^0.4.11": version "0.4.11" resolved "https://registry.yarnpkg.com/@types/xml2js/-/xml2js-0.4.11.tgz#bf46a84ecc12c41159a7bd9cf51ae84129af0e79" From 012eaf671b52e157814c698fa331d9631db076ea Mon Sep 17 00:00:00 2001 From: Seb Aebischer Date: Mon, 22 Aug 2022 11:47:51 +0100 Subject: [PATCH 20/39] Add type definitions for `alai` package --- tsconfig-build.json | 3 ++- tsconfig.json | 2 +- types/alai.d.ts | 3 +++ 3 files changed, 6 insertions(+), 2 deletions(-) create mode 100644 types/alai.d.ts diff --git a/tsconfig-build.json b/tsconfig-build.json index 8c4c9811..3b71185b 100644 --- a/tsconfig-build.json +++ b/tsconfig-build.json @@ -5,6 +5,7 @@ "types": ["node"], }, "include": [ - "src/**/*.ts" + "src/**/*.ts", + "types/*.d.ts", ], } \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 8ecd2c59..1d67ec3f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,6 +7,6 @@ "include": [ "src/**/*.ts", "tests/**/*.ts", - "*.d.ts", + "types/*.d.ts", ], } diff --git a/types/alai.d.ts b/types/alai.d.ts new file mode 100644 index 00000000..ab06dcbe --- /dev/null +++ b/types/alai.d.ts @@ -0,0 +1,3 @@ +declare module 'alai' { + export function parse(ctx: import('aws-lambda').Context): string; +} From dfa703c50ecec53f07df9d009c526bbc2214f77b Mon Sep 17 00:00:00 2001 From: Seb Aebischer Date: Mon, 22 Aug 2022 13:44:19 +0100 Subject: [PATCH 21/39] Write v2 migration guide --- README.md | 2 + docs/migration/v2.md | 146 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 148 insertions(+) create mode 100644 docs/migration/v2.md diff --git a/README.md b/README.md index f29dd98d..64ef002c 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ When writing Serverless applications, we have found ourselves replicating a lot of boilerplate code to do basic actions, such as reading request data or sending messages to SQS. The aim of this package is to provide a wrapper for our Lambda functions, to provide some level of dependency and configuration injection and to reduce time spent on project setup. +If you're coming from v1 and updating to v2, check out the [v2 migration guide](docs/migration/v2.md). + ## Getting started Install via npm or Yarn: diff --git a/docs/migration/v2.md b/docs/migration/v2.md new file mode 100644 index 00000000..73cd3ff6 --- /dev/null +++ b/docs/migration/v2.md @@ -0,0 +1,146 @@ +# Migrating from v1 to v2 + +This doc summarises the breaking changes introduced in v2 and what you need to do to update your projects to work with it. + +- [Configuration](#configuration) +- [Wrapping a function](#wrapping-a-function) +- [Dependency injection](#dependency-injection) + +## Configuration + +v1 required several consts with shouty names. In v2 these are replaced with a single config object with camel-case keys. You pass this to the new `configure` method to get a configured instance of `LambdaWrapper`. + +Instead of this: + +```js +// src/config/Configuration.js +import { DEFINITIONS as CORE_DEFINITIONS } from '@comicrelief/lambda-wrapper'; + +import { MyService } from '@/src/service/MyService'; + +export const DEFINITIONS = { + ...CORE_DEFINITIONS, + MY_SERVICE: 'MY_SERVICE', +}; + +export const DEPENDENCIES = { + [DEFINITIONS.MY_SERVICE]: MyService, +}; + +export const QUEUE_DEFINITIONS = { + MY_QUEUE: 'MY_QUEUE', +}; + +export const QUEUES = { + [QUEUE_DEFINITIONS.MY_QUEUE]: process.env.SQS_MY_QUEUE, +}; + +export default { + DEFINITIONS, + DEPENDENCIES, + QUEUE_DEFINITIONS, + QUEUES, +}; +``` + +do this: + +```ts +// src/config/LambdaWrapper.ts +import lw from '@comicrelief/lambda-wrapper'; + +import { MyService } from '@/src/service/MyService'; + +export const lambdaWrapper = lw.configure({ + dependencies: { + MyService, + }, + sqs: { + queues: { + myQueue: process.env.SQS_MY_QUEUE, + }, + }, +}); +``` + +## Wrapping a function + +Rather than passing in a config object everywhere you use Lambda Wrapper, you now simply use the configured instance. + +v2 also drops support for callback-style async. Use promises instead. + +Finally, there is no longer a `request` parameter provided to your wrapped function. You can get this from `di` if you need it. + +Instead of this: + +```js +import { LambdaWrapper } from '@comicrelief/lambda-wrapper'; + +import { CONFIGURATION, DEFINITIONS } from '@/src/config/Configuration'; + +export default LambdaWrapper(CONFIGURATION, (di, request, done) => { + // ... + done(null, response); +}); +``` + +do this: + +```ts +import lambdaWrapper from '@/src/config/lambda-wrapper'; + +export default lambdaWrapper.wrap(async (di) => { + const request = di.get(RequestService); + // ... +}); +``` + +If your project doesn't add any additional services to dependency injection, you can also now use `lambdaWrapper` straight out of the box: + +```ts +import lambdaWrapper from '@comicrelief/lambda-wrapper'; + +export default lambdaWrapper.wrap(async (di) => { + // ... +}); +``` + +## Dependency injection + +As you'll have seen in the above examples, dependencies are no longer identified by a `DEFINITIONS` string. `get` now takes the dependency class directly. + +Instead of this: + +```js +import { LambdaWrapper } from '@comicrelief/lambda-wrapper'; + +import { CONFIGURATION, DEFINITIONS } from '@/src/config/Configuration'; + +export default LambdaWrapper(CONFIGURATION, (di, request, done) => { + const logger = di.get(DEFINITIONS.LOGGER); + const myService = di.get(DEFINITIONS.MY_SERVICE); + // ... +}); +``` + +do this: + +```ts +import { LoggerService, RequestService } from '@comicrelief/lambda-wrapper'; + +import lambdaWrapper from '@/src/config/LambdaWrapper'; +import { MyService } from '@/src/service/MyService'; + +export default lambdaWrapper.wrap(async (di) => { + const logger = di.get(LoggerService); + const request = di.get(RequestService); + const myService = di.get(MyService); + // ... +}); +``` + +`get` will also always throw an error when used in a constructor to avoid surprises where other dependencies may be `undefined`. Instead of storing references to dependencies in class members, `get` them just before use. + +`definitions` has been removed. + +`getEvent`, `getContext` and `getConfiguration` have been deprecated and will be removed in a future major release. Use the `event`, `context` and `config` properties directly. From e5ffa8fc88ab28340c28c6a6f0af3cfffa0dd28e Mon Sep 17 00:00:00 2001 From: Seb Aebischer Date: Mon, 22 Aug 2022 15:04:21 +0100 Subject: [PATCH 22/39] Migrate LambdaTermination --- src/index.ts | 4 +++ src/utils/LambdaTermination.ts | 22 +++++++++++++ tests/unit/utils/LambdaTermination.spec.ts | 38 ++++++++++++++++++++++ 3 files changed, 64 insertions(+) create mode 100644 src/utils/LambdaTermination.ts create mode 100644 tests/unit/utils/LambdaTermination.spec.ts diff --git a/src/index.ts b/src/index.ts index 8518f7b0..d25d435e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -58,3 +58,7 @@ export { export { default as TimerService, } from './services/TimerService'; + +export { + default as LambdaTermination, +} from './utils/LambdaTermination'; diff --git a/src/utils/LambdaTermination.ts b/src/utils/LambdaTermination.ts new file mode 100644 index 00000000..cda88e36 --- /dev/null +++ b/src/utils/LambdaTermination.ts @@ -0,0 +1,22 @@ +/** + * An error that triggers a Lambda termination. + * + * Offers developer details (that are logged), a code for the Lambda and a + * front-facing consumer message. + */ +export default class LambdaTermination extends Error { + constructor( + readonly internal: object | string, + readonly code = 500, + readonly body: object | string | null = null, + readonly details = 'unknown error', + ) { + const stringified = typeof internal === 'string' + ? internal + : JSON.stringify(internal); + + super(stringified); + + this.body = body || 'unknown error'; + } +} diff --git a/tests/unit/utils/LambdaTermination.spec.ts b/tests/unit/utils/LambdaTermination.spec.ts new file mode 100644 index 00000000..607ba577 --- /dev/null +++ b/tests/unit/utils/LambdaTermination.spec.ts @@ -0,0 +1,38 @@ +import { LambdaTermination } from '@/src'; + +describe('unit.utils.LambdaTermination', () => { + describe('custom fields', () => { + const properties = { + internal: 'INTERNAL', + code: 401, + body: 'BODY', + }; + + const lt = new LambdaTermination(properties.internal, properties.code, properties.body); + + Object.entries(properties).forEach(([key, value]) => { + it(`should set and expose '${key}'`, () => { + expect(lt[key as keyof LambdaTermination]).toEqual(value); + }); + }); + }); + + it('should create an instance of error', () => { + const lt = new LambdaTermination('internal'); + expect(lt).toBeInstanceOf(Error); + }); + + describe('error message', () => { + it('should use `internal` param when a string', () => { + const lt = new LambdaTermination('abc'); + expect(lt.message).toEqual('abc'); + }); + + it('should use stringified `internal` param when an object', () => { + const details = { a: 1 }; + const stringified = JSON.stringify(details); + const lt = new LambdaTermination(details); + expect(lt.message).toEqual(stringified); + }); + }); +}); From 1e6329ee9c9e27489835e1065a188cc570690a12 Mon Sep 17 00:00:00 2001 From: Seb Aebischer Date: Mon, 22 Aug 2022 15:12:55 +0100 Subject: [PATCH 23/39] Migrate PromisifiedDelay --- src/index.ts | 5 ++-- src/utils/PromisifiedDelay.ts | 50 +++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 3 deletions(-) create mode 100644 src/utils/PromisifiedDelay.ts diff --git a/src/index.ts b/src/index.ts index d25d435e..3185dc94 100644 --- a/src/index.ts +++ b/src/index.ts @@ -59,6 +59,5 @@ export { default as TimerService, } from './services/TimerService'; -export { - default as LambdaTermination, -} from './utils/LambdaTermination'; +export { default as LambdaTermination } from './utils/LambdaTermination'; +export { default as PromisifiedDelay } from './utils/PromisifiedDelay'; diff --git a/src/utils/PromisifiedDelay.ts b/src/utils/PromisifiedDelay.ts new file mode 100644 index 00000000..d2c3cbee --- /dev/null +++ b/src/utils/PromisifiedDelay.ts @@ -0,0 +1,50 @@ +const STANDARD_LATENCY_DELAYS = { + 2000: 70, + 3500: 15, + 4000: 10, + 5000: 5, +}; + +const HIGH_LATENCY_DELAYS = { + 2000: 65, + 3500: 15, + 4000: 9, + 5000: 5, + 10_000: 5, + 20_000: 1, +}; + +/** + * PromisifiedDelay class + */ +export default class PromisifiedDelay { + readonly delays: number[] = []; + + /** + * PromisifiedDelay constructor + * + * @param highLatency + */ + constructor(highLatency = true) { + const delayArray = highLatency === true ? HIGH_LATENCY_DELAYS : STANDARD_LATENCY_DELAYS; + + Object.keys(delayArray).forEach((delayDuration) => { + const delayIterations = (delayArray as any)[delayDuration]; + + for (let i = 0; i < delayIterations; i += 1) { + this.delays.push(Number.parseInt(delayDuration, 10)); + } + }); + } + + /** + * Create a promisified delay + */ + get(): Promise { + return new Promise((resolve) => { + setTimeout(() => { + resolve(); + }, this.delays[Math.floor(Math.random() * this.delays.length)]); + }); + } +} From 07438416c4ee2461fdbfe7d6ffb961bf7e136da0 Mon Sep 17 00:00:00 2001 From: Seb Aebischer Date: Mon, 22 Aug 2022 17:09:03 +0100 Subject: [PATCH 24/39] Migrate BaseConfigService --- docs/services/BaseConfigService.md | 46 +++ src/index.ts | 3 + src/services/BaseConfigService.ts | 199 ++++++++++++ tests/unit/services/BaseConfigService.spec.ts | 290 ++++++++++++++++++ .../BaseConfigService.spec.ts.snap | 35 +++ 5 files changed, 573 insertions(+) create mode 100644 docs/services/BaseConfigService.md create mode 100644 src/services/BaseConfigService.ts create mode 100644 tests/unit/services/BaseConfigService.spec.ts create mode 100644 tests/unit/services/__snapshots__/BaseConfigService.spec.ts.snap diff --git a/docs/services/BaseConfigService.md b/docs/services/BaseConfigService.md new file mode 100644 index 00000000..debe8d36 --- /dev/null +++ b/docs/services/BaseConfigService.md @@ -0,0 +1,46 @@ +# BaseConfigService + +Instead of reimplementing the service status get and set logic across several services, Lambda Wrapper provides a Status service that handles these two operations for us. + +## Usage + +This class is to be extended by the implementing services so that `defaultConfig` and possibly `s3Config` can be overriden / extended. As such, it is not included as a dependency by default and must be explicitly added. + +Example implementation with validation: + +```ts +// src/services/ConfigService.ts +import { BaseConfigService } from '@comicrelief/lambda-wrapper'; + +import { ConfigModel, ConfigProps } from '@/src/models/Config'; + +export default class ConfigService extends BaseConfigService { + async put(config): Promise { + const validated = await ConfigModel.validate(config); + return super.put(validated); + } + + async get(): Promise { + const config = await super.get(); + return ConfigModel.validate(config); + } +} +``` + +Config is typed as `unknown` in the base class since you shouldn't trust what's in the bucket. Override the `get` and `put` methods to pass the results through some validation to ensure the config is valid and can safely be typed. + +Then add to your Lambda Wrapper dependencies: + +```ts +// src/config/lambda-wrapper.ts +import lambdaWrapper from '@comicrelief/lambda-wrapper'; + +import ConfigService from '@/src/services/ConfigService'; + +export default lambdaWrapper.configure({ + dependencies: { + ConfigService, + // ... + }, +}); +``` diff --git a/src/index.ts b/src/index.ts index 3185dc94..aacbc544 100644 --- a/src/index.ts +++ b/src/index.ts @@ -40,6 +40,9 @@ export { STATUS_TYPES, } from './models/StatusModel'; +export { + default as BaseConfigService, +} from './services/BaseConfigService'; export { default as LoggerService, } from './services/LoggerService'; diff --git a/src/services/BaseConfigService.ts b/src/services/BaseConfigService.ts new file mode 100644 index 00000000..e1e5f524 --- /dev/null +++ b/src/services/BaseConfigService.ts @@ -0,0 +1,199 @@ +import { S3 } from 'aws-sdk'; + +import DependencyAwareClass from '../core/dependency-base'; +import LambdaTermination from '../utils/LambdaTermination'; + +/** + * `error.code` for S3 404 errors. + */ +export const S3_NO_SUCH_KEY_ERROR_CODE = 'NoSuchKey'; + +/** + * Represents the service states. + */ +export const ServiceStates = { + OK: 'OK', + TEMPORARILY_PAUSED: 'TEMPORARILY_PAUSED', + INDEFINITELY_PAUSED: 'INDEFINITELY_PAUSED', +}; + +/** + * Maps service states to HTTP codes. + */ +export const ServiceStatesHttpCodes = { + [ServiceStates.OK]: 200, + [ServiceStates.TEMPORARILY_PAUSED]: 409, + [ServiceStates.INDEFINITELY_PAUSED]: 409, +}; + +/** + * This class is to be extended by the implementing services so that + * `defaultConfig` and possibly `s3Config` can be overriden / extended. + * + * Config is typed as `unknown` since you shouldn't trust what's in the bucket. + * Override the `get` and `put` methods to pass the results through some + * validation to ensure the config is valid and can safely be typed. + */ +export default class BaseConfigService extends DependencyAwareClass { + /** + * Returns the basic config. + * + * This getter is used to set the default config should the service not find + * any on the configured S3 Bucket. + * + * See `getOrCreate` and `patch` methods. + */ + static get defaultConfig() { + return { + state: ServiceStates.OK, + }; + } + + /** + * Returns the S3 configuration used to retrieve or update the service + * configuration. + */ + static get s3config(): { Bucket: string; Key: string; } { + return { + Bucket: process.env.SERVICE_CONFIG_S3_BUCKET || '', + Key: process.env.SERVICE_CONFIG_S3_KEY || '', + }; + } + + /** + * Returns an S3 client. + */ + static get client() { + return new S3({ + region: process.env.REGION, + }); + } + + /** + * Returns an S3 client + */ + get client() { + return (this.constructor as typeof BaseConfigService).client; + } + + /** + * Deletes the configuration stored on S3. Helpful in feature tests. + */ + async delete() { + return this.client.deleteObject( + (this.constructor as typeof BaseConfigService).s3config, + ).promise(); + } + + /** + * Puts the given configuration on S3. + * + * @param config + */ + async put(config: T): Promise { + await this.client.putObject({ + ...(this.constructor as typeof BaseConfigService).s3config, + Body: JSON.stringify(config), + }).promise(); + + return config; + } + + /** + * Gets the service configuration. + */ + async get(): Promise { + const response = await this.client.getObject( + (this.constructor as typeof BaseConfigService).s3config, + ).promise(); + const body = String(response.Body); + + if (!body) { + // Empty strings are not valid configurations + throw new Error('Configuration file is empty'); + } + + try { + return JSON.parse(body); + } catch { + throw new Error('Invalid configuration file'); + } + } + + /** + * Gets or creates the service configuration. + * + * If the configuration is not found on S3 the default configuration is + * uploaded and returned instead. + */ + async getOrCreate(): Promise { + try { + return await this.get(); + } catch (error: any) { + if (error.code !== S3_NO_SUCH_KEY_ERROR_CODE) { + // Throw directly any other error + throw error; + } + + return this.put((this.constructor as typeof BaseConfigService).defaultConfig); + } + } + + /** + * Patches the existing configuration + * or the default configuration + * with the provided partial configuration + * + * @param partialConfig + */ + async patch(partialConfig: any): Promise { + let base: any = (this.constructor as typeof BaseConfigService).defaultConfig; + + try { + base = await this.get(); + } catch (error: any) { + if (error.code !== S3_NO_SUCH_KEY_ERROR_CODE) { + // Throw directly any other error + throw error; + } + + // Config doesn't exist + // keep using `this.constructor.defaultConfig` + } + + return this.put({ + ...base, + ...partialConfig, + }); + } + + /** + * Performs a health check given the current config. + * + * If `currentConfig` is not supplied it uses `getOrCreate` to fetch it. + * + * @param currentConfig + */ + async healthCheck(currentConfig?: any): Promise { + const config = currentConfig || await this.getOrCreate(); + + return ServiceStatesHttpCodes[config.state] || 500; + } + + /** + * Ensures that the application is healthy or throws a `LambdaTermination`. + * + * @param currentConfig + */ + async ensureHealthy(currentConfig: any = null) { + const statusCode = await this.healthCheck(currentConfig); + + if (statusCode < 400) { + return statusCode; + } + + const message = 'Application is not healthy.'; + + throw new LambdaTermination(message, statusCode, message, message); + } +} diff --git a/tests/unit/services/BaseConfigService.spec.ts b/tests/unit/services/BaseConfigService.spec.ts new file mode 100644 index 00000000..7a6166a8 --- /dev/null +++ b/tests/unit/services/BaseConfigService.spec.ts @@ -0,0 +1,290 @@ +import { S3 } from 'aws-sdk'; + +import { + S3_NO_SUCH_KEY_ERROR_CODE, + ServiceStates, + ServiceStatesHttpCodes, +} from '@/src/services/BaseConfigService'; + +import { + BaseConfigService, + Context, + DependencyInjection, +} from '@/src'; + +type ErrorWithCode = Error & { code?: any }; + +const createAsyncMock = (returnValue: any) => { + const mockedValue = returnValue instanceof Error + ? Promise.reject(returnValue) + : Promise.resolve(returnValue); + + return jest.fn().mockReturnValue({ promise: () => mockedValue }); +}; + +/** + * Generate a BaseConfigService with mock S3 client. + */ +const getService = ( + { + getObject = null, + putObject = null, + deleteObject = null, + }: any = {}, +): BaseConfigService & { constructor: typeof BaseConfigService; } => { + const di = new DependencyInjection({ + dependencies: { + BaseConfigService, + }, + }, {}, {} as Context); + + const service = di.get(BaseConfigService); + + const client = { + getObject: createAsyncMock(getObject), + putObject: createAsyncMock(putObject), + deleteObject: createAsyncMock(deleteObject), + } as unknown as S3; + + jest.spyOn(service, 'client', 'get').mockReturnValue(client); + + return service as any; +}; + +describe('unit.services.BaseConfigService', () => { + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('defaultConfig', () => { + it('is a valid object', () => { + const service = getService(); + const isValidObject = typeof service.constructor.defaultConfig === 'object' && service.constructor.defaultConfig !== null; + + expect(isValidObject).toEqual(true); + }); + + it('has state defined', () => { + const service = getService(); + const defaultConfig = service.constructor.defaultConfig; + + expect('state' in defaultConfig).toEqual(true); + }); + }); + + describe('s3config', () => { + it('is a valid object', () => { + const service = getService(); + const isValidObject = typeof service.constructor.s3config === 'object' && service.constructor.s3config !== null; + + expect(isValidObject).toEqual(true); + }); + + it('has Bucket and Key defined', () => { + const service = getService(); + const s3config = service.constructor.s3config; + + expect('Bucket' in s3config).toEqual(true); + expect('Key' in s3config).toEqual(true); + }); + }); + + describe('delete', () => { + it('calls client.deleteObject', async () => { + const service = getService(); + await service.delete(); + + expect(service.client.deleteObject).toHaveBeenCalledTimes(1); + }); + }); + + describe('put', () => { + it('calls client.putObject', async () => { + const expected = Symbol('put'); + const service = getService(); + await service.put(expected); + + expect(service.client.putObject).toHaveBeenCalledTimes(1); + }); + + it('returns the provided config unchanged', async () => { + const expected = Symbol('put'); + const service = getService(); + const config = await service.put(expected); + + expect(config).toEqual(expected); + }); + }); + + describe('get', () => { + it('gets an existing config', async () => { + const expected = { a: 1 }; + const service = getService({ getObject: { Body: JSON.stringify(expected) } }); + const config = await service.get(); + + expect(config).toEqual(expected); + }); + + it('refuses empty configurations', async () => { + const service = getService({ getObject: { Body: '' } }); + + await expect(service.get()).rejects.toThrowErrorMatchingSnapshot(); + }); + + it('refuses invalid configurations', async () => { + const service = getService({ getObject: { Body: '{ "a": 1' } }); + + await expect(service.get()).rejects.toThrowErrorMatchingSnapshot(); + }); + + it('propagates the 404', async () => { + const error: ErrorWithCode = new Error('404'); + error.code = S3_NO_SUCH_KEY_ERROR_CODE; + + const service = getService({ getObject: error }); + + await expect(service.get()).rejects.toThrowErrorMatchingSnapshot(); + }); + }); + + describe('getOrCreate', () => { + it('uploads the defaultConfig with a 404 error', async () => { + const error: ErrorWithCode = new Error('404'); + error.code = S3_NO_SUCH_KEY_ERROR_CODE; + + const service = getService({ getObject: error }); + const config = await service.getOrCreate(); + + expect(config).toEqual(service.constructor.defaultConfig); + }); + + it('throws any non-404 error', async () => { + const error: ErrorWithCode = new Error('Bad error'); + error.code = 'another'; + + const service = getService({ getObject: error }); + + await expect(service.getOrCreate()).rejects.toThrowErrorMatchingSnapshot(); + }); + }); + + describe('patch', () => { + it('uses the existing config if an existing config is found', async () => { + const existing = { a: 1 }; + const service = getService({ getObject: { Body: JSON.stringify(existing) } }); + + const additional = { b: 2 }; + const expected = { ...existing, ...additional }; + const config = await service.patch(additional); + + expect(config).toEqual(expected); + }); + + it('uses the base config if no existing config is found', async () => { + const error: ErrorWithCode = new Error('404'); + error.code = S3_NO_SUCH_KEY_ERROR_CODE; + const service = getService({ getObject: error }); + + const existing = service.constructor.defaultConfig; + const additional = { b: 2 }; + const expected = { ...existing, ...additional }; + const config = await service.patch(additional); + + expect(config).toEqual(expected); + }); + + it('throws any non-404 error', async () => { + const error: ErrorWithCode = new Error('Bad error'); + error.code = 'another'; + + const service = getService({ getObject: error }); + + await expect(service.patch({ b: 1 })).rejects.toThrowErrorMatchingSnapshot(); + }); + }); + + describe('healthCheck', () => { + Object.values(ServiceStates).forEach((state) => { + describe(state, () => { + it('returns the expected HTTP code with the given config', async () => { + const config = { state }; + const service = getService(); + const statusCode = await service.healthCheck(config); + const expected = ServiceStatesHttpCodes[state]; + + expect(statusCode).toEqual(expected); + }); + + it('returns the expected HTTP code with the existing config', async () => { + const config = { state }; + const service = getService({ getObject: { Body: JSON.stringify(config) } }); + const statusCode = await service.healthCheck(); + const expected = ServiceStatesHttpCodes[state]; + + expect(statusCode).toEqual(expected); + }); + }); + }); + + describe('Unknown state', () => { + it('returns 500 with the given config', async () => { + const config = { state: 'Unknown' }; + const service = getService(); + const statusCode = await service.healthCheck(config); + const expected = 500; + + expect(statusCode).toEqual(expected); + }); + + it('returns 500 with the existing config', async () => { + const config = { state: 'Unknown' }; + const service = getService({ getObject: { Body: JSON.stringify(config) } }); + const statusCode = await service.healthCheck(); + const expected = 500; + + expect(statusCode).toEqual(expected); + }); + }); + }); + + describe('ensureHealthy', () => { + [200, 201, 202, 204, 300, 301, 399].forEach((statusCode) => { + describe(statusCode, () => { + it('is healthy', async () => { + const service = getService(); + jest.spyOn(service, 'healthCheck').mockImplementation(() => Promise.resolve(statusCode)); + + await expect(service.ensureHealthy()).resolves.toEqual(statusCode); + }); + }); + }); + + [400, 401, 403, 404, 409, 499, 500, 501, 502, 503, 504, 'Dante Alighieri'].forEach((statusCode) => { + describe(statusCode, () => { + it('throws a LambdaTermination', async () => { + const service = getService(); + jest.spyOn(service, 'healthCheck').mockImplementation(() => Promise.resolve(statusCode as any)); + + await expect(service.ensureHealthy()).rejects.toThrowErrorMatchingSnapshot(); + }); + }); + }); + }); + + describe('client', () => { + it('should return an S3 instance (static method)', () => { + expect(BaseConfigService.client).toBeInstanceOf(S3); + }); + + it('should return an S3 instance (instance method)', () => { + const di = new DependencyInjection({ + dependencies: { + BaseConfigService, + }, + }, {}, {} as Context); + const service = di.get(BaseConfigService); + + expect(service.client).toBeInstanceOf(S3); + }); + }); +}); diff --git a/tests/unit/services/__snapshots__/BaseConfigService.spec.ts.snap b/tests/unit/services/__snapshots__/BaseConfigService.spec.ts.snap new file mode 100644 index 00000000..5f6fec23 --- /dev/null +++ b/tests/unit/services/__snapshots__/BaseConfigService.spec.ts.snap @@ -0,0 +1,35 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`unit.services.BaseConfigService ensureHealthy 400 throws a LambdaTermination 1`] = `"Application is not healthy."`; + +exports[`unit.services.BaseConfigService ensureHealthy 401 throws a LambdaTermination 1`] = `"Application is not healthy."`; + +exports[`unit.services.BaseConfigService ensureHealthy 403 throws a LambdaTermination 1`] = `"Application is not healthy."`; + +exports[`unit.services.BaseConfigService ensureHealthy 404 throws a LambdaTermination 1`] = `"Application is not healthy."`; + +exports[`unit.services.BaseConfigService ensureHealthy 409 throws a LambdaTermination 1`] = `"Application is not healthy."`; + +exports[`unit.services.BaseConfigService ensureHealthy 499 throws a LambdaTermination 1`] = `"Application is not healthy."`; + +exports[`unit.services.BaseConfigService ensureHealthy 500 throws a LambdaTermination 1`] = `"Application is not healthy."`; + +exports[`unit.services.BaseConfigService ensureHealthy 501 throws a LambdaTermination 1`] = `"Application is not healthy."`; + +exports[`unit.services.BaseConfigService ensureHealthy 502 throws a LambdaTermination 1`] = `"Application is not healthy."`; + +exports[`unit.services.BaseConfigService ensureHealthy 503 throws a LambdaTermination 1`] = `"Application is not healthy."`; + +exports[`unit.services.BaseConfigService ensureHealthy 504 throws a LambdaTermination 1`] = `"Application is not healthy."`; + +exports[`unit.services.BaseConfigService ensureHealthy Dante Alighieri throws a LambdaTermination 1`] = `"Application is not healthy."`; + +exports[`unit.services.BaseConfigService get propagates the 404 1`] = `"404"`; + +exports[`unit.services.BaseConfigService get refuses empty configurations 1`] = `"Configuration file is empty"`; + +exports[`unit.services.BaseConfigService get refuses invalid configurations 1`] = `"Invalid configuration file"`; + +exports[`unit.services.BaseConfigService getOrCreate throws any non-404 error 1`] = `"Bad error"`; + +exports[`unit.services.BaseConfigService patch throws any non-404 error 1`] = `"Bad error"`; From 7e5db456fc30889fd725581160e25291d685e379 Mon Sep 17 00:00:00 2001 From: Seb Aebischer Date: Mon, 22 Aug 2022 17:38:06 +0100 Subject: [PATCH 25/39] Migrate HTTPService --- README.md | 1 + docs/services/HTTPService.md | 6 + src/index.ts | 4 + src/services/HTTPService.ts | 59 ++++ tests/unit/services/HTTPService.spec.ts | 74 +++++ .../__snapshots__/HTTPService.spec.ts.snap | 298 ++++++++++++++++++ 6 files changed, 442 insertions(+) create mode 100644 docs/services/HTTPService.md create mode 100644 src/services/HTTPService.ts create mode 100644 tests/unit/services/HTTPService.spec.ts create mode 100644 tests/unit/services/__snapshots__/HTTPService.spec.ts.snap diff --git a/README.md b/README.md index 64ef002c..daafb392 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,7 @@ export default new LambdaWrapper({ Lambda Wrapper comes with some commonly used dependencies built in: +- [HTTPService](docs/services/HTTPService.md) - [LoggerService](docs/services/LoggerService.md) - [RequestService](docs/services/RequestService.md) - [SQSService](docs/services/SQSService.md) diff --git a/docs/services/HTTPService.md b/docs/services/HTTPService.md new file mode 100644 index 00000000..49e64deb --- /dev/null +++ b/docs/services/HTTPService.md @@ -0,0 +1,6 @@ +# HTTPService + +Wrapper for `axios.request` that: + +- sets a default timeout of 10 seconds +- forwards a `x-comicrelief-test-metadata` header if one was provided in the request from upstream diff --git a/src/index.ts b/src/index.ts index aacbc544..93deeceb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -43,6 +43,10 @@ export { export { default as BaseConfigService, } from './services/BaseConfigService'; +export { + default as HTTPService, + COMICRELIEF_TEST_METADATA_HEADER, +} from './services/HTTPService'; export { default as LoggerService, } from './services/LoggerService'; diff --git a/src/services/HTTPService.ts b/src/services/HTTPService.ts new file mode 100644 index 00000000..22d5241e --- /dev/null +++ b/src/services/HTTPService.ts @@ -0,0 +1,59 @@ +import axios, { AxiosRequestConfig, AxiosResponse } from 'axios'; + +import DependencyAwareClass from '../core/dependency-base'; +import DependencyInjection from '../core/dependency-injection'; +import RequestService from './RequestService'; + +export const COMICRELIEF_TEST_METADATA_HEADER = 'x-comicrelief-test-metadata'; +export const DEFAULT_HTTP_TIMEOUT = 10 * 1000; + +/** + * Wrapper for `axios.request` that: + * + * - sets a default timeout of 10 seconds + * - forwards a `x-comicrelief-test-metadata` header if one was provided in + * the request from upstream + */ +export default class HTTPService extends DependencyAwareClass { + config: AxiosRequestConfig; + + constructor(di: DependencyInjection) { + super(di); + + this.config = { + timeout: DEFAULT_HTTP_TIMEOUT, + }; + } + + /** + * Set the default timeout. + * + * @param ms + */ + setDefaultTimeout(ms: number) { + this.config.timeout = ms; + } + + /** + * Perform an HTTP request. + * + * @param config + */ + async request(config: AxiosRequestConfig): Promise { + const mergedConfig = { + timeout: this.config.timeout, + headers: {}, + ...config, + }; + + const request = this.di.get(RequestService); + const testMetadata = request.getHeader(COMICRELIEF_TEST_METADATA_HEADER); + + if (testMetadata) { + mergedConfig.headers = mergedConfig.headers || {}; + mergedConfig.headers[COMICRELIEF_TEST_METADATA_HEADER] = testMetadata; + } + + return axios.request(mergedConfig); + } +} diff --git a/tests/unit/services/HTTPService.spec.ts b/tests/unit/services/HTTPService.spec.ts new file mode 100644 index 00000000..d50fe2d7 --- /dev/null +++ b/tests/unit/services/HTTPService.spec.ts @@ -0,0 +1,74 @@ +import axios from 'axios'; + +import { + COMICRELIEF_TEST_METADATA_HEADER, + Context, + DependencyInjection, + HTTPService, + RequestService, +} from '@/src'; +import mockEvent from '@/tests/mocks/aws/event.json'; + +const mockContext = { invokedFunctionArn: 'my-function' } as Context; + +const getService = (event = mockEvent, context = mockContext) => { + const di = new DependencyInjection({ + dependencies: { + HTTPService, + RequestService, + }, + }, event, context); + return di.get(HTTPService); +}; + +describe('unit.services.HTTPService', () => { + afterEach(() => jest.clearAllMocks()); + + describe('request', () => { + const testCases = { + 'GET request': { method: 'GET', url: '/' }, + 'POST request': { method: 'POST', url: '/' }, + 'PUT request': { method: 'PUT', url: '/' }, + 'PATCH request': { method: 'PATCH', url: '/' }, + 'HEAD request': { method: 'HEAD', url: '/' }, + 'DELETE request': { method: 'DELETE', url: '/' }, + 'with URL': { url: '/some/nested/path' }, + 'with baseURL': { baseUrl: 'https://comicrelief.com/test', url: '/additional/url' }, + 'overriding timeout': { timeout: 99 }, + 'with headers': { headers: { Authorization: 'Bearer test' } }, + 'with undefined headers': { headers: undefined }, + }; + + Object.entries(testCases).forEach(([description, config]) => { + it(description, async () => { + const expected = { response: {} }; + const mock = jest.spyOn(axios, 'request').mockResolvedValue(expected); + const service = getService(); + + const response = await service.request(config); + + expect(response).toEqual(expected); + expect(mock.mock.calls).toMatchSnapshot('config'); + }); + + it(`adds the test header, ${description}`, async () => { + const metadata = JSON.stringify({ user: 'Dante Alighieri' }); + const event = { + ...mockEvent, + headers: { + ...mockEvent.headers, + [COMICRELIEF_TEST_METADATA_HEADER]: metadata, + }, + }; + const expected = { response: {} }; + const mock = jest.spyOn(axios, 'request').mockResolvedValue(expected); + const service = getService(event); + + const response = await service.request(config); + + expect(response).toEqual(expected); + expect(mock.mock.calls).toMatchSnapshot('config'); + }); + }); + }); +}); diff --git a/tests/unit/services/__snapshots__/HTTPService.spec.ts.snap b/tests/unit/services/__snapshots__/HTTPService.spec.ts.snap new file mode 100644 index 00000000..2328d61f --- /dev/null +++ b/tests/unit/services/__snapshots__/HTTPService.spec.ts.snap @@ -0,0 +1,298 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`unit.services.HTTPService request DELETE request: config 1`] = ` +Array [ + Array [ + Object { + "headers": Object {}, + "method": "DELETE", + "timeout": 10000, + "url": "/", + }, + ], +] +`; + +exports[`unit.services.HTTPService request GET request: config 1`] = ` +Array [ + Array [ + Object { + "headers": Object {}, + "method": "GET", + "timeout": 10000, + "url": "/", + }, + ], +] +`; + +exports[`unit.services.HTTPService request HEAD request: config 1`] = ` +Array [ + Array [ + Object { + "headers": Object {}, + "method": "HEAD", + "timeout": 10000, + "url": "/", + }, + ], +] +`; + +exports[`unit.services.HTTPService request PATCH request: config 1`] = ` +Array [ + Array [ + Object { + "headers": Object {}, + "method": "PATCH", + "timeout": 10000, + "url": "/", + }, + ], +] +`; + +exports[`unit.services.HTTPService request POST request: config 1`] = ` +Array [ + Array [ + Object { + "headers": Object {}, + "method": "POST", + "timeout": 10000, + "url": "/", + }, + ], +] +`; + +exports[`unit.services.HTTPService request PUT request: config 1`] = ` +Array [ + Array [ + Object { + "headers": Object {}, + "method": "PUT", + "timeout": 10000, + "url": "/", + }, + ], +] +`; + +exports[`unit.services.HTTPService request adds the test header, DELETE request: config 1`] = ` +Array [ + Array [ + Object { + "headers": Object { + "x-comicrelief-test-metadata": "{\\"user\\":\\"Dante Alighieri\\"}", + }, + "method": "DELETE", + "timeout": 10000, + "url": "/", + }, + ], +] +`; + +exports[`unit.services.HTTPService request adds the test header, GET request: config 1`] = ` +Array [ + Array [ + Object { + "headers": Object { + "x-comicrelief-test-metadata": "{\\"user\\":\\"Dante Alighieri\\"}", + }, + "method": "GET", + "timeout": 10000, + "url": "/", + }, + ], +] +`; + +exports[`unit.services.HTTPService request adds the test header, HEAD request: config 1`] = ` +Array [ + Array [ + Object { + "headers": Object { + "x-comicrelief-test-metadata": "{\\"user\\":\\"Dante Alighieri\\"}", + }, + "method": "HEAD", + "timeout": 10000, + "url": "/", + }, + ], +] +`; + +exports[`unit.services.HTTPService request adds the test header, PATCH request: config 1`] = ` +Array [ + Array [ + Object { + "headers": Object { + "x-comicrelief-test-metadata": "{\\"user\\":\\"Dante Alighieri\\"}", + }, + "method": "PATCH", + "timeout": 10000, + "url": "/", + }, + ], +] +`; + +exports[`unit.services.HTTPService request adds the test header, POST request: config 1`] = ` +Array [ + Array [ + Object { + "headers": Object { + "x-comicrelief-test-metadata": "{\\"user\\":\\"Dante Alighieri\\"}", + }, + "method": "POST", + "timeout": 10000, + "url": "/", + }, + ], +] +`; + +exports[`unit.services.HTTPService request adds the test header, PUT request: config 1`] = ` +Array [ + Array [ + Object { + "headers": Object { + "x-comicrelief-test-metadata": "{\\"user\\":\\"Dante Alighieri\\"}", + }, + "method": "PUT", + "timeout": 10000, + "url": "/", + }, + ], +] +`; + +exports[`unit.services.HTTPService request adds the test header, overriding timeout: config 1`] = ` +Array [ + Array [ + Object { + "headers": Object { + "x-comicrelief-test-metadata": "{\\"user\\":\\"Dante Alighieri\\"}", + }, + "timeout": 99, + }, + ], +] +`; + +exports[`unit.services.HTTPService request adds the test header, with URL: config 1`] = ` +Array [ + Array [ + Object { + "headers": Object { + "x-comicrelief-test-metadata": "{\\"user\\":\\"Dante Alighieri\\"}", + }, + "timeout": 10000, + "url": "/some/nested/path", + }, + ], +] +`; + +exports[`unit.services.HTTPService request adds the test header, with baseURL: config 1`] = ` +Array [ + Array [ + Object { + "baseUrl": "https://comicrelief.com/test", + "headers": Object { + "x-comicrelief-test-metadata": "{\\"user\\":\\"Dante Alighieri\\"}", + }, + "timeout": 10000, + "url": "/additional/url", + }, + ], +] +`; + +exports[`unit.services.HTTPService request adds the test header, with headers: config 1`] = ` +Array [ + Array [ + Object { + "headers": Object { + "Authorization": "Bearer test", + "x-comicrelief-test-metadata": "{\\"user\\":\\"Dante Alighieri\\"}", + }, + "timeout": 10000, + }, + ], +] +`; + +exports[`unit.services.HTTPService request adds the test header, with undefined headers: config 1`] = ` +Array [ + Array [ + Object { + "headers": Object { + "x-comicrelief-test-metadata": "{\\"user\\":\\"Dante Alighieri\\"}", + }, + "timeout": 10000, + }, + ], +] +`; + +exports[`unit.services.HTTPService request overriding timeout: config 1`] = ` +Array [ + Array [ + Object { + "headers": Object {}, + "timeout": 99, + }, + ], +] +`; + +exports[`unit.services.HTTPService request with URL: config 1`] = ` +Array [ + Array [ + Object { + "headers": Object {}, + "timeout": 10000, + "url": "/some/nested/path", + }, + ], +] +`; + +exports[`unit.services.HTTPService request with baseURL: config 1`] = ` +Array [ + Array [ + Object { + "baseUrl": "https://comicrelief.com/test", + "headers": Object {}, + "timeout": 10000, + "url": "/additional/url", + }, + ], +] +`; + +exports[`unit.services.HTTPService request with headers: config 1`] = ` +Array [ + Array [ + Object { + "headers": Object { + "Authorization": "Bearer test", + }, + "timeout": 10000, + }, + ], +] +`; + +exports[`unit.services.HTTPService request with undefined headers: config 1`] = ` +Array [ + Array [ + Object { + "headers": undefined, + "timeout": 10000, + }, + ], +] +`; From a7787209cb39f55f3275e536f1a3bef7aa228f8a Mon Sep 17 00:00:00 2001 From: Seb Aebischer Date: Tue, 23 Aug 2022 21:10:37 +0100 Subject: [PATCH 26/39] Rename core modules to match class names --- src/core/{dependency-base.ts => DependencyAwareClass.ts} | 2 +- .../{dependency-injection.ts => DependencyInjection.ts} | 2 +- src/core/{lambda-wrapper.ts => LambdaWrapper.ts} | 2 +- src/core/config.ts | 2 +- src/index.ts | 8 ++++---- src/services/BaseConfigService.ts | 2 +- src/services/HTTPService.ts | 4 ++-- src/services/LoggerService.ts | 4 ++-- src/services/RequestService.ts | 2 +- src/services/SQSService.ts | 4 ++-- src/services/TimerService.ts | 2 +- ...pendency-base.spec.ts => DependencyAwareClass.spec.ts} | 0 ...ency-injection.spec.ts => DependencyInjection.spec.ts} | 0 tests/unit/index.spec.ts | 6 +++--- 14 files changed, 20 insertions(+), 20 deletions(-) rename src/core/{dependency-base.ts => DependencyAwareClass.ts} (83%) rename src/core/{dependency-injection.ts => DependencyInjection.ts} (97%) rename src/core/{lambda-wrapper.ts => LambdaWrapper.ts} (95%) rename tests/unit/core/{dependency-base.spec.ts => DependencyAwareClass.spec.ts} (100%) rename tests/unit/core/{dependency-injection.spec.ts => DependencyInjection.spec.ts} (100%) diff --git a/src/core/dependency-base.ts b/src/core/DependencyAwareClass.ts similarity index 83% rename from src/core/dependency-base.ts rename to src/core/DependencyAwareClass.ts index 45881e85..8957cf93 100644 --- a/src/core/dependency-base.ts +++ b/src/core/DependencyAwareClass.ts @@ -1,4 +1,4 @@ -import DependencyInjection from './dependency-injection'; +import DependencyInjection from './DependencyInjection'; /** * Base class for dependencies. diff --git a/src/core/dependency-injection.ts b/src/core/DependencyInjection.ts similarity index 97% rename from src/core/dependency-injection.ts rename to src/core/DependencyInjection.ts index d324d87b..3f443eb7 100644 --- a/src/core/dependency-injection.ts +++ b/src/core/DependencyInjection.ts @@ -1,7 +1,7 @@ import { Context } from 'aws-lambda'; +import DependencyAwareClass from './DependencyAwareClass'; import { LambdaWrapperConfig } from './config'; -import DependencyAwareClass from './dependency-base'; // eslint-disable-next-line no-use-before-define type Class = new (di: DependencyInjection) => T; diff --git a/src/core/lambda-wrapper.ts b/src/core/LambdaWrapper.ts similarity index 95% rename from src/core/lambda-wrapper.ts rename to src/core/LambdaWrapper.ts index 9ab0d0d6..053d9138 100644 --- a/src/core/lambda-wrapper.ts +++ b/src/core/LambdaWrapper.ts @@ -1,8 +1,8 @@ import Epsagon from 'epsagon'; import { Context, Handler } from '../index'; +import DependencyInjection from './DependencyInjection'; import { LambdaWrapperConfig, mergeConfig } from './config'; -import DependencyInjection from './dependency-injection'; export default class LambdaWrapper { constructor(readonly config: TConfig) {} diff --git a/src/core/config.ts b/src/core/config.ts index b5d3ecd4..43c5e64c 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -1,4 +1,4 @@ -import DependencyAwareClass from './dependency-base'; +import DependencyAwareClass from './DependencyAwareClass'; /** * Config for Lambda Wrapper defining dependencies and their configuration. diff --git a/src/index.ts b/src/index.ts index 93deeceb..e1ae2a6d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,5 @@ +import LambdaWrapper from './core/LambdaWrapper'; import { LambdaWrapperConfig } from './core/config'; -import LambdaWrapper from './core/lambda-wrapper'; import LoggerService from './services/LoggerService'; import RequestService from './services/RequestService'; import SQSService, { WithSQSServiceConfig } from './services/SQSService'; @@ -25,9 +25,9 @@ export default lambdaWrapper; export { Context, Handler } from 'aws-lambda'; export { LambdaWrapperConfig } from './core/config'; -export { default as DependencyAwareClass } from './core/dependency-base'; -export { default as DependencyInjection } from './core/dependency-injection'; -export { default as LambdaWrapper } from './core/lambda-wrapper'; +export { default as DependencyAwareClass } from './core/DependencyAwareClass'; +export { default as DependencyInjection } from './core/DependencyInjection'; +export { default as LambdaWrapper } from './core/LambdaWrapper'; export { default as ResponseModel, diff --git a/src/services/BaseConfigService.ts b/src/services/BaseConfigService.ts index e1e5f524..0a6805d9 100644 --- a/src/services/BaseConfigService.ts +++ b/src/services/BaseConfigService.ts @@ -1,6 +1,6 @@ import { S3 } from 'aws-sdk'; -import DependencyAwareClass from '../core/dependency-base'; +import DependencyAwareClass from '../core/DependencyAwareClass'; import LambdaTermination from '../utils/LambdaTermination'; /** diff --git a/src/services/HTTPService.ts b/src/services/HTTPService.ts index 22d5241e..80898091 100644 --- a/src/services/HTTPService.ts +++ b/src/services/HTTPService.ts @@ -1,7 +1,7 @@ import axios, { AxiosRequestConfig, AxiosResponse } from 'axios'; -import DependencyAwareClass from '../core/dependency-base'; -import DependencyInjection from '../core/dependency-injection'; +import DependencyAwareClass from '../core/DependencyAwareClass'; +import DependencyInjection from '../core/DependencyInjection'; import RequestService from './RequestService'; export const COMICRELIEF_TEST_METADATA_HEADER = 'x-comicrelief-test-metadata'; diff --git a/src/services/LoggerService.ts b/src/services/LoggerService.ts index 2aeb2f33..9d82b6cf 100644 --- a/src/services/LoggerService.ts +++ b/src/services/LoggerService.ts @@ -3,8 +3,8 @@ import { AxiosError } from 'axios'; import Epsagon from 'epsagon'; import Winston from 'winston'; -import DependencyAwareClass from '../core/dependency-base'; -import DependencyInjection from '../core/dependency-injection'; +import DependencyAwareClass from '../core/DependencyAwareClass'; +import DependencyInjection from '../core/DependencyInjection'; const sentryIsAvailable = typeof process.env.RAVEN_DSN !== 'undefined' && typeof process.env.RAVEN_DSN === 'string' && process.env.RAVEN_DSN !== 'undefined'; diff --git a/src/services/RequestService.ts b/src/services/RequestService.ts index 74d8df0c..4070ffb7 100644 --- a/src/services/RequestService.ts +++ b/src/services/RequestService.ts @@ -5,7 +5,7 @@ import useragent from 'useragent'; import validate from 'validate.js/validate'; import XML2JS from 'xml2js'; -import DependencyAwareClass from '../core/dependency-base'; +import DependencyAwareClass from '../core/DependencyAwareClass'; import ResponseModel from '../models/ResponseModel'; import LoggerService from './LoggerService'; diff --git a/src/services/SQSService.ts b/src/services/SQSService.ts index ce0c3628..1144f016 100644 --- a/src/services/SQSService.ts +++ b/src/services/SQSService.ts @@ -3,8 +3,8 @@ import { each } from 'async'; import AWS from 'aws-sdk'; import { v4 as uuid } from 'uuid'; -import DependencyAwareClass from '../core/dependency-base'; -import DependencyInjection from '../core/dependency-injection'; +import DependencyAwareClass from '../core/DependencyAwareClass'; +import DependencyInjection from '../core/DependencyInjection'; import SQSMessageModel from '../models/SQSMessageModel'; import StatusModel, { STATUS_TYPES } from '../models/StatusModel'; import LoggerService from './LoggerService'; diff --git a/src/services/TimerService.ts b/src/services/TimerService.ts index ccddb0b9..962cbb20 100644 --- a/src/services/TimerService.ts +++ b/src/services/TimerService.ts @@ -1,4 +1,4 @@ -import DependencyAwareClass from '../core/dependency-base'; +import DependencyAwareClass from '../core/DependencyAwareClass'; import LoggerService from './LoggerService'; /** diff --git a/tests/unit/core/dependency-base.spec.ts b/tests/unit/core/DependencyAwareClass.spec.ts similarity index 100% rename from tests/unit/core/dependency-base.spec.ts rename to tests/unit/core/DependencyAwareClass.spec.ts diff --git a/tests/unit/core/dependency-injection.spec.ts b/tests/unit/core/DependencyInjection.spec.ts similarity index 100% rename from tests/unit/core/dependency-injection.spec.ts rename to tests/unit/core/DependencyInjection.spec.ts diff --git a/tests/unit/index.spec.ts b/tests/unit/index.spec.ts index 21c606c9..8c66cce2 100644 --- a/tests/unit/index.spec.ts +++ b/tests/unit/index.spec.ts @@ -1,6 +1,6 @@ -import _DependencyAwareClass from '@/src/core/dependency-base'; -import _DependencyInjection from '@/src/core/dependency-injection'; -import _LambdaWrapper from '@/src/core/lambda-wrapper'; +import _DependencyAwareClass from '@/src/core/DependencyAwareClass'; +import _DependencyInjection from '@/src/core/DependencyInjection'; +import _LambdaWrapper from '@/src/core/LambdaWrapper'; import _SQSService from '@/src/services/SQSService'; import lambdaWrapper, { From 0cd202591ed5a8856c9b90bacd0667daf7ef3a20 Mon Sep 17 00:00:00 2001 From: Seb Aebischer Date: Tue, 23 Aug 2022 21:15:07 +0100 Subject: [PATCH 27/39] Document models that will not be migrated --- docs/migration/v2.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/migration/v2.md b/docs/migration/v2.md index 73cd3ff6..44bd61e7 100644 --- a/docs/migration/v2.md +++ b/docs/migration/v2.md @@ -5,6 +5,7 @@ This doc summarises the breaking changes introduced in v2 and what you need to d - [Configuration](#configuration) - [Wrapping a function](#wrapping-a-function) - [Dependency injection](#dependency-injection) +- [Models](#models) ## Configuration @@ -144,3 +145,11 @@ export default lambdaWrapper.wrap(async (di) => { `definitions` has been removed. `getEvent`, `getContext` and `getConfiguration` have been deprecated and will be removed in a future major release. Use the `event`, `context` and `config` properties directly. + +## Models + +The `Model` base class has been removed. It's hard to make it type-safe (it tries to dynamically call setter methods) and we do modelling and validation differently now, using our [data-models](https://github.com/comicrelief/data-models) repo which is based around [Yup](https://github.com/jquense/yup). + +The `MarketingPreference` model is removed, as this is application-specific and again is replaced by our [data-models](https://github.com/comicrelief/data-models) repo. + +Other models (`ResponseModel`, `SQSMessageModel`, `StatusModel`) are unaffected except that they no longer inherit from a common `Model` class. From 2a7e4439f2c2dc4314c821b109ebc18dbbdd4ccc Mon Sep 17 00:00:00 2001 From: Seb Aebischer Date: Tue, 23 Aug 2022 21:39:29 +0100 Subject: [PATCH 28/39] Migrate and improve missing model tests --- tests/unit/models/ResponseModel.spec.ts | 93 +++++++++++++++++++++++ tests/unit/models/SQSMessageModel.spec.ts | 59 ++++++++++++++ 2 files changed, 152 insertions(+) create mode 100644 tests/unit/models/ResponseModel.spec.ts create mode 100644 tests/unit/models/SQSMessageModel.spec.ts diff --git a/tests/unit/models/ResponseModel.spec.ts b/tests/unit/models/ResponseModel.spec.ts new file mode 100644 index 00000000..17442c52 --- /dev/null +++ b/tests/unit/models/ResponseModel.spec.ts @@ -0,0 +1,93 @@ +import { DEFAULT_MESSAGE, RESPONSE_HEADERS } from '@/src/models/ResponseModel'; + +import { ResponseModel } from '@/src'; + +describe('unit.models.ResponseModel', () => { + describe('headers', () => { + it('should include the default response headers', () => { + const response = new ResponseModel({}, 500); + expect(response.generate().headers).toEqual(RESPONSE_HEADERS); + }); + }); + + describe('body', () => { + it('should set the body data from the constructor', () => { + const response = new ResponseModel({ test: 123 }, 500); + + const responseBody = response.generate(); + + expect(typeof responseBody.body).toEqual('string'); + expect(responseBody.body).toContain('123'); + expect(JSON.parse(responseBody.body)).toHaveProperty('data.test', 123); + }); + + it('should modify the body data using setData', () => { + const response = new ResponseModel({ test: 123 }, 500); + + response.setData({ test: 234 }); + const responseBody = response.generate(); + + expect(typeof responseBody.body).toEqual('string'); + expect(responseBody.body).toContain('234'); + expect(JSON.parse(responseBody.body)).toHaveProperty('data.test', 234); + }); + }); + + describe('status code', () => { + it('should return a 200 status code that is supplied to it', () => { + const response = new ResponseModel({}, 200); + expect(response.generate().statusCode).toEqual(200); + }); + + it('should return a 500 status code that is supplied to it', () => { + const response = new ResponseModel({}, 500); + expect(response.generate().statusCode).toEqual(500); + }); + + it('should modify the status code using setCode', () => { + const response = new ResponseModel({}, 200); + response.setCode(300); + + expect(response.generate().statusCode).toEqual(300); + }); + }); + + describe('message', () => { + it('should put a message field in the body', () => { + const message = 'test 123'; + const response = new ResponseModel({}, 500, message); + expect(JSON.parse(response.generate().body)).toHaveProperty('message', message); + }); + + it('should be able to get the message using getMessage', () => { + const message = 'test 123'; + const response = new ResponseModel({}, 500, message); + expect(response.getMessage()).toEqual(message); + }); + + it('should return success message if no message is given', () => { + const response = new ResponseModel({}, 500); + expect(JSON.parse(response.generate().body).message).toEqual(DEFAULT_MESSAGE); + }); + + it('should modify the message using setMessage', () => { + const response = new ResponseModel({}, 200, 'replace-me'); + response.setMessage('test'); + + expect(JSON.parse(response.generate().body).message).toEqual('test'); + }); + }); + + describe('generate', () => { + it('should output the same from the static and instance method', () => { + const data = { a: 1, b: { c: 2 } }; + const code = 201; + const message = 'Some message'; + + const response1 = new ResponseModel(data, code, message).generate(); + const response2 = ResponseModel.generate(data, code, message); + + expect(response1).toEqual(response2); + }); + }); +}); diff --git a/tests/unit/models/SQSMessageModel.spec.ts b/tests/unit/models/SQSMessageModel.spec.ts new file mode 100644 index 00000000..3bcd60c0 --- /dev/null +++ b/tests/unit/models/SQSMessageModel.spec.ts @@ -0,0 +1,59 @@ +import { SQSMessageModel as Message } from '@/src'; + +describe('unit.models.SQSMessageModel', () => { + const messageData = { + test: 123, + }; + + const mockedMessage = { + MessageId: 'message-id-123', + ReceiptHandle: 'receipt-handle-123', + Body: JSON.stringify(messageData), + }; + + const messageModel = new Message(mockedMessage); + + describe('getMessageId', () => { + it('should return the message ID', () => { + expect(messageModel.getMessageId()).toEqual(mockedMessage.MessageId); + }); + }); + + describe('getReceiptHandle', () => { + it('should return the receipt handle', () => { + expect(messageModel.getReceiptHandle()).toEqual(mockedMessage.ReceiptHandle); + }); + }); + + describe('getBody', () => { + it('should parse and return the message body', () => { + expect(messageModel.getBody()).toEqual(messageData); + }); + }); + + describe('isForDeletion', () => { + it('should be false initially', () => { + expect(messageModel.isForDeletion()).toBe(false); + }); + }); + + describe('setForDeletion', () => { + it('should set the is-for-deletion status', () => { + messageModel.setForDeletion(true); + expect(messageModel.isForDeletion()).toBe(true); + }); + }); + + describe('getMetaData', () => { + it('should return an empty object initially', () => { + expect(messageModel.getMetaData()).toEqual({}); + }); + }); + + describe('setMetaData', () => { + it('should add a key to metadata', () => { + messageModel.setMetaData('test', 123); + expect(messageModel.getMetaData()).toEqual({ test: 123 }); + }); + }); +}); From 364ebd1a276bd16e2e6c381a2347e7029df5af8f Mon Sep 17 00:00:00 2001 From: Seb Aebischer Date: Tue, 23 Aug 2022 22:02:03 +0100 Subject: [PATCH 29/39] Test config module --- tests/unit/core/config.spec.ts | 52 ++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 tests/unit/core/config.spec.ts diff --git a/tests/unit/core/config.spec.ts b/tests/unit/core/config.spec.ts new file mode 100644 index 00000000..4b302668 --- /dev/null +++ b/tests/unit/core/config.spec.ts @@ -0,0 +1,52 @@ +import DependencyAwareClass from '@/src/core/DependencyAwareClass'; +import { LambdaWrapperConfig, mergeConfig } from '@/src/core/config'; + +class A extends DependencyAwareClass {} + +class B extends DependencyAwareClass {} + +describe('unit.core.config', () => { + describe('mergeConfig', () => { + it('should return the config if no new config is given', () => { + const a: LambdaWrapperConfig = { + dependencies: { A, B }, + }; + const b = {}; + + expect(mergeConfig(a, b)).toEqual(a); + }); + + it('should combine dependencies', () => { + const a: LambdaWrapperConfig = { + dependencies: { A }, + }; + const b: LambdaWrapperConfig = { + dependencies: { B }, + }; + + expect(mergeConfig(a, b)).toEqual({ + dependencies: { A, B }, + }); + }); + + it('should override other keys', () => { + type WithOtherKeys = { test: string; another: string; }; + + const a: LambdaWrapperConfig & WithOtherKeys = { + dependencies: {}, + test: 'initial', + another: 'values', + }; + const b: Partial & WithOtherKeys = { + test: 'overridden', + another: 'here', + }; + + expect(mergeConfig(a, b)).toEqual({ + dependencies: {}, + test: 'overridden', + another: 'here', + }); + }); + }); +}); From 2914ea3162eae96c6aaee8533bb47dc3a563d459 Mon Sep 17 00:00:00 2001 From: Seb Aebischer Date: Wed, 24 Aug 2022 10:45:52 +0100 Subject: [PATCH 30/39] Migrate LambdaWrapper --- src/core/LambdaWrapper.ts | 124 ++++++++++++++++++++++++++++++++++++-- src/index.ts | 2 +- 2 files changed, 120 insertions(+), 6 deletions(-) diff --git a/src/core/LambdaWrapper.ts b/src/core/LambdaWrapper.ts index 053d9138..dbe3e3ef 100644 --- a/src/core/LambdaWrapper.ts +++ b/src/core/LambdaWrapper.ts @@ -1,9 +1,24 @@ import Epsagon from 'epsagon'; -import { Context, Handler } from '../index'; +import { Context } from '../index'; +import ResponseModel from '../models/ResponseModel'; +import LoggerService from '../services/LoggerService'; +import RequestService from '../services/RequestService'; import DependencyInjection from './DependencyInjection'; import { LambdaWrapperConfig, mergeConfig } from './config'; +export interface WrapOptions { + /** + * Whether uncaught errors should be handled to return an HTTP 500 response + * instead of causing a function error. (default: `true`) + * + * This is what you usually want when working on an HTTP endpoint, but in + * other contexts (e.g. queue consumers) you may want AWS Lambda to report a + * failure so that the event is retried. + */ + handleUncaughtErrors?: boolean; +} + export default class LambdaWrapper { constructor(readonly config: TConfig) {} @@ -19,16 +34,53 @@ export default class LambdaWrapper(handler: (di: DependencyInjection) => Promise): Handler { + wrap(handler: (di: DependencyInjection) => Promise, options?: WrapOptions) { + const { + handleUncaughtErrors = true, + } = options || {}; + let wrapper = async (event: any, context: Context) => { const di = new DependencyInjection(this.config, event, context); + const request = di.get(RequestService); + const logger = di.get(LoggerService); + + context.callbackWaitsForEmptyEventLoop = false; - // If the event is a warmup, don't bother running the function - if (di.event.source === 'serverless-plugin-warmup') { + // if the event is a warmup, don't bother running the function + if (event.source === 'serverless-plugin-warmup') { return 'Lambda is warm!'; } - return handler(di); + // log the user's IP address silently for use in error tracing + const ipAddress = request.getIp(); + if (ipAddress) { + logger.metric('ipAddress', ipAddress, true); + } + + // add metrics with user browser information for rapid debugging + const userBrowserAndDevice = request.getUserBrowserAndDevice(); + if (userBrowserAndDevice) { + Object.entries(userBrowserAndDevice).forEach(([key, value]) => { + logger.metric(key, value, true); + }); + } + + try { + const result = await handler.call(wrapper, di); + return LambdaWrapper.handleSuccess(di, result); + } catch (error: any) { + const handled = LambdaWrapper.handleError(di, error, !handleUncaughtErrors); + + if (!handleUncaughtErrors) { + // AWS Lambda with async handler is looking for a rejection + // and not an error object directly + // and will treat resolved errors as successful + // as it will cast the error to JSON, i.e. `{}` + throw handled; + } + + return handled; + } }; // If Epsagon is enabled, wrap the instance in the Epsagon wrapper @@ -43,4 +95,66 @@ export default class LambdaWrapper= 500`. + * + * @param di + * @param error + * @param [throwError=false] + */ + static handleError(di: DependencyInjection, error: Error, throwError = false) { + const logger = di.get(LoggerService); + + const { + code = 500, + raiseOnEpsagon, + body = {}, + details = 'unknown error', + } = error as any; + + logger.metric('lambda.statusCode', code); + + if (raiseOnEpsagon || !code || code >= 500) { + logger.error(error); + } else { + logger.info(error); + } + + if (throwError) { + if (error instanceof Error) { + return error; + } + + // We want to be absolutely sure that we are returning an error, as + // Lambda sync handlers will only fail if the object is instanceof Error + return new Error(error); + } + + return ResponseModel.generate(body, code, details); + } } diff --git a/src/index.ts b/src/index.ts index e1ae2a6d..612c06e2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -27,7 +27,7 @@ export { Context, Handler } from 'aws-lambda'; export { LambdaWrapperConfig } from './core/config'; export { default as DependencyAwareClass } from './core/DependencyAwareClass'; export { default as DependencyInjection } from './core/DependencyInjection'; -export { default as LambdaWrapper } from './core/LambdaWrapper'; +export { default as LambdaWrapper, WrapOptions } from './core/LambdaWrapper'; export { default as ResponseModel, From 7877f841f651fc624859e01409eb8655451901ed Mon Sep 17 00:00:00 2001 From: Seb Aebischer Date: Wed, 24 Aug 2022 12:27:25 +0100 Subject: [PATCH 31/39] Test LambdaWrapper --- src/core/LambdaWrapper.ts | 6 +- tests/unit/core/LambdaWrapper.spec.ts | 309 ++++++++++++++++++++++++++ 2 files changed, 312 insertions(+), 3 deletions(-) create mode 100644 tests/unit/core/LambdaWrapper.spec.ts diff --git a/src/core/LambdaWrapper.ts b/src/core/LambdaWrapper.ts index dbe3e3ef..59085d62 100644 --- a/src/core/LambdaWrapper.ts +++ b/src/core/LambdaWrapper.ts @@ -131,13 +131,13 @@ export default class LambdaWrapper= 500) { logger.error(error); @@ -155,6 +155,6 @@ export default class LambdaWrapper new DependencyInjection(config, mockEvent, mockContext as Context); + +describe('unit.core.LambdaWrapper', () => { + beforeEach(() => { + // Mute Winston + jest.spyOn(process.stdout, 'write').mockImplementation(() => false); + }); + + afterEach(() => jest.resetAllMocks()); + + describe('configure', () => { + // see tests/unit/core/config.spec.ts for config merge tests + + const base = new LambdaWrapper({ + dependencies: {}, + }); + + const configured = base.configure({ + dependencies: { + LoggerService, + }, + }); + + it('should return a LambdaWrapper with the given config', () => { + expect(configured).toBeInstanceOf(LambdaWrapper); + expect(configured.config).toEqual({ + dependencies: { + LoggerService, + }, + }); + }); + + it('should not modify the original wrapper config', () => { + expect(base.config).toEqual({ + dependencies: {}, + }); + }); + }); + + describe('wrap', () => { + const lambdaWrapper = new LambdaWrapper(config); + + it('should return a wrapped handler function', async () => { + const wrapped = lambdaWrapper.wrap(jest.fn()); + + expect(typeof wrapped).toEqual('function'); + }); + + describe('the wrapped handler', () => { + it('should call the handler', async () => { + const fn = jest.fn(); + const wrapped = lambdaWrapper.wrap(fn); + + await wrapped(mockEvent, mockContext as Context); + + expect(fn).toHaveBeenCalled(); + }); + + it('should forward the return value', async () => { + const result = Math.random(); + const fn = jest.fn().mockResolvedValue(result); + const wrapped = lambdaWrapper.wrap(fn); + + expect(await wrapped(mockEvent, mockContext as Context)).toEqual(result); + }); + + it('should pass dependency injection to the handler', async () => { + const fn = jest.fn(); + const wrapped = lambdaWrapper.wrap(fn); + + await wrapped(mockEvent, mockContext as Context); + + const callArgs: any[] = fn.mock.calls[0]; + expect(callArgs).toHaveLength(1); + expect(callArgs[0]).toBeInstanceOf(DependencyInjection); + }); + + it('should provide the Lambda event via di', async () => { + const fn = jest.fn(); + const wrapped = lambdaWrapper.wrap(fn); + + await wrapped(mockEvent, mockContext as Context); + + const [di]: [DependencyInjection] = fn.mock.calls[0]; + expect(di.event).toEqual(mockEvent); + }); + + it('should provide the Lambda context via di', async () => { + const fn = jest.fn(); + const wrapped = lambdaWrapper.wrap(fn); + + await wrapped(mockEvent, mockContext as Context); + + const [di]: [DependencyInjection] = fn.mock.calls[0]; + expect(di.context).toEqual(mockContext); + }); + }); + + describe('handleUncaughtErrors = true (default)', () => { + describe('when error has no code property', () => { + it('should pass it to logger.error', async () => { + let infoStub; + let errorStub; + let metricStub; + + const lambda = lambdaWrapper.wrap((di) => { + infoStub = jest.spyOn(di.get(LoggerService), 'info'); + errorStub = jest.spyOn(di.get(LoggerService), 'error'); + metricStub = jest.spyOn(di.get(LoggerService), 'metric'); + throw new Error('Undefined error'); + }); + + await lambda(mockEvent, mockContext as Context); + + expect(infoStub).not.toHaveBeenCalled(); + expect(errorStub).toHaveBeenCalled(); + expect(metricStub).nthCalledWith(1, 'lambda.statusCode', 500); + }); + }); + + describe('when error has code 4xx', () => { + [400, 401, 403, 404, 409, 419, 421, 423, 499].forEach((errorCode) => { + it(`should call logger.info with code ${errorCode}`, async () => { + let infoStub; + let errorStub; + let metricStub; + + const lambda = lambdaWrapper.wrap((di) => { + infoStub = jest.spyOn(di.get(LoggerService), 'info'); + errorStub = jest.spyOn(di.get(LoggerService), 'error'); + metricStub = jest.spyOn(di.get(LoggerService), 'metric'); + + const error: ErrorWithCode = new Error('4xx error'); + error.code = errorCode; + throw error; + }); + + await lambda(mockEvent, mockContext as Context); + + expect(infoStub).toHaveBeenCalled(); + expect(errorStub).not.toHaveBeenCalled(); + expect(metricStub).nthCalledWith(1, 'lambda.statusCode', errorCode); + }); + }); + }); + + describe('when error has code 5xx', () => { + [500, 501, 502, 503].forEach((errorCode) => { + it(`should call logger.error with code ${errorCode}`, async () => { + let infoStub; + let errorStub; + let metricStub; + + const lambda = lambdaWrapper.wrap((di) => { + infoStub = jest.spyOn(di.get(LoggerService), 'info'); + errorStub = jest.spyOn(di.get(LoggerService), 'error'); + metricStub = jest.spyOn(di.get(LoggerService), 'metric'); + + const error: ErrorWithCode = new Error('5xx error'); + error.code = errorCode; + throw error; + }); + + await lambda(mockEvent, mockContext as Context); + + expect(infoStub).not.toHaveBeenCalled(); + expect(errorStub).toHaveBeenCalled(); + expect(metricStub).nthCalledWith(1, 'lambda.statusCode', errorCode); + }); + }); + }); + + describe('handler return value', () => { + describe('when a standard Error is thrown', () => { + it('should return status code 500 and message "unknown error"', async () => { + const lambda = lambdaWrapper.wrap(() => { + throw new Error('Some error'); + }); + + const response = await lambda(mockEvent, mockContext as Context); + + expect(response.statusCode).toEqual(500); + expect(JSON.parse(response.body)).toHaveProperty('message', 'unknown error'); + }); + }); + + describe('when a LambdaTermination is thrown', () => { + it('should return status code 403', async () => { + const lambda = lambdaWrapper.wrap(() => { + throw new LambdaTermination('internal', 403, 'external', 'some message'); + }); + + const response = await lambda(mockEvent, mockContext as Context); + + expect(response.statusCode).toEqual(403); + const body = JSON.parse(response.body); + expect(body).toHaveProperty('data', 'external'); + expect(body).toHaveProperty('message', 'some message'); + }); + }); + }); + }); + + describe('handleUncaughtErrors = false', () => { + describe('synchronous error', () => { + it('should return a promise that eventually rejects', async () => { + // note: handler function IS NOT async + const lambda = lambdaWrapper.wrap(() => { + throw new LambdaTermination('sync error message', 403, 'external'); + }, { + handleUncaughtErrors: false, + }); + + const promise = lambda(mockEvent, mockContext as Context); + + // be absolutely sure we got a rejection or the lambda will not count as failed + await expect(promise).rejects.toThrowError(LambdaTermination); + }); + }); + + describe('asynchronous error', () => { + it('should return a promise that eventually rejects', async () => { + // note: handler function IS async + const lambda = lambdaWrapper.wrap(async () => { + throw new LambdaTermination('async error message', 403, 'external'); + }, { + handleUncaughtErrors: false, + }); + + const promise = lambda(mockEvent, mockContext as Context); + + // be absolutely sure we got a rejection or the lambda will not count as failed + await expect(promise).rejects.toThrowError(LambdaTermination); + }); + }); + }); + }); + + describe('handleError', () => { + ([ + [undefined, 400, 0], + [false, 400, 0], + [true, 400, 1], + [undefined, undefined, 1], + [undefined, false, 1], + [undefined, 500, 1], + [true, 500, 1], + ] as const).forEach(([raiseOnEpsagon, code, expected]) => { + it(`should ${expected === 0 ? 'not ' : ''}call logger.error given { raiseOnEpsagon: ${raiseOnEpsagon}, code: ${code} }`, () => { + const di = getDi(); + const logger = di.get(LoggerService); + jest.spyOn(logger, 'error'); + + const error = { + name: 'Error', + message: 'error!', + raiseOnEpsagon, + code, + }; + + LambdaWrapper.handleError(di, error); + + expect(logger.error).toHaveBeenCalledTimes(expected); + }); + + it('should return an HTTP response object', () => { + const di = getDi(); + const error = { + name: 'Error', + message: 'error!', + raiseOnEpsagon, + code, + body: { key: 'value' }, + }; + + const response = LambdaWrapper.handleError(di, error); + + expect(response).toEqual({ + statusCode: code || 500, + body: JSON.stringify({ data: error.body, message: 'unknown error' }), + headers: RESPONSE_HEADERS, + }); + }); + }); + }); +}); From 1bfd96c2633e7af9c5dac515d7c77f17955bc676 Mon Sep 17 00:00:00 2001 From: Seb Aebischer Date: Wed, 24 Aug 2022 12:37:53 +0100 Subject: [PATCH 32/39] Simplify use of AWS event and context mocks --- tests/mocks/aws/index.ts | 6 +++++ tests/unit/core/DependencyInjection.spec.ts | 8 +++--- tests/unit/core/LambdaWrapper.spec.ts | 30 ++++++++++----------- tests/unit/services/RequestService.spec.ts | 10 +++---- 4 files changed, 28 insertions(+), 26 deletions(-) create mode 100644 tests/mocks/aws/index.ts diff --git a/tests/mocks/aws/index.ts b/tests/mocks/aws/index.ts new file mode 100644 index 00000000..adcaa6f5 --- /dev/null +++ b/tests/mocks/aws/index.ts @@ -0,0 +1,6 @@ +import { Context } from '@/src'; +import context from '@/tests/mocks/aws/context.json'; +import event from '@/tests/mocks/aws/event.json'; + +export const mockContext = context as Context; +export const mockEvent = event; diff --git a/tests/unit/core/DependencyInjection.spec.ts b/tests/unit/core/DependencyInjection.spec.ts index 205edd85..4bb17d0f 100644 --- a/tests/unit/core/DependencyInjection.spec.ts +++ b/tests/unit/core/DependencyInjection.spec.ts @@ -1,6 +1,5 @@ -import { Context, DependencyAwareClass, DependencyInjection } from '@/src'; -import mockContext from '@/tests/mocks/aws/context.json'; -import mockEvent from '@/tests/mocks/aws/event.json'; +import { DependencyAwareClass, DependencyInjection } from '@/src'; +import { mockContext, mockEvent } from '@/tests/mocks/aws'; class A extends DependencyAwareClass {} @@ -15,7 +14,8 @@ describe('unit.core.DependencyInjection', () => { B, }, }; - const di = new DependencyInjection(mockConfig, mockEvent, mockContext as Context); + + const di = new DependencyInjection(mockConfig, mockEvent, mockContext); describe('get', () => { it('should return an instance of A, given A', () => { diff --git a/tests/unit/core/LambdaWrapper.spec.ts b/tests/unit/core/LambdaWrapper.spec.ts index a5760d9e..e6d07344 100644 --- a/tests/unit/core/LambdaWrapper.spec.ts +++ b/tests/unit/core/LambdaWrapper.spec.ts @@ -1,7 +1,6 @@ import { RESPONSE_HEADERS } from '@/src/models/ResponseModel'; import { - Context, DependencyInjection, LambdaTermination, LambdaWrapper, @@ -9,8 +8,7 @@ import { LoggerService, RequestService, } from '@/src'; -import mockContext from '@/tests/mocks/aws/context.json'; -import mockEvent from '@/tests/mocks/aws/event.json'; +import { mockContext, mockEvent } from '@/tests/mocks/aws'; type ErrorWithCode = Error & { code?: number }; @@ -21,7 +19,7 @@ const config: LambdaWrapperConfig = { }, }; -const getDi = () => new DependencyInjection(config, mockEvent, mockContext as Context); +const getDi = () => new DependencyInjection(config, mockEvent, mockContext); describe('unit.core.LambdaWrapper', () => { beforeEach(() => { @@ -74,7 +72,7 @@ describe('unit.core.LambdaWrapper', () => { const fn = jest.fn(); const wrapped = lambdaWrapper.wrap(fn); - await wrapped(mockEvent, mockContext as Context); + await wrapped(mockEvent, mockContext); expect(fn).toHaveBeenCalled(); }); @@ -84,14 +82,14 @@ describe('unit.core.LambdaWrapper', () => { const fn = jest.fn().mockResolvedValue(result); const wrapped = lambdaWrapper.wrap(fn); - expect(await wrapped(mockEvent, mockContext as Context)).toEqual(result); + expect(await wrapped(mockEvent, mockContext)).toEqual(result); }); it('should pass dependency injection to the handler', async () => { const fn = jest.fn(); const wrapped = lambdaWrapper.wrap(fn); - await wrapped(mockEvent, mockContext as Context); + await wrapped(mockEvent, mockContext); const callArgs: any[] = fn.mock.calls[0]; expect(callArgs).toHaveLength(1); @@ -102,7 +100,7 @@ describe('unit.core.LambdaWrapper', () => { const fn = jest.fn(); const wrapped = lambdaWrapper.wrap(fn); - await wrapped(mockEvent, mockContext as Context); + await wrapped(mockEvent, mockContext); const [di]: [DependencyInjection] = fn.mock.calls[0]; expect(di.event).toEqual(mockEvent); @@ -112,7 +110,7 @@ describe('unit.core.LambdaWrapper', () => { const fn = jest.fn(); const wrapped = lambdaWrapper.wrap(fn); - await wrapped(mockEvent, mockContext as Context); + await wrapped(mockEvent, mockContext); const [di]: [DependencyInjection] = fn.mock.calls[0]; expect(di.context).toEqual(mockContext); @@ -133,7 +131,7 @@ describe('unit.core.LambdaWrapper', () => { throw new Error('Undefined error'); }); - await lambda(mockEvent, mockContext as Context); + await lambda(mockEvent, mockContext); expect(infoStub).not.toHaveBeenCalled(); expect(errorStub).toHaveBeenCalled(); @@ -158,7 +156,7 @@ describe('unit.core.LambdaWrapper', () => { throw error; }); - await lambda(mockEvent, mockContext as Context); + await lambda(mockEvent, mockContext); expect(infoStub).toHaveBeenCalled(); expect(errorStub).not.toHaveBeenCalled(); @@ -184,7 +182,7 @@ describe('unit.core.LambdaWrapper', () => { throw error; }); - await lambda(mockEvent, mockContext as Context); + await lambda(mockEvent, mockContext); expect(infoStub).not.toHaveBeenCalled(); expect(errorStub).toHaveBeenCalled(); @@ -200,7 +198,7 @@ describe('unit.core.LambdaWrapper', () => { throw new Error('Some error'); }); - const response = await lambda(mockEvent, mockContext as Context); + const response = await lambda(mockEvent, mockContext); expect(response.statusCode).toEqual(500); expect(JSON.parse(response.body)).toHaveProperty('message', 'unknown error'); @@ -213,7 +211,7 @@ describe('unit.core.LambdaWrapper', () => { throw new LambdaTermination('internal', 403, 'external', 'some message'); }); - const response = await lambda(mockEvent, mockContext as Context); + const response = await lambda(mockEvent, mockContext); expect(response.statusCode).toEqual(403); const body = JSON.parse(response.body); @@ -234,7 +232,7 @@ describe('unit.core.LambdaWrapper', () => { handleUncaughtErrors: false, }); - const promise = lambda(mockEvent, mockContext as Context); + const promise = lambda(mockEvent, mockContext); // be absolutely sure we got a rejection or the lambda will not count as failed await expect(promise).rejects.toThrowError(LambdaTermination); @@ -250,7 +248,7 @@ describe('unit.core.LambdaWrapper', () => { handleUncaughtErrors: false, }); - const promise = lambda(mockEvent, mockContext as Context); + const promise = lambda(mockEvent, mockContext); // be absolutely sure we got a rejection or the lambda will not count as failed await expect(promise).rejects.toThrowError(LambdaTermination); diff --git a/tests/unit/services/RequestService.spec.ts b/tests/unit/services/RequestService.spec.ts index ddb4a7ca..cde8ea36 100644 --- a/tests/unit/services/RequestService.spec.ts +++ b/tests/unit/services/RequestService.spec.ts @@ -6,26 +6,24 @@ import { } from '@/src/services/RequestService'; import { - Context, DependencyInjection, LoggerService, RequestService, } from '@/src'; -import mockContext from '@/tests/mocks/aws/context.json'; -import baseEvent from '@/tests/mocks/aws/event.json'; +import { mockContext, mockEvent } from '@/tests/mocks/aws'; const getEvent = (overrides = {}) => JSON.parse(JSON.stringify(({ - ...baseEvent, + ...mockEvent, ...overrides, }))); -const getRequestService = (event: any, context: any = mockContext) => { +const getRequestService = (event: any) => { const di = new DependencyInjection({ dependencies: { RequestService, LoggerService, }, - }, event, context as Context); + }, event, mockContext); return new RequestService(di); }; From fb21313c787d94341478a2bba4dc15dbd6315d1c Mon Sep 17 00:00:00 2001 From: Seb Aebischer Date: Wed, 24 Aug 2022 13:11:04 +0100 Subject: [PATCH 33/39] Mute logs via LoggerService instead of stdout --- tests/.eslintrc.yml | 1 + tests/unit/core/LambdaWrapper.spec.ts | 52 +++++++++------------- tests/unit/services/RequestService.spec.ts | 19 ++++---- 3 files changed, 32 insertions(+), 40 deletions(-) diff --git a/tests/.eslintrc.yml b/tests/.eslintrc.yml index 091b3d95..4f0ac1cc 100644 --- a/tests/.eslintrc.yml +++ b/tests/.eslintrc.yml @@ -3,3 +3,4 @@ extends: rules: max-classes-per-file: off + '@typescript-eslint/no-non-null-assertion': off diff --git a/tests/unit/core/LambdaWrapper.spec.ts b/tests/unit/core/LambdaWrapper.spec.ts index e6d07344..45ae74fd 100644 --- a/tests/unit/core/LambdaWrapper.spec.ts +++ b/tests/unit/core/LambdaWrapper.spec.ts @@ -22,9 +22,13 @@ const config: LambdaWrapperConfig = { const getDi = () => new DependencyInjection(config, mockEvent, mockContext); describe('unit.core.LambdaWrapper', () => { - beforeEach(() => { - // Mute Winston - jest.spyOn(process.stdout, 'write').mockImplementation(() => false); + beforeAll(() => { + // mute log ouptut + const noop = () => { /* do nothing */ }; + jest.spyOn(LoggerService.prototype, 'info').mockImplementation(noop); + jest.spyOn(LoggerService.prototype, 'error').mockImplementation(noop); + jest.spyOn(LoggerService.prototype, 'metric').mockImplementation(noop); + jest.spyOn(LoggerService.prototype, 'label').mockImplementation(noop); }); afterEach(() => jest.resetAllMocks()); @@ -120,36 +124,28 @@ describe('unit.core.LambdaWrapper', () => { describe('handleUncaughtErrors = true (default)', () => { describe('when error has no code property', () => { it('should pass it to logger.error', async () => { - let infoStub; - let errorStub; - let metricStub; + let logger: LoggerService; const lambda = lambdaWrapper.wrap((di) => { - infoStub = jest.spyOn(di.get(LoggerService), 'info'); - errorStub = jest.spyOn(di.get(LoggerService), 'error'); - metricStub = jest.spyOn(di.get(LoggerService), 'metric'); + logger = di.get(LoggerService); throw new Error('Undefined error'); }); await lambda(mockEvent, mockContext); - expect(infoStub).not.toHaveBeenCalled(); - expect(errorStub).toHaveBeenCalled(); - expect(metricStub).nthCalledWith(1, 'lambda.statusCode', 500); + expect(logger!.info).not.toHaveBeenCalled(); + expect(logger!.error).toHaveBeenCalled(); + expect(logger!.metric).lastCalledWith('lambda.statusCode', 500); }); }); describe('when error has code 4xx', () => { [400, 401, 403, 404, 409, 419, 421, 423, 499].forEach((errorCode) => { it(`should call logger.info with code ${errorCode}`, async () => { - let infoStub; - let errorStub; - let metricStub; + let logger: LoggerService; const lambda = lambdaWrapper.wrap((di) => { - infoStub = jest.spyOn(di.get(LoggerService), 'info'); - errorStub = jest.spyOn(di.get(LoggerService), 'error'); - metricStub = jest.spyOn(di.get(LoggerService), 'metric'); + logger = di.get(LoggerService); const error: ErrorWithCode = new Error('4xx error'); error.code = errorCode; @@ -158,9 +154,9 @@ describe('unit.core.LambdaWrapper', () => { await lambda(mockEvent, mockContext); - expect(infoStub).toHaveBeenCalled(); - expect(errorStub).not.toHaveBeenCalled(); - expect(metricStub).nthCalledWith(1, 'lambda.statusCode', errorCode); + expect(logger!.info).toHaveBeenCalled(); + expect(logger!.error).not.toHaveBeenCalled(); + expect(logger!.metric).lastCalledWith('lambda.statusCode', errorCode); }); }); }); @@ -168,14 +164,10 @@ describe('unit.core.LambdaWrapper', () => { describe('when error has code 5xx', () => { [500, 501, 502, 503].forEach((errorCode) => { it(`should call logger.error with code ${errorCode}`, async () => { - let infoStub; - let errorStub; - let metricStub; + let logger: LoggerService; const lambda = lambdaWrapper.wrap((di) => { - infoStub = jest.spyOn(di.get(LoggerService), 'info'); - errorStub = jest.spyOn(di.get(LoggerService), 'error'); - metricStub = jest.spyOn(di.get(LoggerService), 'metric'); + logger = di.get(LoggerService); const error: ErrorWithCode = new Error('5xx error'); error.code = errorCode; @@ -184,9 +176,9 @@ describe('unit.core.LambdaWrapper', () => { await lambda(mockEvent, mockContext); - expect(infoStub).not.toHaveBeenCalled(); - expect(errorStub).toHaveBeenCalled(); - expect(metricStub).nthCalledWith(1, 'lambda.statusCode', errorCode); + expect(logger!.info).not.toHaveBeenCalled(); + expect(logger!.error).toHaveBeenCalled(); + expect(logger!.metric).lastCalledWith('lambda.statusCode', errorCode); }); }); }); diff --git a/tests/unit/services/RequestService.spec.ts b/tests/unit/services/RequestService.spec.ts index cde8ea36..82889252 100644 --- a/tests/unit/services/RequestService.spec.ts +++ b/tests/unit/services/RequestService.spec.ts @@ -28,6 +28,15 @@ const getRequestService = (event: any) => { }; describe('unit.services.RequestService', () => { + beforeAll(() => { + // mute log ouptut + const noop = () => { /* do nothing */ }; + jest.spyOn(LoggerService.prototype, 'info').mockImplementation(noop); + jest.spyOn(LoggerService.prototype, 'error').mockImplementation(noop); + jest.spyOn(LoggerService.prototype, 'metric').mockImplementation(noop); + jest.spyOn(LoggerService.prototype, 'label').mockImplementation(noop); + }); + afterEach(() => jest.resetAllMocks()); HTTP_METHODS_WITHOUT_PAYLOADS.forEach((httpMethod) => { @@ -94,11 +103,6 @@ describe('unit.services.RequestService', () => { }, }; - beforeEach(() => { - // Mute Winston - jest.spyOn(process.stdout, 'write').mockImplementation(() => false); - }); - it('should resolve if there are no validation errors', async () => { const event = getEvent({ httpMethod }); event.queryStringParameters.giftaid = 123; @@ -204,11 +208,6 @@ describe('unit.services.RequestService', () => { }, }; - beforeEach(() => { - // Mute Winston - jest.spyOn(process.stdout, 'write').mockImplementation(() => false); - }); - it('should resolve if there are no validation errors', async () => { const event = getPayloadEvent({ body: 'giftaid=123' }); const request = getRequestService(event); From 1cc4a293f7a95602df847bef849e37d94493bd6c Mon Sep 17 00:00:00 2001 From: Seb Aebischer Date: Wed, 24 Aug 2022 13:20:05 +0100 Subject: [PATCH 34/39] Add tests for public class properties --- tests/unit/core/DependencyAwareClass.spec.ts | 8 ++++++++ tests/unit/core/DependencyInjection.spec.ts | 18 ++++++++++++++++++ tests/unit/core/LambdaWrapper.spec.ts | 7 +++++++ 3 files changed, 33 insertions(+) diff --git a/tests/unit/core/DependencyAwareClass.spec.ts b/tests/unit/core/DependencyAwareClass.spec.ts index de8f26ce..34fba6df 100644 --- a/tests/unit/core/DependencyAwareClass.spec.ts +++ b/tests/unit/core/DependencyAwareClass.spec.ts @@ -8,4 +8,12 @@ describe('unit.core.DependencyAwareClass', () => { expect(dep.getContainer()).toBe(di); }); }); + + describe('di', () => { + it('should expose the DependencyInjection instance', () => { + const di = new DependencyInjection({ dependencies: {} }, {}, {} as Context); + const dep = new DependencyAwareClass(di); + expect(dep.di).toBe(di); + }); + }); }); diff --git a/tests/unit/core/DependencyInjection.spec.ts b/tests/unit/core/DependencyInjection.spec.ts index 4bb17d0f..efdc8fcd 100644 --- a/tests/unit/core/DependencyInjection.spec.ts +++ b/tests/unit/core/DependencyInjection.spec.ts @@ -17,6 +17,24 @@ describe('unit.core.DependencyInjection', () => { const di = new DependencyInjection(mockConfig, mockEvent, mockContext); + describe('event', () => { + it('should expose the event', () => { + expect(di.event).toBe(mockEvent); + }); + }); + + describe('context', () => { + it('should expose the Lambda context', () => { + expect(di.context).toBe(mockContext); + }); + }); + + describe('config', () => { + it('should expose the config object', () => { + expect(di.config).toBe(mockConfig); + }); + }); + describe('get', () => { it('should return an instance of A, given A', () => { expect(di.get(A)).toBeInstanceOf(A); diff --git a/tests/unit/core/LambdaWrapper.spec.ts b/tests/unit/core/LambdaWrapper.spec.ts index 45ae74fd..fe4542ea 100644 --- a/tests/unit/core/LambdaWrapper.spec.ts +++ b/tests/unit/core/LambdaWrapper.spec.ts @@ -33,6 +33,13 @@ describe('unit.core.LambdaWrapper', () => { afterEach(() => jest.resetAllMocks()); + describe('config', () => { + it('should expose the config object', () => { + const lw = new LambdaWrapper(config); + expect(lw.config).toBe(config); + }); + }); + describe('configure', () => { // see tests/unit/core/config.spec.ts for config merge tests From f69d3fad88141f3ef529ce6549cde5618bd86d78 Mon Sep 17 00:00:00 2001 From: Seb Aebischer Date: Wed, 24 Aug 2022 14:14:33 +0100 Subject: [PATCH 35/39] Consistently import stuff in examples in docs --- docs/services/RequestService.md | 2 ++ docs/services/SQSService.md | 4 +++- docs/services/TimerService.md | 4 +++- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/services/RequestService.md b/docs/services/RequestService.md index fdbafed8..816ba93d 100644 --- a/docs/services/RequestService.md +++ b/docs/services/RequestService.md @@ -7,6 +7,8 @@ Provides access to components of the HTTP request being handled. Since Lambda Wrapper v2, the `RequestService` instance is no longer passed as an argument to your wrapped handler, and must be obtained via `di`. ```ts +import lambdaWrapper, { RequestService } from '@comicrelief/lambda-wrapper'; + lambdaWrapper.wrap(async (di) => { const request = di.get(RequestService); // get the 'name' request parameter, defaulting to 'world' if not set diff --git a/docs/services/SQSService.md b/docs/services/SQSService.md index f836af9c..e9460d07 100644 --- a/docs/services/SQSService.md +++ b/docs/services/SQSService.md @@ -7,7 +7,9 @@ SQS queues are configured inside an `sqs` key in your Lambda Wrapper config. The `queues` key maps short friendly names to the full SQS queue name. Usually we define queue names in our `serverless.yml` and provide them to the application via environment variables. ```ts -const lambdaWrapper = lw.configure({ +import lw, { WithSQSServiceConfig } from '@comicrelief/lambda-wrapper'; + +const lambdaWrapper = lw.configure({ sqs: { queues: { // add an entry for each queue mapping to its AWS name diff --git a/docs/services/TimerService.md b/docs/services/TimerService.md index 682da089..3d4a9e98 100644 --- a/docs/services/TimerService.md +++ b/docs/services/TimerService.md @@ -7,6 +7,8 @@ Timer helper that can be used to measure how long operations take. Start and stop the timer using the `start` and `stop` methods. ```ts +import lambdaWrapper, { TimerService } from '@comicrelief/lambda-wrapper'; + lambdaWrapper.wrap(async (di) => { const timer = di.get(TimerService); @@ -15,5 +17,5 @@ lambdaWrapper.wrap(async (di) => { await someLongSlowOperation(); timer.stop(timerId); // logs 'someLongSlowOperation took 12345 ms to complete' -}) +}); ``` From 7e3bf1e0361b6a0225b7cc6dae839c2870e174c4 Mon Sep 17 00:00:00 2001 From: Seb Aebischer Date: Wed, 24 Aug 2022 15:22:25 +0100 Subject: [PATCH 36/39] Update package export tests --- tests/unit/index.spec.ts | 85 +++++++++++++++++++++++++++++++++------- 1 file changed, 70 insertions(+), 15 deletions(-) diff --git a/tests/unit/index.spec.ts b/tests/unit/index.spec.ts index 8c66cce2..b2cef2a5 100644 --- a/tests/unit/index.spec.ts +++ b/tests/unit/index.spec.ts @@ -1,14 +1,19 @@ -import _DependencyAwareClass from '@/src/core/DependencyAwareClass'; -import _DependencyInjection from '@/src/core/DependencyInjection'; -import _LambdaWrapper from '@/src/core/LambdaWrapper'; -import _SQSService from '@/src/services/SQSService'; - -import lambdaWrapper, { - DependencyAwareClass, - DependencyInjection, - LambdaWrapper, - SQSService, -} from '@/src'; +import DependencyAwareClass from '@/src/core/DependencyAwareClass'; +import DependencyInjection from '@/src/core/DependencyInjection'; +import LambdaWrapper from '@/src/core/LambdaWrapper'; +import ResponseModel from '@/src/models/ResponseModel'; +import SQSMessageModel from '@/src/models/SQSMessageModel'; +import StatusModel from '@/src/models/StatusModel'; +import BaseConfigService from '@/src/services/BaseConfigService'; +import HTTPService, { COMICRELIEF_TEST_METADATA_HEADER } from '@/src/services/HTTPService'; +import LoggerService from '@/src/services/LoggerService'; +import RequestService, { REQUEST_TYPES } from '@/src/services/RequestService'; +import SQSService, { SQS_OFFLINE_MODES, SQS_PUBLISH_FAILURE_MODES } from '@/src/services/SQSService'; +import TimerService from '@/src/services/TimerService'; +import LambdaTermination from '@/src/utils/LambdaTermination'; +import PromisifiedDelay from '@/src/utils/PromisifiedDelay'; + +import lambdaWrapper, * as lib from '@/src'; describe('unit.index', () => { describe('default export', () => { @@ -25,18 +30,68 @@ describe('unit.index', () => { // these tests prevent accidental removal of exports it('should export DependencyAwareClass', () => { - expect(DependencyAwareClass).toBe(_DependencyAwareClass); + expect(lib.DependencyAwareClass).toBe(DependencyAwareClass); }); it('should export DependencyInjection', () => { - expect(DependencyInjection).toBe(_DependencyInjection); + expect(lib.DependencyInjection).toBe(DependencyInjection); }); it('should export LambdaWrapper', () => { - expect(LambdaWrapper).toBe(_LambdaWrapper); + expect(lib.LambdaWrapper).toBe(LambdaWrapper); + }); + + // models + + it('should export ResponseModel', () => { + expect(lib.ResponseModel).toBe(ResponseModel); + }); + + it('should export SQSMessageModel', () => { + expect(lib.SQSMessageModel).toBe(SQSMessageModel); + }); + + it('should export StatusModel', () => { + expect(lib.StatusModel).toBe(StatusModel); + }); + + // services + + it('should export BaseConfigService', () => { + expect(lib.BaseConfigService).toBe(BaseConfigService); + }); + + it('should export HTTPService', () => { + expect(lib.HTTPService).toBe(HTTPService); + expect(lib.COMICRELIEF_TEST_METADATA_HEADER).toBe(COMICRELIEF_TEST_METADATA_HEADER); + }); + + it('should export LoggerService', () => { + expect(lib.LoggerService).toBe(LoggerService); + }); + + it('should export RequestService', () => { + expect(lib.RequestService).toBe(RequestService); + expect(lib.REQUEST_TYPES).toBe(REQUEST_TYPES); }); it('should export SQSService', () => { - expect(SQSService).toBe(_SQSService); + expect(lib.SQSService).toBe(SQSService); + expect(lib.SQS_OFFLINE_MODES).toBe(SQS_OFFLINE_MODES); + expect(lib.SQS_PUBLISH_FAILURE_MODES).toBe(SQS_PUBLISH_FAILURE_MODES); + }); + + it('should export TimerService', () => { + expect(lib.TimerService).toBe(TimerService); + }); + + // utils + + it('should export LambdaTermination', () => { + expect(lib.LambdaTermination).toBe(LambdaTermination); + }); + + it('should export PromisifiedDelay', () => { + expect(lib.PromisifiedDelay).toBe(PromisifiedDelay); }); }); From 64bb69e4d5ea74fdcc6a9f665a2e667536cb7ab2 Mon Sep 17 00:00:00 2001 From: Seb Aebischer Date: Fri, 26 Aug 2022 09:49:22 +0100 Subject: [PATCH 37/39] Fix typo in readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index daafb392..0d7d313a 100644 --- a/README.md +++ b/README.md @@ -115,7 +115,7 @@ Now you can use it inside your handler functions and other dependencies! ```ts // src/Action/DoSomething.ts import lambdaWrapper from '../Config/LambdaWrapper'; -import MyService from '../Sevice/MyService'; +import MyService from '../Service/MyService'; export default lambdaWrapper.wrap(async (di) => { di.get(MyService).doSomething(); From 53764c8ca8cfa8ed6712fc1454c492d915886d0f Mon Sep 17 00:00:00 2001 From: Seb Aebischer Date: Fri, 26 Aug 2022 12:36:33 +0100 Subject: [PATCH 38/39] More consistency in examples --- README.md | 19 ++++++++++--------- docs/migration/v2.md | 8 ++++---- docs/services/BaseConfigService.md | 2 +- docs/services/RequestService.md | 2 +- docs/services/SQSService.md | 8 ++++++-- docs/services/TimerService.md | 2 +- 6 files changed, 23 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 0d7d313a..2bd7288a 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ yarn add @comicrelief/lambda-wrapper You can then wrap your Lambda handler functions like this: ```ts -// src/Action/Hello.ts +// src/action/Hello.ts import lambdaWrapper, { ResponseModel, RequestService, @@ -40,7 +40,7 @@ export default lambdaWrapper.wrap(async (di) => { Here we've used the default export `lambdaWrapper` which is a preconfigured instance that can be used out of the box. You'll likely want to add your own dependencies and service config using the `configure` method: ```ts -// src/Config/LambdaWrapper.ts +// src/config/LambdaWrapper.ts import lambdaWrapper from '@comicrelief/lambda-wrapper'; export default lambdaWrapper.configure({ @@ -55,7 +55,7 @@ Read the next section to see what goes inside the config object! If you want to start from scratch without the built-in dependencies, you can use the `LambdaWrapper` constructor directly. ```ts -// src/Config/LambdaWrapper.ts +// src/config/LambdaWrapper.ts import { LambdaWrapper } from '@comicrelief/lambda-wrapper'; export default new LambdaWrapper({ @@ -86,7 +86,7 @@ export default lambdaWrapper.wrap(async (di) => { To add your own dependencies, first extend `DependencyAwareClass`. ```ts -// src/Service/MyService.ts +// src/services/MyService.ts import { DependencyAwareClass } from '@comicrelief/lambda-wrapper'; export default class MyService extends DependencyAwareClass { @@ -99,9 +99,10 @@ export default class MyService extends DependencyAwareClass { Then add it to your Lambda Wrapper configuration in the `dependencies` key. ```ts -// src/Config/LambdaWrapper.ts +// src/config/LambdaWrapper.ts import lambdaWrapper from '@comicrelief/lambda-wrapper'; -import MyService from '../Service/MyService'; + +import MyService from '@/src/services/MyService'; export default lambdaWrapper.configure({ dependencies: { @@ -113,9 +114,9 @@ export default lambdaWrapper.configure({ Now you can use it inside your handler functions and other dependencies! ```ts -// src/Action/DoSomething.ts -import lambdaWrapper from '../Config/LambdaWrapper'; -import MyService from '../Service/MyService'; +// src/action/DoSomething.ts +import lambdaWrapper from '@/src/config/LambdaWrapper'; +import MyService from '@/src/services/MyService'; export default lambdaWrapper.wrap(async (di) => { di.get(MyService).doSomething(); diff --git a/docs/migration/v2.md b/docs/migration/v2.md index 44bd61e7..2690540c 100644 --- a/docs/migration/v2.md +++ b/docs/migration/v2.md @@ -17,7 +17,7 @@ Instead of this: // src/config/Configuration.js import { DEFINITIONS as CORE_DEFINITIONS } from '@comicrelief/lambda-wrapper'; -import { MyService } from '@/src/service/MyService'; +import { MyService } from '@/src/services/MyService'; export const DEFINITIONS = { ...CORE_DEFINITIONS, @@ -50,7 +50,7 @@ do this: // src/config/LambdaWrapper.ts import lw from '@comicrelief/lambda-wrapper'; -import { MyService } from '@/src/service/MyService'; +import { MyService } from '@/src/services/MyService'; export const lambdaWrapper = lw.configure({ dependencies: { @@ -88,7 +88,7 @@ export default LambdaWrapper(CONFIGURATION, (di, request, done) => { do this: ```ts -import lambdaWrapper from '@/src/config/lambda-wrapper'; +import lambdaWrapper from '@/src/config/LambdaWrapper'; export default lambdaWrapper.wrap(async (di) => { const request = di.get(RequestService); @@ -130,7 +130,7 @@ do this: import { LoggerService, RequestService } from '@comicrelief/lambda-wrapper'; import lambdaWrapper from '@/src/config/LambdaWrapper'; -import { MyService } from '@/src/service/MyService'; +import { MyService } from '@/src/services/MyService'; export default lambdaWrapper.wrap(async (di) => { const logger = di.get(LoggerService); diff --git a/docs/services/BaseConfigService.md b/docs/services/BaseConfigService.md index debe8d36..0a6cb3f1 100644 --- a/docs/services/BaseConfigService.md +++ b/docs/services/BaseConfigService.md @@ -32,7 +32,7 @@ Config is typed as `unknown` in the base class since you shouldn't trust what's Then add to your Lambda Wrapper dependencies: ```ts -// src/config/lambda-wrapper.ts +// src/config/LambdaWrapper.ts import lambdaWrapper from '@comicrelief/lambda-wrapper'; import ConfigService from '@/src/services/ConfigService'; diff --git a/docs/services/RequestService.md b/docs/services/RequestService.md index 816ba93d..eefef05d 100644 --- a/docs/services/RequestService.md +++ b/docs/services/RequestService.md @@ -9,7 +9,7 @@ Since Lambda Wrapper v2, the `RequestService` instance is no longer passed as an ```ts import lambdaWrapper, { RequestService } from '@comicrelief/lambda-wrapper'; -lambdaWrapper.wrap(async (di) => { +export default lambdaWrapper.wrap(async (di) => { const request = di.get(RequestService); // get the 'name' request parameter, defaulting to 'world' if not set const name = request.get('name', 'world'); diff --git a/docs/services/SQSService.md b/docs/services/SQSService.md index e9460d07..de10c1fa 100644 --- a/docs/services/SQSService.md +++ b/docs/services/SQSService.md @@ -7,9 +7,9 @@ SQS queues are configured inside an `sqs` key in your Lambda Wrapper config. The `queues` key maps short friendly names to the full SQS queue name. Usually we define queue names in our `serverless.yml` and provide them to the application via environment variables. ```ts -import lw, { WithSQSServiceConfig } from '@comicrelief/lambda-wrapper'; +import lambdaWrapper, { WithSQSServiceConfig } from '@comicrelief/lambda-wrapper'; -const lambdaWrapper = lw.configure({ +export default lambdaWrapper.configure({ sqs: { queues: { // add an entry for each queue mapping to its AWS name @@ -24,6 +24,10 @@ This config is optional – not every application uses SQS! You can then send messages to a queue within your Lambda handler using the `publish` method. ```ts +import { SQSService } from '@comicrelief/lambda-wrapper'; + +import lambdaWrapper from '@/src/config/LambdaWrapper'; + export default lambdaWrapper.wrap(async (di) => { const sqs = di.get(SQSService); const message = { data: 'Hello SQS!' }; diff --git a/docs/services/TimerService.md b/docs/services/TimerService.md index 3d4a9e98..9c256712 100644 --- a/docs/services/TimerService.md +++ b/docs/services/TimerService.md @@ -9,7 +9,7 @@ Start and stop the timer using the `start` and `stop` methods. ```ts import lambdaWrapper, { TimerService } from '@comicrelief/lambda-wrapper'; -lambdaWrapper.wrap(async (di) => { +export default lambdaWrapper.wrap(async (di) => { const timer = di.get(TimerService); const timerId = 'someLongSlowOperation'; From a3d12b381cedd77ed879e6b044c1f3afd35d7314 Mon Sep 17 00:00:00 2001 From: Seb Aebischer Date: Fri, 2 Dec 2022 17:20:05 +0000 Subject: [PATCH 39/39] Use `Object.values` to get list of dependency classes --- src/core/DependencyInjection.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/DependencyInjection.ts b/src/core/DependencyInjection.ts index 3f443eb7..81ff20dc 100644 --- a/src/core/DependencyInjection.ts +++ b/src/core/DependencyInjection.ts @@ -28,9 +28,9 @@ export default class DependencyInjection { readonly event: any, readonly context: Context, ) { + const classes = Object.values(config.dependencies); this.dependencies = Object.fromEntries( - Object.entries(config.dependencies) - .map(([, Constructor]) => [Constructor.name, new Constructor(this)]), + classes.map((Constructor) => [Constructor.name, new Constructor(this)]), ); this.isConstructing = false;