From bbf7096e44bcbc1a84318297dbd38c7295252cc1 Mon Sep 17 00:00:00 2001 From: James Taylor Date: Mon, 21 Jun 2021 16:53:17 +0100 Subject: [PATCH 01/59] Initial commit Signed-off-by: James Taylor --- LICENSE | 201 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 2 + 2 files changed, 203 insertions(+) create mode 100644 LICENSE create mode 100644 README.md diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..261eeb9e --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 00000000..dac2b5f7 --- /dev/null +++ b/README.md @@ -0,0 +1,2 @@ +# fabric-rest-sample +Prototype sample REST server to demonstrate good Fabric Node SDK practices From 063b21dd3faea72f9973e2e476aa1963b875f3ef Mon Sep 17 00:00:00 2001 From: James Taylor Date: Wed, 23 Jun 2021 18:35:08 +0100 Subject: [PATCH 02/59] Initial REST API skeleton Signed-off-by: James Taylor --- README.md | 1 + .../rest-api-typescript/.env.sample | 2 + .../rest-api-typescript/.eslintrc.json | 27 + .../rest-api-typescript/.gitignore | 15 + .../rest-api-typescript/.prettierrc.json | 3 + .../rest-api-typescript/README.md | 3 + .../rest-api-typescript/package-lock.json | 2129 +++++++++++++++++ .../rest-api-typescript/package.json | 43 + .../rest-api-typescript/src/index.ts | 19 + .../rest-api-typescript/src/server.ts | 89 + .../rest-api-typescript/tsconfig.json | 72 + 11 files changed, 2403 insertions(+) create mode 100644 asset-transfer-basic/rest-api-typescript/.env.sample create mode 100644 asset-transfer-basic/rest-api-typescript/.eslintrc.json create mode 100644 asset-transfer-basic/rest-api-typescript/.gitignore create mode 100644 asset-transfer-basic/rest-api-typescript/.prettierrc.json create mode 100644 asset-transfer-basic/rest-api-typescript/README.md create mode 100644 asset-transfer-basic/rest-api-typescript/package-lock.json create mode 100644 asset-transfer-basic/rest-api-typescript/package.json create mode 100644 asset-transfer-basic/rest-api-typescript/src/index.ts create mode 100644 asset-transfer-basic/rest-api-typescript/src/server.ts create mode 100644 asset-transfer-basic/rest-api-typescript/tsconfig.json diff --git a/README.md b/README.md index dac2b5f7..2059f1f5 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,3 @@ # fabric-rest-sample + Prototype sample REST server to demonstrate good Fabric Node SDK practices diff --git a/asset-transfer-basic/rest-api-typescript/.env.sample b/asset-transfer-basic/rest-api-typescript/.env.sample new file mode 100644 index 00000000..ddc7fed8 --- /dev/null +++ b/asset-transfer-basic/rest-api-typescript/.env.sample @@ -0,0 +1,2 @@ +LOG_LEVEL=info +NODE_ENV=production diff --git a/asset-transfer-basic/rest-api-typescript/.eslintrc.json b/asset-transfer-basic/rest-api-typescript/.eslintrc.json new file mode 100644 index 00000000..0eaa9dde --- /dev/null +++ b/asset-transfer-basic/rest-api-typescript/.eslintrc.json @@ -0,0 +1,27 @@ +{ + "env": { + "node": true, + "es2021": true + }, + "extends": [ + "plugin:@typescript-eslint/recommended", + "plugin:prettier/recommended" + ], + "parser": "@typescript-eslint/parser", + "parserOptions": { + "ecmaVersion": 12, + "sourceType": "module", + "project": "./tsconfig.json" + }, + "plugins": [ + "@typescript-eslint" + ], + "rules": { + "@typescript-eslint/no-unused-vars": [ + "error", + { + "argsIgnorePattern": "^_" + } + ] + } +} diff --git a/asset-transfer-basic/rest-api-typescript/.gitignore b/asset-transfer-basic/rest-api-typescript/.gitignore new file mode 100644 index 00000000..dc7270d7 --- /dev/null +++ b/asset-transfer-basic/rest-api-typescript/.gitignore @@ -0,0 +1,15 @@ +# +# SPDX-License-Identifier: Apache-2.0 +# + +.env + +# Coverage directory used by tools like istanbul +coverage + +# Dependency directories +node_modules/ +jspm_packages/ + +# Compiled TypeScript files +dist diff --git a/asset-transfer-basic/rest-api-typescript/.prettierrc.json b/asset-transfer-basic/rest-api-typescript/.prettierrc.json new file mode 100644 index 00000000..8db60caa --- /dev/null +++ b/asset-transfer-basic/rest-api-typescript/.prettierrc.json @@ -0,0 +1,3 @@ +{ + "singleQuote": true +} diff --git a/asset-transfer-basic/rest-api-typescript/README.md b/asset-transfer-basic/rest-api-typescript/README.md new file mode 100644 index 00000000..d73efbdc --- /dev/null +++ b/asset-transfer-basic/rest-api-typescript/README.md @@ -0,0 +1,3 @@ +# Asset Transfer REST API Sample + +Prototype sample REST server to demonstrate good Fabric Node SDK practices diff --git a/asset-transfer-basic/rest-api-typescript/package-lock.json b/asset-transfer-basic/rest-api-typescript/package-lock.json new file mode 100644 index 00000000..0ac2ba68 --- /dev/null +++ b/asset-transfer-basic/rest-api-typescript/package-lock.json @@ -0,0 +1,2129 @@ +{ + "name": "asset-transfer-basic", + "version": "1.0.0", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "@babel/code-frame": { + "version": "7.12.11", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.12.11.tgz", + "integrity": "sha512-Zt1yodBx1UcyiePMSkWnU4hPqhwq7hGi2nFL1LeA3EUl+q2LQx16MISgJ0+z7dnmgvP9QtIleuETGOiOH1RcIw==", + "dev": true, + "requires": { + "@babel/highlight": "^7.10.4" + } + }, + "@babel/helper-validator-identifier": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.14.5.tgz", + "integrity": "sha512-5lsetuxCLilmVGyiLEfoHBRX8UCFD+1m2x3Rj97WrW3V7H3u4RWRXA4evMjImCsin2J2YT0QaVDGf+z8ondbAg==", + "dev": true + }, + "@babel/highlight": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.14.5.tgz", + "integrity": "sha512-qf9u2WFWVV0MppaL877j2dBtQIDgmidgjGk5VIMw3OadXvYaXn66U1BFlH2t4+t3i+8PhedppRv+i40ABzd+gg==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.14.5", + "chalk": "^2.0.0", + "js-tokens": "^4.0.0" + }, + "dependencies": { + "chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + }, + "escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", + "dev": true + } + } + }, + "@eslint/eslintrc": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-0.4.2.tgz", + "integrity": "sha512-8nmGq/4ycLpIwzvhI4tNDmQztZ8sp+hI7cyG8i1nQDhkAbRzHpXPidRAHlNvCZQpJTKw5ItIpMw9RSToGF00mg==", + "dev": true, + "requires": { + "ajv": "^6.12.4", + "debug": "^4.1.1", + "espree": "^7.3.0", + "globals": "^13.9.0", + "ignore": "^4.0.6", + "import-fresh": "^3.2.1", + "js-yaml": "^3.13.1", + "minimatch": "^3.0.4", + "strip-json-comments": "^3.1.1" + }, + "dependencies": { + "debug": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz", + "integrity": "sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==", + "dev": true, + "requires": { + "ms": "2.1.2" + } + }, + "ignore": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-4.0.6.tgz", + "integrity": "sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==", + "dev": true + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + } + } + }, + "@hapi/bourne": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@hapi/bourne/-/bourne-2.0.0.tgz", + "integrity": "sha512-WEezM1FWztfbzqIUbsDzFRVMxSoLy3HugVcux6KDDtTqzPsLE8NDRHfXvev66aH1i2oOKKar3/XDjbvh/OUBdg==", + "dev": true + }, + "@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "requires": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + } + }, + "@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true + }, + "@nodelib/fs.walk": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.7.tgz", + "integrity": "sha512-BTIhocbPBSrRmHxOAJFtR18oLhxTtAFDAvL8hY1S3iU8k+E60W/YFs4jrixGzQjMpF4qPXxIQHcjVD9dz1C2QA==", + "dev": true, + "requires": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + } + }, + "@types/body-parser": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.0.tgz", + "integrity": "sha512-W98JrE0j2K78swW4ukqMleo8R7h/pFETjM2DQ90MF6XK2i4LO4W3gQ71Lt4w3bfm2EvVSyWHplECvB5sK22yFQ==", + "dev": true, + "requires": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "@types/connect": { + "version": "3.4.34", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.34.tgz", + "integrity": "sha512-ePPA/JuI+X0vb+gSWlPKOY0NdNAie/rPUqX2GUPpbZwiKTkSPhjXWuee47E4MtE54QVzGCQMQkAL6JhV2E1+cQ==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, + "@types/express": { + "version": "4.17.12", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.12.tgz", + "integrity": "sha512-pTYas6FrP15B1Oa0bkN5tQMNqOcVXa9j4FTFtO8DWI9kppKib+6NJtfTOOLcwxuuYvcX2+dVG6et1SxW/Kc17Q==", + "dev": true, + "requires": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.18", + "@types/qs": "*", + "@types/serve-static": "*" + } + }, + "@types/express-serve-static-core": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.21.tgz", + "integrity": "sha512-gwCiEZqW6f7EoR8TTEfalyEhb1zA5jQJnRngr97+3pzMaO1RKoI1w2bw07TK72renMUVWcWS5mLI6rk1NqN0nA==", + "dev": true, + "requires": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*" + } + }, + "@types/json-schema": { + "version": "7.0.7", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.7.tgz", + "integrity": "sha512-cxWFQVseBm6O9Gbw1IWb8r6OS4OhSt3hPZLkFApLjM8TEXROBuQGLAH2i2gZpcXdLBIrpXuTDhH7Vbm1iXmNGA==", + "dev": true + }, + "@types/mime": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.2.tgz", + "integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==", + "dev": true + }, + "@types/node": { + "version": "15.12.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-15.12.4.tgz", + "integrity": "sha512-zrNj1+yqYF4WskCMOHwN+w9iuD12+dGm0rQ35HLl9/Ouuq52cEtd0CH9qMgrdNmi5ejC1/V7vKEXYubB+65DkA==", + "dev": true + }, + "@types/pino": { + "version": "6.3.8", + "resolved": "https://registry.npmjs.org/@types/pino/-/pino-6.3.8.tgz", + "integrity": "sha512-E47CmRy1FNMaCN8r0d8ECQOjXen9O0p6GGsUjLfmawlxRKosZ82WP1oWVKj+ikTkMDHxWzN5BuKmplo44ynrIg==", + "dev": true, + "requires": { + "@types/node": "*", + "@types/pino-pretty": "*", + "@types/pino-std-serializers": "*", + "@types/sonic-boom": "*" + } + }, + "@types/pino-http": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/@types/pino-http/-/pino-http-5.4.1.tgz", + "integrity": "sha512-G/iRh3egjicSm6DPomAfFel0fUsuwKEd4vtLALSEohravku684VHhO3W14UibyHo7gWW0F1v4LxGR/pe27cNdA==", + "dev": true, + "requires": { + "@types/pino": "*" + } + }, + "@types/pino-pretty": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@types/pino-pretty/-/pino-pretty-4.7.0.tgz", + "integrity": "sha512-fIZ+VXf9gJoJR4tiiM7G+j/bZkPoZEfFGzA4d8tAWCTpTVyvVaBwnmdLs3wEXYpMjw8eXulrOzNCjmGHT3FgHw==", + "dev": true, + "requires": { + "@types/pino": "*" + } + }, + "@types/pino-std-serializers": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@types/pino-std-serializers/-/pino-std-serializers-2.4.1.tgz", + "integrity": "sha512-17XcksO47M24IVTVKPeAByWUd3Oez7EbIjXpSbzMPhXVzgjGtrOa49gKBwxH9hb8dKv58OelsWQ+A1G1l9S3wQ==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, + "@types/qs": { + "version": "6.9.6", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.6.tgz", + "integrity": "sha512-0/HnwIfW4ki2D8L8c9GVcG5I72s9jP5GSLVF0VIXDW00kmIpA6O33G7a8n59Tmh7Nz0WUC3rSb7PTY/sdW2JzA==", + "dev": true + }, + "@types/range-parser": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.3.tgz", + "integrity": "sha512-ewFXqrQHlFsgc09MK5jP5iR7vumV/BYayNC6PgJO2LPe8vrnNFyjQjSppfEngITi0qvfKtzFvgKymGheFM9UOA==", + "dev": true + }, + "@types/serve-static": { + "version": "1.13.9", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.13.9.tgz", + "integrity": "sha512-ZFqF6qa48XsPdjXV5Gsz0Zqmux2PerNd3a/ktL45mHpa19cuMi/cL8tcxdAx497yRh+QtYPuofjT9oWw9P7nkA==", + "dev": true, + "requires": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "@types/sonic-boom": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@types/sonic-boom/-/sonic-boom-0.7.0.tgz", + "integrity": "sha512-AfqR0fZMoUXUNwusgXKxcE9DPlHNDHQp6nKYUd4PSRpLobF5CCevSpyTEBcVZreqaWKCnGBr9KI1fHMTttoB7A==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, + "@typescript-eslint/eslint-plugin": { + "version": "4.28.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-4.28.0.tgz", + "integrity": "sha512-KcF6p3zWhf1f8xO84tuBailV5cN92vhS+VT7UJsPzGBm9VnQqfI9AsiMUFUCYHTYPg1uCCo+HyiDnpDuvkAMfQ==", + "dev": true, + "requires": { + "@typescript-eslint/experimental-utils": "4.28.0", + "@typescript-eslint/scope-manager": "4.28.0", + "debug": "^4.3.1", + "functional-red-black-tree": "^1.0.1", + "regexpp": "^3.1.0", + "semver": "^7.3.5", + "tsutils": "^3.21.0" + }, + "dependencies": { + "debug": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz", + "integrity": "sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==", + "dev": true, + "requires": { + "ms": "2.1.2" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + } + } + }, + "@typescript-eslint/experimental-utils": { + "version": "4.28.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-4.28.0.tgz", + "integrity": "sha512-9XD9s7mt3QWMk82GoyUpc/Ji03vz4T5AYlHF9DcoFNfJ/y3UAclRsfGiE2gLfXtyC+JRA3trR7cR296TEb1oiQ==", + "dev": true, + "requires": { + "@types/json-schema": "^7.0.7", + "@typescript-eslint/scope-manager": "4.28.0", + "@typescript-eslint/types": "4.28.0", + "@typescript-eslint/typescript-estree": "4.28.0", + "eslint-scope": "^5.1.1", + "eslint-utils": "^3.0.0" + } + }, + "@typescript-eslint/parser": { + "version": "4.28.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-4.28.0.tgz", + "integrity": "sha512-7x4D22oPY8fDaOCvkuXtYYTQ6mTMmkivwEzS+7iml9F9VkHGbbZ3x4fHRwxAb5KeuSkLqfnYjs46tGx2Nour4A==", + "dev": true, + "requires": { + "@typescript-eslint/scope-manager": "4.28.0", + "@typescript-eslint/types": "4.28.0", + "@typescript-eslint/typescript-estree": "4.28.0", + "debug": "^4.3.1" + }, + "dependencies": { + "debug": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz", + "integrity": "sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==", + "dev": true, + "requires": { + "ms": "2.1.2" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + } + } + }, + "@typescript-eslint/scope-manager": { + "version": "4.28.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-4.28.0.tgz", + "integrity": "sha512-eCALCeScs5P/EYjwo6se9bdjtrh8ByWjtHzOkC4Tia6QQWtQr3PHovxh3TdYTuFcurkYI4rmFsRFpucADIkseg==", + "dev": true, + "requires": { + "@typescript-eslint/types": "4.28.0", + "@typescript-eslint/visitor-keys": "4.28.0" + } + }, + "@typescript-eslint/types": { + "version": "4.28.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-4.28.0.tgz", + "integrity": "sha512-p16xMNKKoiJCVZY5PW/AfILw2xe1LfruTcfAKBj3a+wgNYP5I9ZEKNDOItoRt53p4EiPV6iRSICy8EPanG9ZVA==", + "dev": true + }, + "@typescript-eslint/typescript-estree": { + "version": "4.28.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-4.28.0.tgz", + "integrity": "sha512-m19UQTRtxMzKAm8QxfKpvh6OwQSXaW1CdZPoCaQuLwAq7VZMNuhJmZR4g5281s2ECt658sldnJfdpSZZaxUGMQ==", + "dev": true, + "requires": { + "@typescript-eslint/types": "4.28.0", + "@typescript-eslint/visitor-keys": "4.28.0", + "debug": "^4.3.1", + "globby": "^11.0.3", + "is-glob": "^4.0.1", + "semver": "^7.3.5", + "tsutils": "^3.21.0" + }, + "dependencies": { + "debug": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz", + "integrity": "sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==", + "dev": true, + "requires": { + "ms": "2.1.2" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + } + } + }, + "@typescript-eslint/visitor-keys": { + "version": "4.28.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-4.28.0.tgz", + "integrity": "sha512-PjJyTWwrlrvM5jazxYF5ZPs/nl0kHDZMVbuIcbpawVXaDPelp3+S9zpOz5RmVUfS/fD5l5+ZXNKnWhNYjPzCvw==", + "dev": true, + "requires": { + "@typescript-eslint/types": "4.28.0", + "eslint-visitor-keys": "^2.0.0" + } + }, + "accepts": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.7.tgz", + "integrity": "sha512-Il80Qs2WjYlJIBNzNkK6KYqlVMTbZLXgHx2oT0pU/fjRHyEp+PEfEPY0R3WCwAGVOtauxh1hOxNgIf5bv7dQpA==", + "requires": { + "mime-types": "~2.1.24", + "negotiator": "0.6.2" + } + }, + "acorn": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", + "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", + "dev": true + }, + "acorn-jsx": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.1.tgz", + "integrity": "sha512-K0Ptm/47OKfQRpNQ2J/oIN/3QYiK6FwW+eJbILhsdxh2WTLdl+30o8aGdTbm5JbffpFFAg/g+zi1E+jvJha5ng==", + "dev": true + }, + "ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "requires": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + } + }, + "ansi-colors": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.1.tgz", + "integrity": "sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA==", + "dev": true + }, + "ansi-regex": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz", + "integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==", + "dev": true + }, + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "requires": { + "color-convert": "^1.9.0" + } + }, + "argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "requires": { + "sprintf-js": "~1.0.2" + } + }, + "args": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/args/-/args-5.0.1.tgz", + "integrity": "sha512-1kqmFCFsPffavQFGt8OxJdIcETti99kySRUPMpOhaGjL6mRJn8HFU1OxKY5bMqfZKUwTQc1mZkAjmGYaVOHFtQ==", + "dev": true, + "requires": { + "camelcase": "5.0.0", + "chalk": "2.4.2", + "leven": "2.1.0", + "mri": "1.1.4" + }, + "dependencies": { + "chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + }, + "escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", + "dev": true + } + } + }, + "array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha1-ml9pkFGx5wczKPKgCJaLZOopVdI=" + }, + "array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true + }, + "astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "dev": true + }, + "atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==" + }, + "balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "body-parser": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.19.0.tgz", + "integrity": "sha512-dhEPs72UPbDnAQJ9ZKMNTP6ptJaionhP5cBb541nXPlW60Jepo9RV/a4fX4XWW9CuFNK22krhrj1+rgzifNCsw==", + "requires": { + "bytes": "3.1.0", + "content-type": "~1.0.4", + "debug": "2.6.9", + "depd": "~1.1.2", + "http-errors": "1.7.2", + "iconv-lite": "0.4.24", + "on-finished": "~2.3.0", + "qs": "6.7.0", + "raw-body": "2.4.0", + "type-is": "~1.6.17" + } + }, + "brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "requires": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "braces": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", + "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "dev": true, + "requires": { + "fill-range": "^7.0.1" + } + }, + "buffer-from": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz", + "integrity": "sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A==" + }, + "bytes": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.0.tgz", + "integrity": "sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg==" + }, + "callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true + }, + "camelcase": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.0.0.tgz", + "integrity": "sha512-faqwZqnWxbxn+F1d399ygeamQNy3lPp/H9H6rNrqYh4FSVCtcY+3cub1MxA8o9mDd55mM8Aghuu/kuyYA6VTsA==", + "dev": true + }, + "chalk": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.1.tgz", + "integrity": "sha512-diHzdDKxcU+bAsUboHLPEDQiw0qEe0qd7SYUn3HgcFlWgbDcfLGswOHYeGrHKzG9z6UYf01d9VFMfZxPM1xZSg==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "dependencies": { + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "requires": { + "color-convert": "^2.0.1" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, + "color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "requires": { + "color-name": "1.1.3" + } + }, + "color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=", + "dev": true + }, + "concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", + "dev": true + }, + "content-disposition": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.3.tgz", + "integrity": "sha512-ExO0774ikEObIAEV9kDo50o+79VCUdEB6n6lzKgGwupcVeRlhrj3qGAfwq8G6uBJjkqLrhT0qEYFcWng8z1z0g==", + "requires": { + "safe-buffer": "5.1.2" + } + }, + "content-type": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.4.tgz", + "integrity": "sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==" + }, + "cookie": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.0.tgz", + "integrity": "sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg==" + }, + "cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha1-4wOogrNCzD7oylE6eZmXNNqzriw=" + }, + "cross-spawn": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "dev": true, + "requires": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + } + }, + "dateformat": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.5.1.tgz", + "integrity": "sha512-OD0TZ+B7yP7ZgpJf5K2DIbj3FZvFvxgFUuaqA/V5zTjAtAAXZ1E8bktHxmAGs4x5b7PflqA9LeQ84Og7wYtF7Q==", + "dev": true + }, + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "requires": { + "ms": "2.0.0" + } + }, + "deep-is": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.3.tgz", + "integrity": "sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ=", + "dev": true + }, + "depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak=" + }, + "destroy": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.0.4.tgz", + "integrity": "sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA=" + }, + "dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "requires": { + "path-type": "^4.0.0" + } + }, + "doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "requires": { + "esutils": "^2.0.2" + } + }, + "dotenv": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-10.0.0.tgz", + "integrity": "sha512-rlBi9d8jpv9Sf1klPjNfFAuWDjKLwTIJJ/VxtoTwIR6hnZxcEOQCZg2oIL3MWBYw5GpUDKOEnND7LXTbIpQ03Q==" + }, + "ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=" + }, + "emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, + "encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=" + }, + "end-of-stream": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", + "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", + "dev": true, + "requires": { + "once": "^1.4.0" + } + }, + "enquirer": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.3.6.tgz", + "integrity": "sha512-yjNnPr315/FjS4zIsUxYguYUPP2e1NK4d7E7ZOLiyYCcbFBiTMyID+2wvm2w6+pZ/odMA7cRkjhsPbltwBOrLg==", + "dev": true, + "requires": { + "ansi-colors": "^4.1.1" + } + }, + "escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=" + }, + "escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true + }, + "eslint": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-7.29.0.tgz", + "integrity": "sha512-82G/JToB9qIy/ArBzIWG9xvvwL3R86AlCjtGw+A29OMZDqhTybz/MByORSukGxeI+YPCR4coYyITKk8BFH9nDA==", + "dev": true, + "requires": { + "@babel/code-frame": "7.12.11", + "@eslint/eslintrc": "^0.4.2", + "ajv": "^6.10.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.0.1", + "doctrine": "^3.0.0", + "enquirer": "^2.3.5", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^5.1.1", + "eslint-utils": "^2.1.0", + "eslint-visitor-keys": "^2.0.0", + "espree": "^7.3.1", + "esquery": "^1.4.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "functional-red-black-tree": "^1.0.1", + "glob-parent": "^5.1.2", + "globals": "^13.6.0", + "ignore": "^4.0.6", + "import-fresh": "^3.0.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "js-yaml": "^3.13.1", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.0.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.1", + "progress": "^2.0.0", + "regexpp": "^3.1.0", + "semver": "^7.2.1", + "strip-ansi": "^6.0.0", + "strip-json-comments": "^3.1.0", + "table": "^6.0.9", + "text-table": "^0.2.0", + "v8-compile-cache": "^2.0.3" + }, + "dependencies": { + "debug": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz", + "integrity": "sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==", + "dev": true, + "requires": { + "ms": "2.1.2" + } + }, + "eslint-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-2.1.0.tgz", + "integrity": "sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg==", + "dev": true, + "requires": { + "eslint-visitor-keys": "^1.1.0" + }, + "dependencies": { + "eslint-visitor-keys": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz", + "integrity": "sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==", + "dev": true + } + } + }, + "ignore": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-4.0.6.tgz", + "integrity": "sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==", + "dev": true + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + } + } + }, + "eslint-config-prettier": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-8.3.0.tgz", + "integrity": "sha512-BgZuLUSeKzvlL/VUjx/Yb787VQ26RU3gGjA3iiFvdsp/2bMfVIWUVP7tjxtjS0e+HP409cPlPvNkQloz8C91ew==", + "dev": true + }, + "eslint-plugin-prettier": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-3.4.0.tgz", + "integrity": "sha512-UDK6rJT6INSfcOo545jiaOwB701uAIt2/dR7WnFQoGCVl1/EMqdANBmwUaqqQ45aXprsTGzSa39LI1PyuRBxxw==", + "dev": true, + "requires": { + "prettier-linter-helpers": "^1.0.0" + } + }, + "eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "requires": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + } + }, + "eslint-utils": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-3.0.0.tgz", + "integrity": "sha512-uuQC43IGctw68pJA1RgbQS8/NP7rch6Cwd4j3ZBtgo4/8Flj4eGE7ZYSZRN3iq5pVUv6GPdW5Z1RFleo84uLDA==", + "dev": true, + "requires": { + "eslint-visitor-keys": "^2.0.0" + } + }, + "eslint-visitor-keys": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz", + "integrity": "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==", + "dev": true + }, + "espree": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-7.3.1.tgz", + "integrity": "sha512-v3JCNCE64umkFpmkFGqzVKsOT0tN1Zr+ueqLZfpV1Ob8e+CEgPWa+OxCoGH3tnhimMKIaBm4m/vaRpJ/krRz2g==", + "dev": true, + "requires": { + "acorn": "^7.4.0", + "acorn-jsx": "^5.3.1", + "eslint-visitor-keys": "^1.3.0" + }, + "dependencies": { + "eslint-visitor-keys": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz", + "integrity": "sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==", + "dev": true + } + } + }, + "esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true + }, + "esquery": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.4.0.tgz", + "integrity": "sha512-cCDispWt5vHHtwMY2YrAQ4ibFkAL8RbH5YGBnZBc90MolvvfkkQcJro/aZiAQUlQ3qgrYS6D6v8Gc5G5CQsc9w==", + "dev": true, + "requires": { + "estraverse": "^5.1.0" + }, + "dependencies": { + "estraverse": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.2.0.tgz", + "integrity": "sha512-BxbNGGNm0RyRYvUdHpIwv9IWzeM9XClbOxwoATuFdOE7ZE6wHL+HQ5T8hoPM+zHvmKzzsEqhgy0GrQ5X13afiQ==", + "dev": true + } + } + }, + "esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "requires": { + "estraverse": "^5.2.0" + }, + "dependencies": { + "estraverse": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.2.0.tgz", + "integrity": "sha512-BxbNGGNm0RyRYvUdHpIwv9IWzeM9XClbOxwoATuFdOE7ZE6wHL+HQ5T8hoPM+zHvmKzzsEqhgy0GrQ5X13afiQ==", + "dev": true + } + } + }, + "estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true + }, + "esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true + }, + "etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=" + }, + "express": { + "version": "4.17.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.17.1.tgz", + "integrity": "sha512-mHJ9O79RqluphRrcw2X/GTh3k9tVv8YcoyY4Kkh4WDMUYKRZUq0h1o0w2rrrxBqM7VoeUVqgb27xlEMXTnYt4g==", + "requires": { + "accepts": "~1.3.7", + "array-flatten": "1.1.1", + "body-parser": "1.19.0", + "content-disposition": "0.5.3", + "content-type": "~1.0.4", + "cookie": "0.4.0", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "~1.1.2", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.1.2", + "fresh": "0.5.2", + "merge-descriptors": "1.0.1", + "methods": "~1.1.2", + "on-finished": "~2.3.0", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.7", + "proxy-addr": "~2.0.5", + "qs": "6.7.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.1.2", + "send": "0.17.1", + "serve-static": "1.14.1", + "setprototypeof": "1.1.1", + "statuses": "~1.5.0", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + } + }, + "fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true + }, + "fast-diff": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.2.0.tgz", + "integrity": "sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w==", + "dev": true + }, + "fast-glob": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.5.tgz", + "integrity": "sha512-2DtFcgT68wiTTiwZ2hNdJfcHNke9XOfnwmBRWXhmeKM8rF0TGwmC/Qto3S7RoZKp5cilZbxzO5iTNTQsJ+EeDg==", + "dev": true, + "requires": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.0", + "merge2": "^1.3.0", + "micromatch": "^4.0.2", + "picomatch": "^2.2.1" + } + }, + "fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true + }, + "fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=", + "dev": true + }, + "fast-redact": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/fast-redact/-/fast-redact-3.0.1.tgz", + "integrity": "sha512-kYpn4Y/valC9MdrISg47tZOpYBNoTXKgT9GYXFpHN/jYFs+lFkPoisY+LcBODdKVMY96ATzvzsWv+ES/4Kmufw==" + }, + "fast-safe-stringify": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.0.7.tgz", + "integrity": "sha512-Utm6CdzT+6xsDk2m8S6uL8VHxNwI6Jub+e9NYTcAms28T84pTa25GJQV9j0CY0N1rM8hK4x6grpF2BQf+2qwVA==" + }, + "fast-url-parser": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/fast-url-parser/-/fast-url-parser-1.1.3.tgz", + "integrity": "sha1-9K8+qfNNiicc9YrSs3WfQx8LMY0=", + "requires": { + "punycode": "^1.3.2" + } + }, + "fastq": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.11.0.tgz", + "integrity": "sha512-7Eczs8gIPDrVzT+EksYBcupqMyxSHXXrHOLRRxU2/DicV8789MRBRR8+Hc2uWzUupOs4YS4JzBmBxjjCVBxD/g==", + "dev": true, + "requires": { + "reusify": "^1.0.4" + } + }, + "file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "requires": { + "flat-cache": "^3.0.4" + } + }, + "fill-range": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", + "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "dev": true, + "requires": { + "to-regex-range": "^5.0.1" + } + }, + "finalhandler": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz", + "integrity": "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==", + "requires": { + "debug": "2.6.9", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "on-finished": "~2.3.0", + "parseurl": "~1.3.3", + "statuses": "~1.5.0", + "unpipe": "~1.0.0" + } + }, + "flat-cache": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz", + "integrity": "sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==", + "dev": true, + "requires": { + "flatted": "^3.1.0", + "rimraf": "^3.0.2" + } + }, + "flatstr": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/flatstr/-/flatstr-1.0.12.tgz", + "integrity": "sha512-4zPxDyhCyiN2wIAtSLI6gc82/EjqZc1onI4Mz/l0pWrAlsSfYH/2ZIcU+e3oA2wDwbzIWNKwa23F8rh6+DRWkw==" + }, + "flatted": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.1.1.tgz", + "integrity": "sha512-zAoAQiudy+r5SvnSw3KJy5os/oRJYHzrzja/tBDqrZtNhUw8bt6y8OBzMWcjWr+8liV8Eb6yOhw8WZ7VFZ5ZzA==", + "dev": true + }, + "forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==" + }, + "fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=" + }, + "fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", + "dev": true + }, + "functional-red-black-tree": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", + "integrity": "sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=", + "dev": true + }, + "glob": { + "version": "7.1.7", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.7.tgz", + "integrity": "sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ==", + "dev": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "requires": { + "is-glob": "^4.0.1" + } + }, + "globals": { + "version": "13.9.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.9.0.tgz", + "integrity": "sha512-74/FduwI/JaIrr1H8e71UbDE+5x7pIPs1C2rrwC52SszOo043CsWOZEMW7o2Y58xwm9b+0RBKDxY5n2sUpEFxA==", + "dev": true, + "requires": { + "type-fest": "^0.20.2" + } + }, + "globby": { + "version": "11.0.4", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.0.4.tgz", + "integrity": "sha512-9O4MVG9ioZJ08ffbcyVYyLOJLk5JQ688pJ4eMGLpdWLHq/Wr1D9BlriLQyL0E+jbkuePVZXYFj47QM/v093wHg==", + "dev": true, + "requires": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.1.1", + "ignore": "^5.1.4", + "merge2": "^1.3.0", + "slash": "^3.0.0" + } + }, + "has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", + "dev": true + }, + "helmet": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-4.6.0.tgz", + "integrity": "sha512-HVqALKZlR95ROkrnesdhbbZJFi/rIVSoNq6f3jA/9u6MIbTsPh3xZwihjeI5+DO/2sOV6HMHooXcEOuwskHpTg==" + }, + "http-errors": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.7.2.tgz", + "integrity": "sha512-uUQBt3H/cSIVfch6i1EuPNy/YsRSOUBXTVfZ+yR7Zjez3qjBz6i9+i4zjNaoqcoFVI4lQJ5plg63TvGfRSDCRg==", + "requires": { + "depd": "~1.1.2", + "inherits": "2.0.3", + "setprototypeof": "1.1.1", + "statuses": ">= 1.5.0 < 2", + "toidentifier": "1.0.0" + } + }, + "http-status-codes": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/http-status-codes/-/http-status-codes-2.1.4.tgz", + "integrity": "sha512-MZVIsLKGVOVE1KEnldppe6Ij+vmemMuApDfjhVSLzyYP+td0bREEYyAoIw9yFePoBXManCuBqmiNP5FqJS5Xkg==" + }, + "iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "requires": { + "safer-buffer": ">= 2.1.2 < 3" + } + }, + "ignore": { + "version": "5.1.8", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.1.8.tgz", + "integrity": "sha512-BMpfD7PpiETpBl/A6S498BaIJ6Y/ABT93ETbby2fP00v4EbvPBXWEoaR1UBPKs3iR53pJY7EtZk5KACI57i1Uw==", + "dev": true + }, + "import-fresh": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", + "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", + "dev": true, + "requires": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + } + }, + "imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o=", + "dev": true + }, + "inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", + "dev": true, + "requires": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=" + }, + "ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==" + }, + "is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=", + "dev": true + }, + "is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true + }, + "is-glob": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.1.tgz", + "integrity": "sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg==", + "dev": true, + "requires": { + "is-extglob": "^2.1.1" + } + }, + "is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true + }, + "isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=", + "dev": true + }, + "jmespath": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/jmespath/-/jmespath-0.15.0.tgz", + "integrity": "sha1-o/Iiqarp+Wb10nx5ZRDigJF2Qhc=", + "dev": true + }, + "joycon": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.0.1.tgz", + "integrity": "sha512-SJcJNBg32dGgxhPtM0wQqxqV0ax9k/9TaUskGDSJkSFSQOEWWvQ3zzWdGQRIUry2j1zA5+ReH13t0Mf3StuVZA==", + "dev": true + }, + "js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true + }, + "js-yaml": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", + "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "dev": true, + "requires": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + } + }, + "json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=", + "dev": true + }, + "leven": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-2.1.0.tgz", + "integrity": "sha1-wuep93IJTe6dNCAq6KzORoeHVYA=", + "dev": true + }, + "levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "requires": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + } + }, + "lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8=", + "dev": true + }, + "lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true + }, + "lodash.truncate": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz", + "integrity": "sha1-WjUNoLERO4N+z//VgSy+WNbq4ZM=", + "dev": true + }, + "lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "requires": { + "yallist": "^4.0.0" + } + }, + "media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=" + }, + "merge-descriptors": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", + "integrity": "sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E=" + }, + "merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true + }, + "methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4=" + }, + "micromatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.4.tgz", + "integrity": "sha512-pRmzw/XUcwXGpD9aI9q/0XOwLNygjETJ8y0ao0wdqprrzDa4YnxLcz7fQRZr8voh8V10kGhABbNcHVk5wHgWwg==", + "dev": true, + "requires": { + "braces": "^3.0.1", + "picomatch": "^2.2.3" + } + }, + "mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==" + }, + "mime-db": { + "version": "1.48.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.48.0.tgz", + "integrity": "sha512-FM3QwxV+TnZYQ2aRqhlKBMHxk10lTbMt3bBkMAp54ddrNeVSfcQYOOKuGuy3Ddrm38I04If834fOUSq1yzslJQ==" + }, + "mime-types": { + "version": "2.1.31", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.31.tgz", + "integrity": "sha512-XGZnNzm3QvgKxa8dpzyhFTHmpP3l5YNusmne07VUOXxou9CqUqYa/HBy124RqtVh/O2pECas/MOcsDgpilPOPg==", + "requires": { + "mime-db": "1.48.0" + } + }, + "minimatch": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", + "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", + "dev": true, + "requires": { + "brace-expansion": "^1.1.7" + } + }, + "mri": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.1.4.tgz", + "integrity": "sha512-6y7IjGPm8AzlvoUrwAaw1tLnUBudaS3752vcd8JtrpGGQn+rXIe63LFVHm/YMwtqAuh+LJPCFdlLYPWM1nYn6w==", + "dev": true + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + }, + "natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=", + "dev": true + }, + "negotiator": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.2.tgz", + "integrity": "sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw==" + }, + "on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha1-IPEzZIGwg811M3mSoWlxqi2QaUc=", + "requires": { + "ee-first": "1.1.1" + } + }, + "once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", + "dev": true, + "requires": { + "wrappy": "1" + } + }, + "optionator": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz", + "integrity": "sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw==", + "dev": true, + "requires": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.3" + } + }, + "parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "requires": { + "callsites": "^3.0.0" + } + }, + "parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==" + }, + "path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", + "dev": true + }, + "path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true + }, + "path-to-regexp": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", + "integrity": "sha1-32BBeABfUi8V60SQ5yR6G/qmf4w=" + }, + "path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true + }, + "picomatch": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.0.tgz", + "integrity": "sha512-lY1Q/PiJGC2zOv/z391WOTD+Z02bCgsFfvxoXXf6h7kv9o+WmsmzYqrAwY63sNgOxE4xEdq0WyUnXfKeBrSvYw==", + "dev": true + }, + "pino": { + "version": "6.11.3", + "resolved": "https://registry.npmjs.org/pino/-/pino-6.11.3.tgz", + "integrity": "sha512-drPtqkkSf0ufx2gaea3TryFiBHdNIdXKf5LN0hTM82SXI4xVIve2wLwNg92e1MT6m3jASLu6VO7eGY6+mmGeyw==", + "requires": { + "fast-redact": "^3.0.0", + "fast-safe-stringify": "^2.0.7", + "flatstr": "^1.0.12", + "pino-std-serializers": "^3.1.0", + "quick-format-unescaped": "^4.0.3", + "sonic-boom": "^1.0.2" + } + }, + "pino-http": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/pino-http/-/pino-http-5.5.0.tgz", + "integrity": "sha512-ZXhWeYhUisf9oZdS54XaBTrNVzZ7p61/sw0RpwCdU1vI/qdGWvSG4QUA5qU5Y5ya47ch3kM3HTcZf/QB5SCtNw==", + "requires": { + "fast-url-parser": "^1.1.3", + "pino": "^6.0.0", + "pino-std-serializers": "^2.4.0" + }, + "dependencies": { + "pino-std-serializers": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-2.5.0.tgz", + "integrity": "sha512-wXqbqSrIhE58TdrxxlfLwU9eDhrzppQDvGhBEr1gYbzzM4KKo3Y63gSjiDXRKLVS2UOXdPNR2v+KnQgNrs+xUg==" + } + } + }, + "pino-pretty": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/pino-pretty/-/pino-pretty-5.1.0.tgz", + "integrity": "sha512-fpDU80MKP59XOWxqV8crTDjRegC2fbDsA56zTr5s1guiv6QuYHILc9x1a4+o9SNPtfmF2kQdpAZS+bIExtbELQ==", + "dev": true, + "requires": { + "@hapi/bourne": "^2.0.0", + "@types/node": "^15.3.0", + "args": "^5.0.1", + "chalk": "^4.0.0", + "dateformat": "^4.5.1", + "fast-safe-stringify": "^2.0.7", + "jmespath": "^0.15.0", + "joycon": "^3.0.0", + "pump": "^3.0.0", + "readable-stream": "^3.6.0", + "rfdc": "^1.3.0", + "split2": "^3.1.1", + "strip-json-comments": "^3.1.1" + } + }, + "pino-std-serializers": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-3.2.0.tgz", + "integrity": "sha512-EqX4pwDPrt3MuOAAUBMU0Tk5kR/YcCM5fNPEzgCO2zJ5HfX0vbiH9HbJglnyeQsN96Kznae6MWD47pZB5avTrg==" + }, + "prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true + }, + "prettier": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.3.1.tgz", + "integrity": "sha512-p+vNbgpLjif/+D+DwAZAbndtRrR0md0MwfmOVN9N+2RgyACMT+7tfaRnT+WDPkqnuVwleyuBIG2XBxKDme3hPA==", + "dev": true + }, + "prettier-linter-helpers": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz", + "integrity": "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==", + "dev": true, + "requires": { + "fast-diff": "^1.1.2" + } + }, + "progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true + }, + "proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "requires": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + } + }, + "pump": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", + "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", + "dev": true, + "requires": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "punycode": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", + "integrity": "sha1-wNWmOycYgArY4esPpSachN1BhF4=" + }, + "qs": { + "version": "6.7.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.7.0.tgz", + "integrity": "sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ==" + }, + "queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true + }, + "quick-format-unescaped": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.3.tgz", + "integrity": "sha512-MaL/oqh02mhEo5m5J2rwsVL23Iw2PEaGVHgT2vFt8AAsr0lfvQA5dpXo9TPu0rz7tSBdUPgkbam0j/fj5ZM8yg==" + }, + "range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==" + }, + "raw-body": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.4.0.tgz", + "integrity": "sha512-4Oz8DUIwdvoa5qMJelxipzi/iJIi40O5cGV1wNYp5hvZP8ZN0T+jiNkL0QepXs+EsQ9XJ8ipEDoiH70ySUJP3Q==", + "requires": { + "bytes": "3.1.0", + "http-errors": "1.7.2", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + } + }, + "readable-stream": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", + "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", + "dev": true, + "requires": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + } + }, + "regexpp": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz", + "integrity": "sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==", + "dev": true + }, + "require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true + }, + "resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true + }, + "reusify": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", + "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "dev": true + }, + "rfdc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.3.0.tgz", + "integrity": "sha512-V2hovdzFbOi77/WajaSMXk2OLm+xNIeQdMMuB7icj7bk6zi2F8GGAxigcnDFpJHbNyNcgyJDiP+8nOrY5cZGrA==", + "dev": true + }, + "rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "dev": true, + "requires": { + "glob": "^7.1.3" + } + }, + "run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "requires": { + "queue-microtask": "^1.2.2" + } + }, + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, + "safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "semver": { + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz", + "integrity": "sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==", + "dev": true, + "requires": { + "lru-cache": "^6.0.0" + } + }, + "send": { + "version": "0.17.1", + "resolved": "https://registry.npmjs.org/send/-/send-0.17.1.tgz", + "integrity": "sha512-BsVKsiGcQMFwT8UxypobUKyv7irCNRHk1T0G680vk88yf6LBByGcZJOTJCrTP2xVN6yI+XjPJcNuE3V4fT9sAg==", + "requires": { + "debug": "2.6.9", + "depd": "~1.1.2", + "destroy": "~1.0.4", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "~1.7.2", + "mime": "1.6.0", + "ms": "2.1.1", + "on-finished": "~2.3.0", + "range-parser": "~1.2.1", + "statuses": "~1.5.0" + }, + "dependencies": { + "ms": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz", + "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==" + } + } + }, + "serve-static": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.14.1.tgz", + "integrity": "sha512-JMrvUwE54emCYWlTI+hGrGv5I8dEwmco/00EvkzIIsR7MqrHonbD9pO2MOfFnpFntl7ecpZs+3mW+XbQZu9QCg==", + "requires": { + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.17.1" + } + }, + "setprototypeof": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.1.tgz", + "integrity": "sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw==" + }, + "shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "requires": { + "shebang-regex": "^3.0.0" + } + }, + "shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true + }, + "slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true + }, + "slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", + "dev": true, + "requires": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "dependencies": { + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "requires": { + "color-convert": "^2.0.1" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + } + } + }, + "sonic-boom": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-1.4.1.tgz", + "integrity": "sha512-LRHh/A8tpW7ru89lrlkU4AszXt1dbwSjVWguGrmlxE7tawVmDBlI1PILMkXAxJTwqhgsEeTHzj36D5CmHgQmNg==", + "requires": { + "atomic-sleep": "^1.0.0", + "flatstr": "^1.0.12" + } + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==" + }, + "source-map-support": { + "version": "0.5.19", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.19.tgz", + "integrity": "sha512-Wonm7zOCIJzBGQdB+thsPar0kYuCIzYvxZwlBa87yi/Mdjv7Tip2cyVbLj5o0cFPN4EVkuTwb3GDDyUx2DGnGw==", + "requires": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "split2": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/split2/-/split2-3.2.2.tgz", + "integrity": "sha512-9NThjpgZnifTkJpzTZ7Eue85S49QwpNhZTq6GRJwObb6jnLFNGB7Qm73V5HewTROPyxD0C29xqmaI68bQtV+hg==", + "dev": true, + "requires": { + "readable-stream": "^3.0.0" + } + }, + "sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=", + "dev": true + }, + "statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow=" + }, + "string-width": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.2.tgz", + "integrity": "sha512-XBJbT3N4JhVumXE0eoLU9DCjcaF92KLNqTmFCnG1pf8duUxFGwtP6AD6nkjw9a3IdiRtL3E2w3JDiE/xi3vOeA==", + "dev": true, + "requires": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.0" + } + }, + "string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "requires": { + "safe-buffer": "~5.2.0" + }, + "dependencies": { + "safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true + } + } + }, + "strip-ansi": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz", + "integrity": "sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==", + "dev": true, + "requires": { + "ansi-regex": "^5.0.0" + } + }, + "strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true + }, + "supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + }, + "table": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/table/-/table-6.7.1.tgz", + "integrity": "sha512-ZGum47Yi6KOOFDE8m223td53ath2enHcYLgOCjGr5ngu8bdIARQk6mN/wRMv4yMRcHnCSnHbCEha4sobQx5yWg==", + "dev": true, + "requires": { + "ajv": "^8.0.1", + "lodash.clonedeep": "^4.5.0", + "lodash.truncate": "^4.4.2", + "slice-ansi": "^4.0.0", + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0" + }, + "dependencies": { + "ajv": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.6.0.tgz", + "integrity": "sha512-cnUG4NSBiM4YFBxgZIj/In3/6KX+rQ2l2YPRVcvAMQGWEPKuXoPIhxzwqh31jA3IPbI4qEOp/5ILI4ynioXsGQ==", + "dev": true, + "requires": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + } + }, + "json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true + } + } + }, + "text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=", + "dev": true + }, + "to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "requires": { + "is-number": "^7.0.0" + } + }, + "toidentifier": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.0.tgz", + "integrity": "sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==" + }, + "tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true + }, + "tsutils": { + "version": "3.21.0", + "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz", + "integrity": "sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==", + "dev": true, + "requires": { + "tslib": "^1.8.1" + } + }, + "type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "requires": { + "prelude-ls": "^1.2.1" + } + }, + "type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true + }, + "type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "requires": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + } + }, + "typescript": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.3.4.tgz", + "integrity": "sha512-uauPG7XZn9F/mo+7MrsRjyvbxFpzemRjKEZXS4AK83oP2KKOJPvb+9cO/gmnv8arWZvhnjVOXz7B49m1l0e9Ew==", + "dev": true + }, + "unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw=" + }, + "uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "requires": { + "punycode": "^2.1.0" + }, + "dependencies": { + "punycode": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", + "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", + "dev": true + } + } + }, + "util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=", + "dev": true + }, + "utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=" + }, + "v8-compile-cache": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz", + "integrity": "sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA==", + "dev": true + }, + "vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=" + }, + "which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "requires": { + "isexe": "^2.0.0" + } + }, + "word-wrap": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz", + "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==", + "dev": true + }, + "wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", + "dev": true + }, + "yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true + } + } +} diff --git a/asset-transfer-basic/rest-api-typescript/package.json b/asset-transfer-basic/rest-api-typescript/package.json new file mode 100644 index 00000000..f02502e2 --- /dev/null +++ b/asset-transfer-basic/rest-api-typescript/package.json @@ -0,0 +1,43 @@ +{ + "name": "asset-transfer-basic", + "version": "1.0.0", + "description": "Asset Transfer Basic REST API implemented in TypeScript", + "main": "dist/index.js", + "dependencies": { + "dotenv": "^10.0.0", + "express": "^4.17.1", + "helmet": "^4.6.0", + "http-status-codes": "^2.1.4", + "pino": "^6.11.3", + "pino-http": "^5.5.0", + "source-map-support": "^0.5.19" + }, + "devDependencies": { + "@types/express": "^4.17.12", + "@types/node": "^15.12.4", + "@types/pino": "^6.3.8", + "@types/pino-http": "^5.4.1", + "@typescript-eslint/eslint-plugin": "^4.28.0", + "@typescript-eslint/parser": "^4.28.0", + "eslint": "^7.29.0", + "eslint-config-prettier": "^8.3.0", + "eslint-plugin-prettier": "^3.4.0", + "pino-pretty": "^5.0.2", + "prettier": "^2.3.1", + "rimraf": "^3.0.2", + "typescript": "^4.3.4" + }, + "scripts": { + "prebuild": "npm run lint", + "build": "tsc", + "clean": "rimraf ./dist", + "format": "prettier --write \"{src,test}/**/*.ts\"", + "lint": "eslint . --ext .ts", + "start": "node --require source-map-support/register ./dist", + "start:dev": "node --require source-map-support/register --require dotenv/config ./dist | pino-pretty", + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "Hyperledger", + "license": "Apache-2.0", + "private": true +} diff --git a/asset-transfer-basic/rest-api-typescript/src/index.ts b/asset-transfer-basic/rest-api-typescript/src/index.ts new file mode 100644 index 00000000..9438d4b0 --- /dev/null +++ b/asset-transfer-basic/rest-api-typescript/src/index.ts @@ -0,0 +1,19 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + */ + +import pino from 'pino'; + +import app from './server'; + +// TODO check any required env vars + +const logger = pino({ + level: process.env.LOG_LEVEL || 'info', +}); + +// Start the server +const port = Number(process.env.PORT || 3000); +app.listen(port, () => { + logger.info('Express server started on port: %d', port); +}); diff --git a/asset-transfer-basic/rest-api-typescript/src/server.ts b/asset-transfer-basic/rest-api-typescript/src/server.ts new file mode 100644 index 00000000..044a5830 --- /dev/null +++ b/asset-transfer-basic/rest-api-typescript/src/server.ts @@ -0,0 +1,89 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + */ + +import helmet from 'helmet'; +import { StatusCodes, getReasonPhrase } from 'http-status-codes'; +import express, { NextFunction, Request, Response } from 'express'; +import pino from 'pino'; +import pinoMiddleware from 'pino-http'; + +const logger = pino({ + level: process.env.LOG_LEVEL || 'info', +}); + +const app = express(); +const { BAD_REQUEST, INTERNAL_SERVER_ERROR, NOT_FOUND, OK } = StatusCodes; + +app.use( + pinoMiddleware({ + logger, + customLogLevel: function customLogLevel(res, err) { + if ( + res.statusCode >= BAD_REQUEST && + res.statusCode < INTERNAL_SERVER_ERROR + ) { + return 'warn'; + } + + if (res.statusCode >= INTERNAL_SERVER_ERROR || err) { + return 'error'; + } + + return 'debug'; + }, + }) +); + +app.use(express.json()); +app.use(express.urlencoded({ extended: true })); + +if (process.env.NODE_ENV === 'development') { + // TBC +} + +if (process.env.NODE_ENV === 'production') { + app.use(helmet()); +} + +// Health routes +app.get('/ready', (_req, res) => + res.status(OK).json({ + status: getReasonPhrase(OK), + timestamp: new Date().toISOString(), + }) +); +app.get('/live', (_req, res) => + res.status(OK).json({ + status: getReasonPhrase(OK), + timestamp: new Date().toISOString(), + }) +); + +// TODO delete me +app.get('/error', (_req, _res) => { + throw new Error('Example error'); +}); + +// TODO add asset APIs +// app.use("/api/assets", assetsRouter); + +// For everything else +app.use((_req, res) => + res.status(NOT_FOUND).json({ + status: getReasonPhrase(NOT_FOUND), + timestamp: new Date().toISOString(), + }) +); + +// Print API errors +// TBC in addition to pinoMiddleware errors? +app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => { + logger.error(err); + return res.status(INTERNAL_SERVER_ERROR).json({ + status: getReasonPhrase(INTERNAL_SERVER_ERROR), + timestamp: new Date().toISOString(), + }); +}); + +export default app; diff --git a/asset-transfer-basic/rest-api-typescript/tsconfig.json b/asset-transfer-basic/rest-api-typescript/tsconfig.json new file mode 100644 index 00000000..34391e93 --- /dev/null +++ b/asset-transfer-basic/rest-api-typescript/tsconfig.json @@ -0,0 +1,72 @@ +{ + "compilerOptions": { + /* Visit https://aka.ms/tsconfig.json to read more about this file */ + + /* Basic Options */ + // "incremental": true, /* Enable incremental compilation */ + "target": "es5", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', 'ES2021', or 'ESNEXT'. */ + "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */ + // "lib": [], /* Specify library files to be included in the compilation. */ + // "allowJs": true, /* Allow javascript files to be compiled. */ + // "checkJs": true, /* Report errors in .js files. */ + // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', 'react', 'react-jsx' or 'react-jsxdev'. */ + // "declaration": true, /* Generates corresponding '.d.ts' file. */ + // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ + "sourceMap": true, /* Generates corresponding '.map' file. */ + // "outFile": "./", /* Concatenate and emit output to single file. */ + "outDir": "./dist", /* Redirect output structure to the directory. */ + // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ + // "composite": true, /* Enable project compilation */ + // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ + // "removeComments": true, /* Do not emit comments to output. */ + // "noEmit": true, /* Do not emit outputs. */ + // "importHelpers": true, /* Import emit helpers from 'tslib'. */ + // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ + // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ + + /* Strict Type-Checking Options */ + "strict": true, /* Enable all strict type-checking options. */ + // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ + // "strictNullChecks": true, /* Enable strict null checks. */ + // "strictFunctionTypes": true, /* Enable strict checking of function types. */ + // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ + // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ + // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ + // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ + + /* Additional Checks */ + // "noUnusedLocals": true, /* Report errors on unused locals. */ + // "noUnusedParameters": true, /* Report errors on unused parameters. */ + // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ + // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ + // "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */ + // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an 'override' modifier. */ + // "noPropertyAccessFromIndexSignature": true, /* Require undeclared properties from index signatures to use element accesses. */ + + /* Module Resolution Options */ + // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ + // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ + // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ + // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ + // "typeRoots": [], /* List of folders to include type definitions from. */ + // "types": [], /* Type declaration files to be included in compilation. */ + // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ + "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ + // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ + // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ + + /* Source Map Options */ + // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ + // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ + // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ + // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ + + /* Experimental Options */ + // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ + // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ + + /* Advanced Options */ + "skipLibCheck": true, /* Skip type checking of declaration files. */ + "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ + } +} From 1f8bc889cfae2d1f710307236657730c11614226 Mon Sep 17 00:00:00 2001 From: James Taylor Date: Tue, 29 Jun 2021 14:04:49 +0100 Subject: [PATCH 03/59] Enable alwaysStrict compile option Signed-off-by: James Taylor --- asset-transfer-basic/rest-api-typescript/tsconfig.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/asset-transfer-basic/rest-api-typescript/tsconfig.json b/asset-transfer-basic/rest-api-typescript/tsconfig.json index 34391e93..c0e23f1c 100644 --- a/asset-transfer-basic/rest-api-typescript/tsconfig.json +++ b/asset-transfer-basic/rest-api-typescript/tsconfig.json @@ -32,7 +32,7 @@ // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ - // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ + "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ /* Additional Checks */ // "noUnusedLocals": true, /* Report errors on unused locals. */ From 9c98450946564cea6fc660c7ce62ac818a97808c Mon Sep 17 00:00:00 2001 From: James Taylor Date: Tue, 29 Jun 2021 13:56:05 +0100 Subject: [PATCH 04/59] Initial asset route Signed-off-by: James Taylor --- .../rest-api-typescript/.env.sample | 2 +- .../rest-api-typescript/package-lock.json | 564 +++++++++++++++++- .../rest-api-typescript/package.json | 3 + .../scripts/generateEnv.sh | 23 + .../rest-api-typescript/src/assets.router.ts | 72 +++ .../rest-api-typescript/src/config.ts | 62 ++ .../rest-api-typescript/src/index.ts | 22 +- .../rest-api-typescript/src/logger.ts | 6 + .../rest-api-typescript/src/server.ts | 170 +++--- 9 files changed, 834 insertions(+), 90 deletions(-) create mode 100755 asset-transfer-basic/rest-api-typescript/scripts/generateEnv.sh create mode 100644 asset-transfer-basic/rest-api-typescript/src/assets.router.ts create mode 100644 asset-transfer-basic/rest-api-typescript/src/config.ts create mode 100644 asset-transfer-basic/rest-api-typescript/src/logger.ts diff --git a/asset-transfer-basic/rest-api-typescript/.env.sample b/asset-transfer-basic/rest-api-typescript/.env.sample index ddc7fed8..b8e71a8b 100644 --- a/asset-transfer-basic/rest-api-typescript/.env.sample +++ b/asset-transfer-basic/rest-api-typescript/.env.sample @@ -1,2 +1,2 @@ LOG_LEVEL=info -NODE_ENV=production +PORT=3000 diff --git a/asset-transfer-basic/rest-api-typescript/package-lock.json b/asset-transfer-basic/rest-api-typescript/package-lock.json index 0ac2ba68..63c72b89 100644 --- a/asset-transfer-basic/rest-api-typescript/package-lock.json +++ b/asset-transfer-basic/rest-api-typescript/package-lock.json @@ -89,6 +89,26 @@ } } }, + "@grpc/grpc-js": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.3.4.tgz", + "integrity": "sha512-AxtZcm0mArQhY9z8T3TynCYVEaSKxNCa9mVhVwBCUnsuUEe8Zn94bPYYKVQSLt+hJJ1y0ukr3mUvtWfcATL/IQ==", + "requires": { + "@types/node": ">=12.12.47" + } + }, + "@grpc/proto-loader": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.6.3.tgz", + "integrity": "sha512-AtMWwb7kY8DdtwIQh2hC4YFM1MzZ22lMA+gjbnCYDgICt14vX2tCa59bDrEjFyOI4LvORjpvT/UhHUdKvsX8og==", + "requires": { + "@types/long": "^4.0.1", + "lodash.camelcase": "^4.3.0", + "long": "^4.0.0", + "protobufjs": "^6.10.0", + "yargs": "^16.1.1" + } + }, "@hapi/bourne": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@hapi/bourne/-/bourne-2.0.0.tgz", @@ -121,6 +141,60 @@ "fastq": "^1.6.0" } }, + "@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha1-m4sMxmPWaafY9vXQiToU00jzD78=" + }, + "@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==" + }, + "@protobufjs/codegen": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==" + }, + "@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha1-NVy8mLr61ZePntCV85diHx0Ga3A=" + }, + "@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha1-upn7WYYUr2VwDBYZ/wbUVLDYTEU=", + "requires": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha1-Xp4avctz/Ap8uLKR33jIy9l7h9E=" + }, + "@protobufjs/inquire": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha1-/yAOPnzyQp4tyvwRQIKOjMY48Ik=" + }, + "@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha1-bMKyDFya1q0NzP0hynZz2Nf79o0=" + }, + "@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha1-Cf0V8tbTq/qbZbw2ZQbWrXhG/1Q=" + }, + "@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha1-p3c2C1s5oaLlEG+OhY8v0tBgxXA=" + }, "@types/body-parser": { "version": "1.19.0", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.0.tgz", @@ -169,6 +243,11 @@ "integrity": "sha512-cxWFQVseBm6O9Gbw1IWb8r6OS4OhSt3hPZLkFApLjM8TEXROBuQGLAH2i2gZpcXdLBIrpXuTDhH7Vbm1iXmNGA==", "dev": true }, + "@types/long": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.1.tgz", + "integrity": "sha512-5tXH6Bx/kNGd3MgffdmP4dy2Z+G4eaXw0SE81Tq3BNadtnMR5/ySMzX4SLEzHJzSmPNn4HIdpQsBvXMUykr58w==" + }, "@types/mime": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.2.tgz", @@ -178,8 +257,7 @@ "@types/node": { "version": "15.12.4", "resolved": "https://registry.npmjs.org/@types/node/-/node-15.12.4.tgz", - "integrity": "sha512-zrNj1+yqYF4WskCMOHwN+w9iuD12+dGm0rQ35HLl9/Ouuq52cEtd0CH9qMgrdNmi5ejC1/V7vKEXYubB+65DkA==", - "dev": true + "integrity": "sha512-zrNj1+yqYF4WskCMOHwN+w9iuD12+dGm0rQ35HLl9/Ouuq52cEtd0CH9qMgrdNmi5ejC1/V7vKEXYubB+65DkA==" }, "@types/pino": { "version": "6.3.8", @@ -251,6 +329,11 @@ "@types/node": "*" } }, + "@types/tough-cookie": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.0.tgz", + "integrity": "sha512-I99sngh224D0M7XgW1s120zxCt3VYQ3IQsuw3P3jbq5GG4yc79+ZjyKznyOGIQrflfylLgcfekeZW/vk0yng6A==" + }, "@typescript-eslint/eslint-plugin": { "version": "4.28.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-4.28.0.tgz", @@ -426,8 +509,7 @@ "ansi-regex": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz", - "integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==", - "dev": true + "integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==" }, "ansi-styles": { "version": "3.2.1", @@ -495,17 +577,44 @@ "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", "dev": true }, + "async": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/async/-/async-1.5.2.tgz", + "integrity": "sha1-7GphrlZIDAw8skHJVhjiCJL5Zyo=" + }, "atomic-sleep": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==" }, + "axios": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.1.tgz", + "integrity": "sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA==", + "requires": { + "follow-redirects": "^1.10.0" + } + }, + "axios-cookiejar-support": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/axios-cookiejar-support/-/axios-cookiejar-support-1.0.1.tgz", + "integrity": "sha512-IZJxnAJ99XxiLqNeMOqrPbfR7fRyIfaoSLdPUf4AMQEGkH8URs0ghJK/xtqBsD+KsSr3pKl4DEQjCn834pHMig==", + "requires": { + "is-redirect": "^1.0.0", + "pify": "^5.0.0" + } + }, "balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, + "bn.js": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz", + "integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==" + }, "body-parser": { "version": "1.19.0", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.19.0.tgz", @@ -542,6 +651,11 @@ "fill-range": "^7.0.1" } }, + "brorand": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz", + "integrity": "sha1-EsJe/kCkXjwyPrhnWgoM5XsiNx8=" + }, "buffer-from": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz", @@ -552,6 +666,20 @@ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.0.tgz", "integrity": "sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg==" }, + "call-bind": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", + "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", + "requires": { + "function-bind": "^1.1.1", + "get-intrinsic": "^1.0.2" + } + }, + "callsite": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/callsite/-/callsite-1.0.0.tgz", + "integrity": "sha1-KAOY5dZkvXQDi28JBRU+borxvCA=" + }, "callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -615,6 +743,16 @@ } } }, + "cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "requires": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + }, "color-convert": { "version": "1.9.3", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", @@ -630,6 +768,11 @@ "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=", "dev": true }, + "colors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/colors/-/colors-1.0.3.tgz", + "integrity": "sha1-BDP0TYCWgP3rYO0mDxsMJi6CpAs=" + }, "concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -670,6 +813,11 @@ "which": "^2.0.1" } }, + "cycle": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/cycle/-/cycle-1.0.3.tgz", + "integrity": "sha1-IegLK+hYD5i0aPN5QwZisEbDStI=" + }, "dateformat": { "version": "4.5.1", "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.5.1.tgz", @@ -728,11 +876,31 @@ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=" }, + "elliptic": { + "version": "6.5.4", + "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.4.tgz", + "integrity": "sha512-iLhC6ULemrljPZb+QutR5TQGB+pdW6KGD5RSegS+8sorOZT+rdQFbsQFJgvN3eRqNALqJer4oQ16YvJHlU8hzQ==", + "requires": { + "bn.js": "^4.11.9", + "brorand": "^1.1.0", + "hash.js": "^1.0.0", + "hmac-drbg": "^1.0.1", + "inherits": "^2.0.4", + "minimalistic-assert": "^1.0.1", + "minimalistic-crypto-utils": "^1.0.1" + }, + "dependencies": { + "inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + } + } + }, "emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" }, "encodeurl": { "version": "1.0.2", @@ -757,6 +925,16 @@ "ansi-colors": "^4.1.1" } }, + "env-var": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/env-var/-/env-var-7.0.1.tgz", + "integrity": "sha512-w4iTR5nongmpSgIByBhEaMvuLZOQCyzv4IUbhZnYMSKo/X8tj9E2Wdn4ikzKNFi29K78e5eT64iQkpar+nIYzw==" + }, + "escalade": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", + "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==" + }, "escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", @@ -1008,6 +1186,51 @@ "vary": "~1.1.2" } }, + "eyes": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/eyes/-/eyes-0.1.8.tgz", + "integrity": "sha1-Ys8SAjTGg3hdkCNIqADvPgzCC8A=" + }, + "fabric-common": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/fabric-common/-/fabric-common-2.2.8.tgz", + "integrity": "sha512-lUOb2Sq645XcfIrtH6jMBaPiPUmFaHqMjGEK7uix1al0ITsNUUvtYD17MJfj/Pr0yhj0KjTI0FF1Ep3ZSL7kXg==", + "requires": { + "callsite": "^1.0.0", + "elliptic": "^6.5.4", + "fabric-protos": "2.2.8", + "js-sha3": "^0.8.0", + "jsrsasign": "^8.0.24", + "long": "^4.0.0", + "nconf": "^0.11.2", + "pkcs11js": "^1.0.6", + "promise-settle": "^0.3.0", + "sjcl": "^1.0.8", + "winston": "^2.4.5", + "yn": "^4.0.0" + } + }, + "fabric-network": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/fabric-network/-/fabric-network-2.2.8.tgz", + "integrity": "sha512-/kFgTtNA2jqY26HeEpti56G7dPAEef2fX3ebNfL/mAtJxA0Z0YXK3Jwd1N7wGCRRu+lriPd3a0wi7RPIgwAcCw==", + "requires": { + "fabric-common": "2.2.8", + "fabric-protos": "2.2.8", + "long": "^4.0.0", + "nano": "^9.0.3" + } + }, + "fabric-protos": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/fabric-protos/-/fabric-protos-2.2.8.tgz", + "integrity": "sha512-5e3MDLtXdsZpXs92kfTGRirIomaaQ3MaKQ59kp0y9QtYZGced4k9Donl1G3nREoBi0yy1bp45lkDnjRIOG9v+g==", + "requires": { + "@grpc/grpc-js": "^1.3.4", + "@grpc/proto-loader": "^0.6.2", + "protobufjs": "^6.11.2" + } + }, "fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -1126,6 +1349,11 @@ "integrity": "sha512-zAoAQiudy+r5SvnSw3KJy5os/oRJYHzrzja/tBDqrZtNhUw8bt6y8OBzMWcjWr+8liV8Eb6yOhw8WZ7VFZ5ZzA==", "dev": true }, + "follow-redirects": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.1.tgz", + "integrity": "sha512-HWqDgT7ZEkqRzBvc2s64vSZ/hfOceEol3ac/7tKwzuvEyWx3/4UegXh5oBOIotkGsObyk3xznnSRVADBgWSQVg==" + }, "forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -1142,12 +1370,32 @@ "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", "dev": true }, + "function-bind": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", + "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" + }, "functional-red-black-tree": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", "integrity": "sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=", "dev": true }, + "get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==" + }, + "get-intrinsic": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.1.tgz", + "integrity": "sha512-kWZrnVM42QCiEA2Ig1bG8zjoIMOgxWwYCEeNdwY6Tv/cOSeGpcoX4pXHfKUxNKVoArnrEr2e9srnAxxGIraS9Q==", + "requires": { + "function-bind": "^1.1.1", + "has": "^1.0.3", + "has-symbols": "^1.0.1" + } + }, "glob": { "version": "7.1.7", "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.7.tgz", @@ -1194,17 +1442,49 @@ "slash": "^3.0.0" } }, + "has": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", + "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", + "requires": { + "function-bind": "^1.1.1" + } + }, "has-flag": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", "dev": true }, + "has-symbols": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.2.tgz", + "integrity": "sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw==" + }, + "hash.js": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", + "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==", + "requires": { + "inherits": "^2.0.3", + "minimalistic-assert": "^1.0.1" + } + }, "helmet": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/helmet/-/helmet-4.6.0.tgz", "integrity": "sha512-HVqALKZlR95ROkrnesdhbbZJFi/rIVSoNq6f3jA/9u6MIbTsPh3xZwihjeI5+DO/2sOV6HMHooXcEOuwskHpTg==" }, + "hmac-drbg": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz", + "integrity": "sha1-0nRXAQJabHdabFRXk+1QL8DGSaE=", + "requires": { + "hash.js": "^1.0.3", + "minimalistic-assert": "^1.0.0", + "minimalistic-crypto-utils": "^1.0.1" + } + }, "http-errors": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.7.2.tgz", @@ -1267,6 +1547,11 @@ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=" }, + "ini": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-2.0.0.tgz", + "integrity": "sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA==" + }, "ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -1281,8 +1566,7 @@ "is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==" }, "is-glob": { "version": "4.0.1", @@ -1299,12 +1583,22 @@ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", "dev": true }, + "is-redirect": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-redirect/-/is-redirect-1.0.0.tgz", + "integrity": "sha1-HQPd7VO9jbDzDCbk+V02/HyH3CQ=" + }, "isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=", "dev": true }, + "isstream": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", + "integrity": "sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=" + }, "jmespath": { "version": "0.15.0", "resolved": "https://registry.npmjs.org/jmespath/-/jmespath-0.15.0.tgz", @@ -1317,6 +1611,11 @@ "integrity": "sha512-SJcJNBg32dGgxhPtM0wQqxqV0ax9k/9TaUskGDSJkSFSQOEWWvQ3zzWdGQRIUry2j1zA5+ReH13t0Mf3StuVZA==", "dev": true }, + "js-sha3": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/js-sha3/-/js-sha3-0.8.0.tgz", + "integrity": "sha512-gF1cRrHhIzNfToc802P800N8PpXS+evLLXfsVpowqmAFR9uwbi89WvXg2QspOmXL8QL86J4T1EpFu+yUkwJY3Q==" + }, "js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -1345,6 +1644,11 @@ "integrity": "sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=", "dev": true }, + "jsrsasign": { + "version": "8.0.24", + "resolved": "https://registry.npmjs.org/jsrsasign/-/jsrsasign-8.0.24.tgz", + "integrity": "sha512-u45jAyusqUpyGbFc2IbHoeE4rSkoBWQgLe/w99temHenX+GyCz4nflU5sjK7ajU1ffZTezl6le7u43Yjr/lkQg==" + }, "leven": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-2.1.0.tgz", @@ -1361,6 +1665,11 @@ "type-check": "~0.4.0" } }, + "lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha1-soqmKIorn8ZRA1x3EfZathkDMaY=" + }, "lodash.clonedeep": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", @@ -1379,6 +1688,11 @@ "integrity": "sha1-WjUNoLERO4N+z//VgSy+WNbq4ZM=", "dev": true }, + "long": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", + "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==" + }, "lru-cache": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", @@ -1437,6 +1751,16 @@ "mime-db": "1.48.0" } }, + "minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==" + }, + "minimalistic-crypto-utils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz", + "integrity": "sha1-9sAMHAsIIkblxNmd+4x8CDsrWCo=" + }, "minimatch": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", @@ -1457,17 +1781,61 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" }, + "nan": { + "version": "2.14.2", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.14.2.tgz", + "integrity": "sha512-M2ufzIiINKCuDfBSAUr1vWQ+vuVcA9kqx8JJUsbQi6yf1uGRyb7HfpdfUr5qLXf3B/t8dPvcjhKMmlfnP47EzQ==", + "optional": true + }, + "nano": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/nano/-/nano-9.0.3.tgz", + "integrity": "sha512-NFI8+6q5ihnozH6qK+BJ+ilnPfZzBhlUswaFgqUvSp2EN5eJ2BMxbzkYiBsN+waa+N95FculCdbneDmzLWfXaQ==", + "requires": { + "@types/tough-cookie": "^4.0.0", + "axios": "^0.21.1", + "axios-cookiejar-support": "^1.0.1", + "qs": "^6.9.4", + "tough-cookie": "^4.0.0" + }, + "dependencies": { + "qs": { + "version": "6.10.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.10.1.tgz", + "integrity": "sha512-M528Hph6wsSVOBiYUnGf+K/7w0hNshs/duGsNXPUCLH5XAqjEtiPGwNONLV0tBH8NoGb0mvD5JubnUTrujKDTg==", + "requires": { + "side-channel": "^1.0.4" + } + } + } + }, "natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=", "dev": true }, + "nconf": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/nconf/-/nconf-0.11.3.tgz", + "integrity": "sha512-iYsAuDS9pzjVMGIzJrGE0Vk3Eh8r/suJanRAnWGBd29rVS2XtSgzcAo5l6asV3e4hH2idVONHirg1efoBOslBg==", + "requires": { + "async": "^1.4.0", + "ini": "^2.0.0", + "secure-keys": "^1.0.0", + "yargs": "^16.1.1" + } + }, "negotiator": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.2.tgz", "integrity": "sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw==" }, + "object-inspect": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.10.3.tgz", + "integrity": "sha512-e5mCJlSH7poANfC8z8S9s9S2IN5/4Zb3aZ33f5s8YqoazCFzNLloLU8r5VCG+G7WoqLvAAZoVMcy3tp/3X0Plw==" + }, "on-finished": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", @@ -1542,6 +1910,11 @@ "integrity": "sha512-lY1Q/PiJGC2zOv/z391WOTD+Z02bCgsFfvxoXXf6h7kv9o+WmsmzYqrAwY63sNgOxE4xEdq0WyUnXfKeBrSvYw==", "dev": true }, + "pify": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-5.0.0.tgz", + "integrity": "sha512-eW/gHNMlxdSP6dmG6uJip6FXN0EQBwm2clYYd8Wul42Cwu/DK8HEftzsapcNdYe2MfLiIwZqsDk2RDEsTE79hA==" + }, "pino": { "version": "6.11.3", "resolved": "https://registry.npmjs.org/pino/-/pino-6.11.3.tgz", @@ -1598,6 +1971,15 @@ "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-3.2.0.tgz", "integrity": "sha512-EqX4pwDPrt3MuOAAUBMU0Tk5kR/YcCM5fNPEzgCO2zJ5HfX0vbiH9HbJglnyeQsN96Kznae6MWD47pZB5avTrg==" }, + "pkcs11js": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/pkcs11js/-/pkcs11js-1.2.5.tgz", + "integrity": "sha512-ZOCi2ZqKV6LprMmODsQKxgxnwGyy5nQ+nbI6QeS1M5B7gaH09xIcz8BomukrtyLHs/z3eQvvzy1SAFYXrYOG4w==", + "optional": true, + "requires": { + "nan": "^2.14.2" + } + }, "prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -1625,6 +2007,31 @@ "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", "dev": true }, + "promise-settle": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/promise-settle/-/promise-settle-0.3.0.tgz", + "integrity": "sha1-tO/VcqHrdM95T4KM00naQKCOTpY=" + }, + "protobufjs": { + "version": "6.11.2", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-6.11.2.tgz", + "integrity": "sha512-4BQJoPooKJl2G9j3XftkIXjoC9C0Av2NOrWmbLWT1vH32GcSUHjM0Arra6UfTsVyfMAuFzaLucXn1sadxJydAw==", + "requires": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/long": "^4.0.1", + "@types/node": ">=13.7.0", + "long": "^4.0.0" + } + }, "proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -1634,6 +2041,11 @@ "ipaddr.js": "1.9.1" } }, + "psl": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.8.0.tgz", + "integrity": "sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ==" + }, "pump": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", @@ -1698,6 +2110,11 @@ "integrity": "sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==", "dev": true }, + "require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha1-jGStX9MNqxyXbiNE/+f3kqam30I=" + }, "require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -1750,6 +2167,11 @@ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, + "secure-keys": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/secure-keys/-/secure-keys-1.0.0.tgz", + "integrity": "sha1-8MgtmKOxOah3aogIBQuCRDEIf8o=" + }, "semver": { "version": "7.3.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz", @@ -1817,6 +2239,21 @@ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "dev": true }, + "side-channel": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", + "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", + "requires": { + "call-bind": "^1.0.0", + "get-intrinsic": "^1.0.2", + "object-inspect": "^1.9.0" + } + }, + "sjcl": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/sjcl/-/sjcl-1.0.8.tgz", + "integrity": "sha512-LzIjEQ0S0DpIgnxMEayM1rq9aGwGRG4OnZhCdjx7glTaJtf4zRfpg87ImfjSJjoW9vKpagd82McDOwbRT5kQKQ==" + }, "slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -1898,6 +2335,11 @@ "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=", "dev": true }, + "stack-trace": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", + "integrity": "sha1-VHxws0fo0ytOEI6hoqFZ5f3eGcA=" + }, "statuses": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", @@ -1907,7 +2349,6 @@ "version": "4.2.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.2.tgz", "integrity": "sha512-XBJbT3N4JhVumXE0eoLU9DCjcaF92KLNqTmFCnG1pf8duUxFGwtP6AD6nkjw9a3IdiRtL3E2w3JDiE/xi3vOeA==", - "dev": true, "requires": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", @@ -1935,7 +2376,6 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz", "integrity": "sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==", - "dev": true, "requires": { "ansi-regex": "^5.0.0" } @@ -2009,6 +2449,23 @@ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.0.tgz", "integrity": "sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==" }, + "tough-cookie": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.0.0.tgz", + "integrity": "sha512-tHdtEpQCMrc1YLrMaqXXcj6AxhYi/xgit6mZu1+EDWUn+qhUf8wMQoFIy9NXuq23zAwtcB0t/MjACGR18pcRbg==", + "requires": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.1.2" + }, + "dependencies": { + "punycode": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", + "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==" + } + } + }, "tslib": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", @@ -2054,6 +2511,11 @@ "integrity": "sha512-uauPG7XZn9F/mo+7MrsRjyvbxFpzemRjKEZXS4AK83oP2KKOJPvb+9cO/gmnv8arWZvhnjVOXz7B49m1l0e9Ew==", "dev": true }, + "universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==" + }, "unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -2107,23 +2569,105 @@ "isexe": "^2.0.0" } }, + "winston": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/winston/-/winston-2.4.5.tgz", + "integrity": "sha512-TWoamHt5yYvsMarGlGEQE59SbJHqGsZV8/lwC+iCcGeAe0vUaOh+Lv6SYM17ouzC/a/LB1/hz/7sxFBtlu1l4A==", + "requires": { + "async": "~1.0.0", + "colors": "1.0.x", + "cycle": "1.0.x", + "eyes": "0.1.x", + "isstream": "0.1.x", + "stack-trace": "0.0.x" + }, + "dependencies": { + "async": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async/-/async-1.0.0.tgz", + "integrity": "sha1-+PwEyjoTeErenhZBr5hXjPvWR6k=" + } + } + }, "word-wrap": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz", "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==", "dev": true }, + "wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "requires": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "dependencies": { + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "requires": { + "color-convert": "^2.0.1" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + } + } + }, "wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", "dev": true }, + "y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==" + }, "yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "dev": true + }, + "yargs": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", + "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", + "requires": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + } + }, + "yargs-parser": { + "version": "20.2.9", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==" + }, + "yn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yn/-/yn-4.0.0.tgz", + "integrity": "sha512-huWiiCS4TxKc4SfgmTwW1K7JmXPPAmuXWYy4j9qjQo4+27Kni8mGhAAi1cloRWmBe2EqcLgt3IGqQoRL/MtPgg==" } } } diff --git a/asset-transfer-basic/rest-api-typescript/package.json b/asset-transfer-basic/rest-api-typescript/package.json index f02502e2..fabd9667 100644 --- a/asset-transfer-basic/rest-api-typescript/package.json +++ b/asset-transfer-basic/rest-api-typescript/package.json @@ -5,7 +5,9 @@ "main": "dist/index.js", "dependencies": { "dotenv": "^10.0.0", + "env-var": "^7.0.1", "express": "^4.17.1", + "fabric-network": "^2.2.8", "helmet": "^4.6.0", "http-status-codes": "^2.1.4", "pino": "^6.11.3", @@ -32,6 +34,7 @@ "build": "tsc", "clean": "rimraf ./dist", "format": "prettier --write \"{src,test}/**/*.ts\"", + "generateEnv": "./scripts/generateEnv.sh", "lint": "eslint . --ext .ts", "start": "node --require source-map-support/register ./dist", "start:dev": "node --require source-map-support/register --require dotenv/config ./dist | pino-pretty", diff --git a/asset-transfer-basic/rest-api-typescript/scripts/generateEnv.sh b/asset-transfer-basic/rest-api-typescript/scripts/generateEnv.sh new file mode 100755 index 00000000..637c6307 --- /dev/null +++ b/asset-transfer-basic/rest-api-typescript/scripts/generateEnv.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash + +# +# SPDX-License-Identifier: Apache-2.0 +# + +: "${TEST_NETWORK_HOME:=../..}" +: "${CONNECTION_PROFILE_FILE:=${TEST_NETWORK_HOME}/organizations/peerOrganizations/org1.example.com/connection-org1.json}" +: "${CERTIFICATE_FILE:=${TEST_NETWORK_HOME}/organizations/peerOrganizations/org1.example.com/users/Admin@org1.example.com/msp/signcerts/Admin@org1.example.com-cert.pem}" +: "${PRIVATE_KEY_FILE:=${TEST_NETWORK_HOME}/organizations/peerOrganizations/org1.example.com/users/Admin@org1.example.com/msp/keystore/priv_sk}" + +cat << ENV_END > .env +LOG_LEVEL=info + +PORT=3000 + +CONNECTION_PROFILE=$(cat ${CONNECTION_PROFILE_FILE} | jq -c .) + +CERTIFICATE="$(cat ${CERTIFICATE_FILE} | sed -e 's/$/\\n/' | tr -d '\r\n')" + +PRIVATE_KEY="$(cat ${PRIVATE_KEY_FILE} | sed -e 's/$/\\n/' | tr -d '\r\n')" + +ENV_END diff --git a/asset-transfer-basic/rest-api-typescript/src/assets.router.ts b/asset-transfer-basic/rest-api-typescript/src/assets.router.ts new file mode 100644 index 00000000..180adaa8 --- /dev/null +++ b/asset-transfer-basic/rest-api-typescript/src/assets.router.ts @@ -0,0 +1,72 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * Note: this sample is intended to work with the basic asset transfer + * chaincode which imposes some constraints on what is possible here. + * + * For example, + * - There is no validation for Asset IDs + * - There are no error codes from the chaincode + * + */ + +import { Contract } from 'fabric-network'; +import { StatusCodes, getReasonPhrase } from 'http-status-codes'; +import express, { Request, Response } from 'express'; + +import { logger } from './logger'; + +const { INTERNAL_SERVER_ERROR, NOT_FOUND, OK } = StatusCodes; + +export const assetsRouter = express.Router(); + +assetsRouter.options('/:assetId', async (req: Request, res: Response) => { + try { + const contract: Contract = req.app.get('contract'); + + const assetId = req.params.assetId; + const data = await contract.evaluateTransaction('AssetExists', assetId); + const exists = data.toString() === 'true'; + + if (exists) { + res + .status(OK) + .set({ + Allow: 'GET,OPTIONS', + }) + .json({ + status: getReasonPhrase(OK), + timestamp: new Date().toISOString(), + }); + } else { + res.status(NOT_FOUND).json({ + status: getReasonPhrase(NOT_FOUND), + timestamp: new Date().toISOString(), + }); + } + } catch (err) { + logger.error(err); + return res.status(INTERNAL_SERVER_ERROR).json({ + status: getReasonPhrase(INTERNAL_SERVER_ERROR), + timestamp: new Date().toISOString(), + }); + } +}); + +assetsRouter.get('/:assetId', async (req: Request, res: Response) => { + try { + const contract: Contract = req.app.get('contract'); + + const assetId = req.params.assetId; + const data = await contract.evaluateTransaction('ReadAsset', assetId); + const asset = JSON.parse(data.toString()); + + res.status(OK).json(asset); + } catch (err) { + logger.error(err); + return res.status(INTERNAL_SERVER_ERROR).json({ + status: getReasonPhrase(INTERNAL_SERVER_ERROR), + timestamp: new Date().toISOString(), + }); + } +}); diff --git a/asset-transfer-basic/rest-api-typescript/src/config.ts b/asset-transfer-basic/rest-api-typescript/src/config.ts new file mode 100644 index 00000000..69ac9b69 --- /dev/null +++ b/asset-transfer-basic/rest-api-typescript/src/config.ts @@ -0,0 +1,62 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as env from 'env-var'; + +export const logLevel = env + .get('LOG_LEVEL') + .default('info') + .asEnum(['fatal', 'error', 'warn', 'info', 'debug', 'trace', 'silent']); + +export const port = env + .get('PORT') + .default('3000') + .example('3000') + .asIntPositive(); + +export const asLocalHost = env + .get('AS_LOCAL_HOST') + .default('true') + .example('true') + .asBoolStrict(); + +export const identityName = 'restServerIdentity'; + +export const mspId = env + .get('MSP_ID') + .default('Org1MSP') + .example('Org1MSP') + .asString(); + +export const channelName = env + .get('CHANNEL_NAME') + .default('mychannel') + .example('mychannel') + .asString(); + +export const chaincodeName = env + .get('CHAINCODE_NAME') + .default('basic') + .example('basic') + .asString(); + +export const connectionProfile = env + .get('CONNECTION_PROFILE') + .required() + .example( + '{"name":"test-network-org1","version":"1.0.0","client":{"organization":"Org1" ... }' + ) + .asJsonObject(); + +export const certificate = env + .get('CERTIFICATE') + .required() + .example('"-----BEGIN CERTIFICATE-----\\n...\\n-----END CERTIFICATE-----\\n"') + .asString(); + +export const privateKey = env + .get('PRIVATE_KEY') + .required() + .example('"-----BEGIN PRIVATE KEY-----\\n...\\n-----END PRIVATE KEY-----\\n"') + .asString(); diff --git a/asset-transfer-basic/rest-api-typescript/src/index.ts b/asset-transfer-basic/rest-api-typescript/src/index.ts index 9438d4b0..435a8022 100644 --- a/asset-transfer-basic/rest-api-typescript/src/index.ts +++ b/asset-transfer-basic/rest-api-typescript/src/index.ts @@ -2,18 +2,16 @@ * SPDX-License-Identifier: Apache-2.0 */ -import pino from 'pino'; +import { logger } from './logger'; +import { createServer } from './server'; +import * as config from './config'; -import app from './server'; +async function main() { + const app = await createServer(); -// TODO check any required env vars + app.listen(config.port, () => { + logger.info('Express server started on port: %d', config.port); + }); +} -const logger = pino({ - level: process.env.LOG_LEVEL || 'info', -}); - -// Start the server -const port = Number(process.env.PORT || 3000); -app.listen(port, () => { - logger.info('Express server started on port: %d', port); -}); +main(); diff --git a/asset-transfer-basic/rest-api-typescript/src/logger.ts b/asset-transfer-basic/rest-api-typescript/src/logger.ts new file mode 100644 index 00000000..fe53a01e --- /dev/null +++ b/asset-transfer-basic/rest-api-typescript/src/logger.ts @@ -0,0 +1,6 @@ +import pino from 'pino'; +import * as config from './config'; + +export const logger = pino({ + level: config.logLevel, +}); diff --git a/asset-transfer-basic/rest-api-typescript/src/server.ts b/asset-transfer-basic/rest-api-typescript/src/server.ts index 044a5830..436f2c8e 100644 --- a/asset-transfer-basic/rest-api-typescript/src/server.ts +++ b/asset-transfer-basic/rest-api-typescript/src/server.ts @@ -4,86 +4,122 @@ import helmet from 'helmet'; import { StatusCodes, getReasonPhrase } from 'http-status-codes'; -import express, { NextFunction, Request, Response } from 'express'; -import pino from 'pino'; +import express, { Application, NextFunction, Request, Response } from 'express'; import pinoMiddleware from 'pino-http'; +import { Gateway, GatewayOptions, Contract, Wallets } from 'fabric-network'; -const logger = pino({ - level: process.env.LOG_LEVEL || 'info', -}); +import * as config from './config'; +import { logger } from './logger'; +import { assetsRouter } from './assets.router'; -const app = express(); const { BAD_REQUEST, INTERNAL_SERVER_ERROR, NOT_FOUND, OK } = StatusCodes; -app.use( - pinoMiddleware({ - logger, - customLogLevel: function customLogLevel(res, err) { - if ( - res.statusCode >= BAD_REQUEST && - res.statusCode < INTERNAL_SERVER_ERROR - ) { - return 'warn'; - } +export const createServer = async (): Promise => { + const app = express(); - if (res.statusCode >= INTERNAL_SERVER_ERROR || err) { - return 'error'; - } + app.use( + pinoMiddleware({ + logger, + customLogLevel: function customLogLevel(res, err) { + if ( + res.statusCode >= BAD_REQUEST && + res.statusCode < INTERNAL_SERVER_ERROR + ) { + return 'warn'; + } - return 'debug'; - }, - }) -); + if (res.statusCode >= INTERNAL_SERVER_ERROR || err) { + return 'error'; + } -app.use(express.json()); -app.use(express.urlencoded({ extended: true })); + return 'debug'; + }, + }) + ); -if (process.env.NODE_ENV === 'development') { - // TBC -} + app.use(express.json()); + app.use(express.urlencoded({ extended: true })); -if (process.env.NODE_ENV === 'production') { - app.use(helmet()); -} + if (process.env.NODE_ENV === 'development') { + // TBC + } -// Health routes -app.get('/ready', (_req, res) => - res.status(OK).json({ - status: getReasonPhrase(OK), - timestamp: new Date().toISOString(), - }) -); -app.get('/live', (_req, res) => - res.status(OK).json({ - status: getReasonPhrase(OK), - timestamp: new Date().toISOString(), - }) -); + if (process.env.NODE_ENV === 'production') { + app.use(helmet()); + } -// TODO delete me -app.get('/error', (_req, _res) => { - throw new Error('Example error'); -}); + const contract = await getContract(); + app.set('contract', contract); -// TODO add asset APIs -// app.use("/api/assets", assetsRouter); + // Health routes + app.get('/ready', (_req, res) => + res.status(OK).json({ + status: getReasonPhrase(OK), + timestamp: new Date().toISOString(), + }) + ); + app.get('/live', (_req, res) => + res.status(OK).json({ + status: getReasonPhrase(OK), + timestamp: new Date().toISOString(), + }) + ); -// For everything else -app.use((_req, res) => - res.status(NOT_FOUND).json({ - status: getReasonPhrase(NOT_FOUND), - timestamp: new Date().toISOString(), - }) -); - -// Print API errors -// TBC in addition to pinoMiddleware errors? -app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => { - logger.error(err); - return res.status(INTERNAL_SERVER_ERROR).json({ - status: getReasonPhrase(INTERNAL_SERVER_ERROR), - timestamp: new Date().toISOString(), + // TODO delete me + app.get('/error', (_req, _res) => { + throw new Error('Example error'); }); -}); -export default app; + app.use('/api/assets', assetsRouter); + + // For everything else + app.use((_req, res) => + res.status(NOT_FOUND).json({ + status: getReasonPhrase(NOT_FOUND), + timestamp: new Date().toISOString(), + }) + ); + + // Print API errors + // TBC in addition to pinoMiddleware errors? + app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => { + logger.error(err); + return res.status(INTERNAL_SERVER_ERROR).json({ + status: getReasonPhrase(INTERNAL_SERVER_ERROR), + timestamp: new Date().toISOString(), + }); + }); + + return app; +}; + +// TODO should this go in a fabric.ts file? + +const getContract = async (): Promise => { + const wallet = await Wallets.newInMemoryWallet(); + + const x509Identity = { + credentials: { + certificate: config.certificate, + privateKey: config.privateKey, + }, + mspId: config.mspId, + type: 'X.509', + }; + await wallet.put(config.identityName, x509Identity); + + const gateway = new Gateway(); + + const gatewayOpts: GatewayOptions = { + wallet, + identity: config.identityName, + discovery: { enabled: true, asLocalhost: config.asLocalHost }, + }; + + await gateway.connect(config.connectionProfile, gatewayOpts); + + const network = await gateway.getNetwork(config.channelName); + const contract = network.getContract(config.chaincodeName); + + return contract; +}; From 324f1c8683fb8297ff1ae8bb9e7bfd129ac21a1b Mon Sep 17 00:00:00 2001 From: James Taylor Date: Fri, 2 Jul 2021 12:24:13 +0100 Subject: [PATCH 05/59] Specify query handler strategy Avoid load on a single peer by specifying the PREFER_MSPID_SCOPE_ROUND_ROBIN strategy Signed-off-by: James Taylor --- .../rest-api-typescript/src/fabric.ts | 46 +++++++++++++++++++ .../rest-api-typescript/src/server.ts | 34 +------------- 2 files changed, 47 insertions(+), 33 deletions(-) create mode 100644 asset-transfer-basic/rest-api-typescript/src/fabric.ts diff --git a/asset-transfer-basic/rest-api-typescript/src/fabric.ts b/asset-transfer-basic/rest-api-typescript/src/fabric.ts new file mode 100644 index 00000000..a568b376 --- /dev/null +++ b/asset-transfer-basic/rest-api-typescript/src/fabric.ts @@ -0,0 +1,46 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + DefaultQueryHandlerStrategies, + Gateway, + GatewayOptions, + Contract, + Wallets, +} from 'fabric-network'; + +import * as config from './config'; + +export const getContract = async (): Promise => { + const wallet = await Wallets.newInMemoryWallet(); + + const x509Identity = { + credentials: { + certificate: config.certificate, + privateKey: config.privateKey, + }, + mspId: config.mspId, + type: 'X.509', + }; + await wallet.put(config.identityName, x509Identity); + + const gateway = new Gateway(); + + const connectOptions: GatewayOptions = { + wallet, + identity: config.identityName, + discovery: { enabled: true, asLocalhost: config.asLocalHost }, + queryHandlerOptions: { + timeout: 3, + strategy: DefaultQueryHandlerStrategies.PREFER_MSPID_SCOPE_ROUND_ROBIN, + }, + }; + + await gateway.connect(config.connectionProfile, connectOptions); + + const network = await gateway.getNetwork(config.channelName); + const contract = network.getContract(config.chaincodeName); + + return contract; +}; diff --git a/asset-transfer-basic/rest-api-typescript/src/server.ts b/asset-transfer-basic/rest-api-typescript/src/server.ts index 436f2c8e..19714d0e 100644 --- a/asset-transfer-basic/rest-api-typescript/src/server.ts +++ b/asset-transfer-basic/rest-api-typescript/src/server.ts @@ -6,11 +6,10 @@ import helmet from 'helmet'; import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import express, { Application, NextFunction, Request, Response } from 'express'; import pinoMiddleware from 'pino-http'; -import { Gateway, GatewayOptions, Contract, Wallets } from 'fabric-network'; -import * as config from './config'; import { logger } from './logger'; import { assetsRouter } from './assets.router'; +import { getContract } from './fabric'; const { BAD_REQUEST, INTERNAL_SERVER_ERROR, NOT_FOUND, OK } = StatusCodes; @@ -92,34 +91,3 @@ export const createServer = async (): Promise => { return app; }; - -// TODO should this go in a fabric.ts file? - -const getContract = async (): Promise => { - const wallet = await Wallets.newInMemoryWallet(); - - const x509Identity = { - credentials: { - certificate: config.certificate, - privateKey: config.privateKey, - }, - mspId: config.mspId, - type: 'X.509', - }; - await wallet.put(config.identityName, x509Identity); - - const gateway = new Gateway(); - - const gatewayOpts: GatewayOptions = { - wallet, - identity: config.identityName, - discovery: { enabled: true, asLocalhost: config.asLocalHost }, - }; - - await gateway.connect(config.connectionProfile, gatewayOpts); - - const network = await gateway.getNetwork(config.channelName); - const contract = network.getContract(config.chaincodeName); - - return contract; -}; From 3b50404763cb484e1988fd75e64cdc1457b2167b Mon Sep 17 00:00:00 2001 From: James Taylor Date: Fri, 2 Jul 2021 12:13:15 +0100 Subject: [PATCH 06/59] Initial create asset logic Signed-off-by: James Taylor --- .../rest-api-typescript/.env.sample | 21 ++ .../rest-api-typescript/package-lock.json | 108 ++++++++ .../rest-api-typescript/package.json | 4 + .../scripts/generateEnv.sh | 20 +- .../rest-api-typescript/src/assets.router.ts | 98 +++++++- .../rest-api-typescript/src/config.ts | 49 +++- .../rest-api-typescript/src/fabric.ts | 230 +++++++++++++++++- .../rest-api-typescript/src/index.ts | 14 +- .../rest-api-typescript/src/redis.ts | 16 ++ .../rest-api-typescript/src/server.ts | 2 + 10 files changed, 542 insertions(+), 20 deletions(-) create mode 100644 asset-transfer-basic/rest-api-typescript/src/redis.ts diff --git a/asset-transfer-basic/rest-api-typescript/.env.sample b/asset-transfer-basic/rest-api-typescript/.env.sample index b8e71a8b..9fe7cf46 100644 --- a/asset-transfer-basic/rest-api-typescript/.env.sample +++ b/asset-transfer-basic/rest-api-typescript/.env.sample @@ -1,2 +1,23 @@ LOG_LEVEL=info + PORT=3000 + +RETRY_DELAY=3000 + +HLF_CONNECTION_PROFILE={"name":"test-network-org1","version":"1.0.0","client":{"organization":"Org1" ... } + +HLF_CERTIFICATE="-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----\n" + +HLF_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n" + +HLF_COMMIT_TIMEOUT=3000 + +HLF_ENDORSE_TIMEOUT=30 + +REDIS_HOST=localhost + +REDIS_PORT=6379 + +#REDIS_USERNAME= + +#REDIS_PASSWORD= diff --git a/asset-transfer-basic/rest-api-typescript/package-lock.json b/asset-transfer-basic/rest-api-typescript/package-lock.json index 63c72b89..4c4d1114 100644 --- a/asset-transfer-basic/rest-api-typescript/package-lock.json +++ b/asset-transfer-basic/rest-api-typescript/package-lock.json @@ -237,6 +237,15 @@ "@types/range-parser": "*" } }, + "@types/ioredis": { + "version": "4.26.4", + "resolved": "https://registry.npmjs.org/@types/ioredis/-/ioredis-4.26.4.tgz", + "integrity": "sha512-QFbjNq7EnOGw6d1gZZt2h26OFXjx7z+eqEnbCHSrDI1OOLEgOHMKdtIajJbuCr9uO+X9kQQRe7Lz6uxqxl5XKg==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/json-schema": { "version": "7.0.7", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.7.tgz", @@ -753,6 +762,11 @@ "wrap-ansi": "^7.0.0" } }, + "cluster-key-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.0.tgz", + "integrity": "sha512-2Nii8p3RwAPiFwsnZvukotvow2rIHM+yQ6ZcBXGHdniadkYGZYiGmkHJIbZPIV9nfv7m/U1IPMVVcAhoWFeklw==" + }, "color-convert": { "version": "1.9.3", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", @@ -838,6 +852,11 @@ "integrity": "sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ=", "dev": true }, + "denque": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-1.5.0.tgz", + "integrity": "sha512-CYiCSgIF1p6EUByQPlGkKnP1M9g0ZV3qMIrqMqZqdwazygIA/YP2vrbcyl1h/WppKJTdl1F85cXIle+394iDAQ==" + }, "depd": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", @@ -1186,6 +1205,15 @@ "vary": "~1.1.2" } }, + "express-validator": { + "version": "6.12.0", + "resolved": "https://registry.npmjs.org/express-validator/-/express-validator-6.12.0.tgz", + "integrity": "sha512-lcQAdVeAO+pBbHD33nIsDsd+QPakLX08tJ82iEsXj6ezyWCfYjE9RY/g9SVq5z4G0NaIkH8039Oe4r0G92DRyA==", + "requires": { + "lodash": "^4.17.21", + "validator": "^13.5.2" + } + }, "eyes": { "version": "0.1.8", "resolved": "https://registry.npmjs.org/eyes/-/eyes-0.1.8.tgz", @@ -1552,6 +1580,38 @@ "resolved": "https://registry.npmjs.org/ini/-/ini-2.0.0.tgz", "integrity": "sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA==" }, + "ioredis": { + "version": "4.27.6", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-4.27.6.tgz", + "integrity": "sha512-6W3ZHMbpCa8ByMyC1LJGOi7P2WiOKP9B3resoZOVLDhi+6dDBOW+KNsRq3yI36Hmnb2sifCxHX+YSarTeXh48A==", + "requires": { + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.1", + "denque": "^1.1.0", + "lodash.defaults": "^4.2.0", + "lodash.flatten": "^4.4.0", + "p-map": "^2.1.0", + "redis-commands": "1.7.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "dependencies": { + "debug": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.2.tgz", + "integrity": "sha512-mOp8wKcvj7XxC78zLgw/ZA+6TSgkoE2C/ienthhRD298T7UNwAg9diBpLRxC0mOezLl4B0xV7M0cCO6P/O0Xhw==", + "requires": { + "ms": "2.1.2" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + } + } + }, "ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -1665,6 +1725,11 @@ "type-check": "~0.4.0" } }, + "lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + }, "lodash.camelcase": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", @@ -1676,6 +1741,16 @@ "integrity": "sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8=", "dev": true }, + "lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha1-0JF4cW/+pN3p5ft7N/bwgCJ0WAw=" + }, + "lodash.flatten": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", + "integrity": "sha1-8xwiIlqWMtK7+OSt2+8kCqdlph8=" + }, "lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -1867,6 +1942,11 @@ "word-wrap": "^1.2.3" } }, + "p-map": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-2.1.0.tgz", + "integrity": "sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==" + }, "parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -2104,6 +2184,24 @@ "util-deprecate": "^1.0.1" } }, + "redis-commands": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/redis-commands/-/redis-commands-1.7.0.tgz", + "integrity": "sha512-nJWqw3bTFy21hX/CPKHth6sfhZbdiHP6bTawSgQBlKOVRG7EZkfHbbHwQJnrE4vsQf0CMNE+3gJ4Fmm16vdVlQ==" + }, + "redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha1-62LSrbFeTq9GEMBK/hUpOEJQq60=" + }, + "redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha1-tm2CjNyv5rS4pCin3vTGvKwxyLQ=", + "requires": { + "redis-errors": "^1.0.0" + } + }, "regexpp": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz", @@ -2340,6 +2438,11 @@ "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", "integrity": "sha1-VHxws0fo0ytOEI6hoqFZ5f3eGcA=" }, + "standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==" + }, "statuses": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", @@ -2555,6 +2658,11 @@ "integrity": "sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA==", "dev": true }, + "validator": { + "version": "13.6.0", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.6.0.tgz", + "integrity": "sha512-gVgKbdbHgtxpRyR8K0O6oFZPhhB5tT1jeEHZR0Znr9Svg03U0+r9DXWMrnRAB+HtCStDQKlaIZm42tVsVjqtjg==" + }, "vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", diff --git a/asset-transfer-basic/rest-api-typescript/package.json b/asset-transfer-basic/rest-api-typescript/package.json index fabd9667..cfee92bd 100644 --- a/asset-transfer-basic/rest-api-typescript/package.json +++ b/asset-transfer-basic/rest-api-typescript/package.json @@ -7,15 +7,18 @@ "dotenv": "^10.0.0", "env-var": "^7.0.1", "express": "^4.17.1", + "express-validator": "^6.12.0", "fabric-network": "^2.2.8", "helmet": "^4.6.0", "http-status-codes": "^2.1.4", + "ioredis": "^4.27.6", "pino": "^6.11.3", "pino-http": "^5.5.0", "source-map-support": "^0.5.19" }, "devDependencies": { "@types/express": "^4.17.12", + "@types/ioredis": "^4.26.4", "@types/node": "^15.12.4", "@types/pino": "^6.3.8", "@types/pino-http": "^5.4.1", @@ -38,6 +41,7 @@ "lint": "eslint . --ext .ts", "start": "node --require source-map-support/register ./dist", "start:dev": "node --require source-map-support/register --require dotenv/config ./dist | pino-pretty", + "start:redis": "docker run -p 6379:6379 --name fabric-sample-redis -d redis", "test": "echo \"Error: no test specified\" && exit 1" }, "author": "Hyperledger", diff --git a/asset-transfer-basic/rest-api-typescript/scripts/generateEnv.sh b/asset-transfer-basic/rest-api-typescript/scripts/generateEnv.sh index 637c6307..fa30cbdc 100755 --- a/asset-transfer-basic/rest-api-typescript/scripts/generateEnv.sh +++ b/asset-transfer-basic/rest-api-typescript/scripts/generateEnv.sh @@ -14,10 +14,24 @@ LOG_LEVEL=info PORT=3000 -CONNECTION_PROFILE=$(cat ${CONNECTION_PROFILE_FILE} | jq -c .) +RETRY_DELAY=3000 -CERTIFICATE="$(cat ${CERTIFICATE_FILE} | sed -e 's/$/\\n/' | tr -d '\r\n')" +HLF_CONNECTION_PROFILE=$(cat ${CONNECTION_PROFILE_FILE} | jq -c .) -PRIVATE_KEY="$(cat ${PRIVATE_KEY_FILE} | sed -e 's/$/\\n/' | tr -d '\r\n')" +HLF_CERTIFICATE="$(cat ${CERTIFICATE_FILE} | sed -e 's/$/\\n/' | tr -d '\r\n')" + +HLF_PRIVATE_KEY="$(cat ${PRIVATE_KEY_FILE} | sed -e 's/$/\\n/' | tr -d '\r\n')" + +HLF_COMMIT_TIMEOUT=3000 + +HLF_ENDORSE_TIMEOUT=30 + +REDIS_HOST=localhost + +REDIS_PORT=6379 + +#REDIS_USERNAME= + +#REDIS_PASSWORD= ENV_END diff --git a/asset-transfer-basic/rest-api-typescript/src/assets.router.ts b/asset-transfer-basic/rest-api-typescript/src/assets.router.ts index 180adaa8..f3ad92e1 100644 --- a/asset-transfer-basic/rest-api-typescript/src/assets.router.ts +++ b/asset-transfer-basic/rest-api-typescript/src/assets.router.ts @@ -10,17 +10,101 @@ * */ -import { Contract } from 'fabric-network'; -import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import express, { Request, Response } from 'express'; - +import { body, validationResult } from 'express-validator'; +import { Contract } from 'fabric-network'; +import { getReasonPhrase, StatusCodes } from 'http-status-codes'; +import { Redis } from 'ioredis'; +import { + clearTransactionDetails, + createDeferredEventHandler, + storeTransactionDetails, +} from './fabric'; import { logger } from './logger'; -const { INTERNAL_SERVER_ERROR, NOT_FOUND, OK } = StatusCodes; +const { ACCEPTED, BAD_REQUEST, INTERNAL_SERVER_ERROR, NOT_FOUND, OK } = + StatusCodes; export const assetsRouter = express.Router(); +assetsRouter.post( + '/', + body('id', 'must be a string').notEmpty(), + body('color', 'must be a string').notEmpty(), + body('size', 'must be a number').isNumeric(), + body('owner', 'must be a string').notEmpty(), + body('appraisedValue', 'must be a number').isNumeric(), + async (req: Request, res: Response) => { + logger.info(req.body, 'Create asset request received'); + + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(BAD_REQUEST).json({ + status: getReasonPhrase(BAD_REQUEST), + timestamp: new Date().toISOString(), + errors: errors.array(), + }); + } + + const contract: Contract = req.app.get('contract'); + const redis: Redis = req.app.get('redis'); + const txn = contract.createTransaction('CreateAsset'); + const txnId = txn.getTransactionId(); + const txnState = txn.serialize(); + const txnArgs = JSON.stringify([ + req.body.id, + req.body.color, + req.body.size, + req.body.owner, + req.body.appraisedValue, + ]); + + try { + const timestamp = Date.now(); + + // Store the transaction details and set the event handler in case there + // are problems later with commiting the transaction + await storeTransactionDetails(redis, txnId, txnState, txnArgs, timestamp); + txn.setEventHandler(createDeferredEventHandler(redis)); + + await txn.submit( + req.body.id, + req.body.color, + req.body.size, + req.body.owner, + req.body.appraisedValue + ); + + return res.status(ACCEPTED).json({ + status: getReasonPhrase(ACCEPTED), + timestamp: new Date().toISOString(), + }); + } catch (err) { + // TODO will this always catch endorsement errors or can those + // arrive later? + + // There's no point retrying a transaction if there were business + // logic errors so clear the transaction details + // + // Note: it would be nice to pick out business logic errors returned + // from chaincode, e.g. asset already exists, and return those as a + // 400 error with message instead. Unfortunately the asset transfer + // sample or Fabric Node SDK do not provide any well defined error + // codes that can be checked. + await clearTransactionDetails(redis, txnId); + + logger.error(err); + return res.status(INTERNAL_SERVER_ERROR).json({ + status: getReasonPhrase(INTERNAL_SERVER_ERROR), + timestamp: new Date().toISOString(), + }); + } + } +); + assetsRouter.options('/:assetId', async (req: Request, res: Response) => { + logger.info(req.body, 'Read asset request received'); + try { const contract: Contract = req.app.get('contract'); @@ -29,7 +113,7 @@ assetsRouter.options('/:assetId', async (req: Request, res: Response) => { const exists = data.toString() === 'true'; if (exists) { - res + return res .status(OK) .set({ Allow: 'GET,OPTIONS', @@ -39,7 +123,7 @@ assetsRouter.options('/:assetId', async (req: Request, res: Response) => { timestamp: new Date().toISOString(), }); } else { - res.status(NOT_FOUND).json({ + return res.status(NOT_FOUND).json({ status: getReasonPhrase(NOT_FOUND), timestamp: new Date().toISOString(), }); @@ -61,7 +145,7 @@ assetsRouter.get('/:assetId', async (req: Request, res: Response) => { const data = await contract.evaluateTransaction('ReadAsset', assetId); const asset = JSON.parse(data.toString()); - res.status(OK).json(asset); + return res.status(OK).json(asset); } catch (err) { logger.error(err); return res.status(INTERNAL_SERVER_ERROR).json({ diff --git a/asset-transfer-basic/rest-api-typescript/src/config.ts b/asset-transfer-basic/rest-api-typescript/src/config.ts index 69ac9b69..cf967dcf 100644 --- a/asset-transfer-basic/rest-api-typescript/src/config.ts +++ b/asset-transfer-basic/rest-api-typescript/src/config.ts @@ -15,6 +15,12 @@ export const port = env .example('3000') .asIntPositive(); +export const retryDelay = env + .get('RETRY_DELAY') + .default('3000') + .example('3000') + .asIntPositive(); + export const asLocalHost = env .get('AS_LOCAL_HOST') .default('true') @@ -24,25 +30,37 @@ export const asLocalHost = env export const identityName = 'restServerIdentity'; export const mspId = env - .get('MSP_ID') + .get('HLF_MSP_ID') .default('Org1MSP') .example('Org1MSP') .asString(); export const channelName = env - .get('CHANNEL_NAME') + .get('HLF_CHANNEL_NAME') .default('mychannel') .example('mychannel') .asString(); export const chaincodeName = env - .get('CHAINCODE_NAME') + .get('HLF_CHAINCODE_NAME') .default('basic') .example('basic') .asString(); +export const commitTimeout = env + .get('HLF_COMMIT_TIMEOUT') + .default('3000') + .example('3000') + .asIntPositive(); + +export const endorseTimeout = env + .get('HLF_ENDORSE_TIMEOUT') + .default('30') + .example('30') + .asIntPositive(); + export const connectionProfile = env - .get('CONNECTION_PROFILE') + .get('HLF_CONNECTION_PROFILE') .required() .example( '{"name":"test-network-org1","version":"1.0.0","client":{"organization":"Org1" ... }' @@ -50,13 +68,32 @@ export const connectionProfile = env .asJsonObject(); export const certificate = env - .get('CERTIFICATE') + .get('HLF_CERTIFICATE') .required() .example('"-----BEGIN CERTIFICATE-----\\n...\\n-----END CERTIFICATE-----\\n"') .asString(); export const privateKey = env - .get('PRIVATE_KEY') + .get('HLF_PRIVATE_KEY') .required() .example('"-----BEGIN PRIVATE KEY-----\\n...\\n-----END PRIVATE KEY-----\\n"') .asString(); + +export const redisHost = env + .get('REDIS_HOST') + .default('localhost') + .example('localhost') + .asString(); + +export const redisPort = env + .get('REDIS_PORT') + .default('6379') + .example('6379') + .asIntPositive(); + +export const redisUsername = env + .get('REDIS_USERNAME') + .example('conga') + .asString(); + +export const redisPassword = env.get('REDIS_PASSWORD').asString(); diff --git a/asset-transfer-basic/rest-api-typescript/src/fabric.ts b/asset-transfer-basic/rest-api-typescript/src/fabric.ts index a568b376..95db6b8b 100644 --- a/asset-transfer-basic/rest-api-typescript/src/fabric.ts +++ b/asset-transfer-basic/rest-api-typescript/src/fabric.ts @@ -3,14 +3,19 @@ */ import { + CommitListener, + Contract, + DefaultEventHandlerStrategies, DefaultQueryHandlerStrategies, Gateway, GatewayOptions, - Contract, + TxEventHandler, + TxEventHandlerFactory, Wallets, } from 'fabric-network'; - +import { Redis } from 'ioredis'; import * as config from './config'; +import { logger } from './logger'; export const getContract = async (): Promise => { const wallet = await Wallets.newInMemoryWallet(); @@ -31,6 +36,11 @@ export const getContract = async (): Promise => { wallet, identity: config.identityName, discovery: { enabled: true, asLocalhost: config.asLocalHost }, + eventHandlerOptions: { + commitTimeout: config.commitTimeout, + endorseTimeout: config.endorseTimeout, + strategy: DefaultEventHandlerStrategies.PREFER_MSPID_SCOPE_ANYFORTX, + }, queryHandlerOptions: { timeout: 3, strategy: DefaultQueryHandlerStrategies.PREFER_MSPID_SCOPE_ROUND_ROBIN, @@ -44,3 +54,219 @@ export const getContract = async (): Promise => { return contract; }; + +export const createDeferredEventHandler = ( + redis: Redis +): TxEventHandlerFactory => { + return (transactionId, network): TxEventHandler => { + // TODO would like to store the transaction details here + // but doesn't seem possible to use await or handle errors + // in the TxEventHandlerFactory :( + + const mspId = network.getGateway().getIdentity().mspId; + const peers = network.getChannel().getEndorsers(mspId); + + const options = Object.assign( + { + commitTimeout: 30, + }, + network.getGateway().getOptions().eventHandlerOptions + ); + + const removeCommitListener = async () => { + network.removeCommitListener(listener); + logger.info('Stopped listening for transaction %s events', transactionId); + + const txnExists = await redis.exists(transactionId); + if (txnExists) { + logger.warn( + 'Transaction %s was not successfully committed', + transactionId + ); + } + }; + + const listener: CommitListener = async (error, event) => { + if (error) { + logger.error(error, 'Commit error for transaction %s', transactionId); + } + + if (event && event.isValid) { + logger.info('Transaction %s successfully committed', transactionId); + + await clearTransactionDetails(redis, transactionId); + await removeCommitListener(); + } + }; + + const deferredEventHandler: TxEventHandler = { + startListening: async () => { + logger.info('Setting timeout for %d ms', options.commitTimeout * 1000); + setTimeout(async () => { + logger.info( + 'Timeout listening for transaction %s events', + transactionId + ); + await removeCommitListener(); + }, options.commitTimeout * 1000); + + await network.addCommitListener(listener, peers, transactionId); + logger.info('Listening for transaction %s events', transactionId); + }, + waitForEvents: async () => { + // No-op + }, + cancelListening: async () => { + // TODO this is what the doc says, but is it true?! + logger.info( + 'Submission of transaction %s to the orderer failed', + transactionId + ); + await removeCommitListener(); + }, + }; + + return deferredEventHandler; + }; +}; + +export const startRetryLoop = (contract: Contract, redis: Redis): void => { + setInterval( + async (redis) => { + try { + const pendingTransactionCount = await (redis as Redis).zcard( + 'index:txn:timestamp' + ); + logger.info('Transactions awaiting retry: %d', pendingTransactionCount); + + const transactionIds = await (redis as Redis).zrange( + 'index:txn:timestamp', + -1, + -1 + ); + + if (transactionIds.length > 0) { + const transactionId = transactionIds[0]; + const savedTransaction = await (redis as Redis).hgetall( + `txn:${transactionId}` + ); + + await retryTransaction( + contract, + redis, + transactionId, + savedTransaction + ); + } + } catch (err) { + // TODO just log? + logger.error(err, 'error getting saved transaction state'); + } + }, + config.retryDelay, + redis + ); +}; + +const retryTransaction = async ( + contract: Contract, + redis: Redis, + transactionId: string, + savedTransaction: Record +) => { + logger.info('Retrying transaction %s', transactionId); + + try { + const transaction = contract.deserializeTransaction( + Buffer.from(savedTransaction.state) + ); + const args: string[] = JSON.parse(savedTransaction.args); + + await transaction.submit(...args); + await clearTransactionDetails(redis, transactionId); + } catch (err) { + if (isDuplicateTransaction(err)) { + logger.info('Transaction %s has already been committed', transactionId); + await clearTransactionDetails(redis, transactionId); + } else { + // TODO check for retry limit and update timestamp + logger.warn( + err, + 'Retry %d failed for transaction %s', + savedTransaction.retries, + transactionId + ); + await (redis as Redis).hincrby(`txn:${transactionId}`, 'retries', 1); + } + } +}; + +const isDuplicateTransaction = (error: { + errors: { endorsements: { details: string }[] }[]; +}) => { + // TODO this is horrible! Isn't it possible to check for TxValidationCode DUPLICATE_TXID somehow? + try { + const isDuplicateTxn = error?.errors?.some((err) => + err?.endorsements?.some((endorsement) => + endorsement?.details?.startsWith('duplicate transaction found') + ) + ); + + return isDuplicateTxn; + } catch (err) { + logger.warn(err, 'error checking for duplicate transaction'); + } + + return false; +}; + +// TODO move these to redis.ts? + +export const storeTransactionDetails = async ( + redis: Redis, + transactionId: string, + transactionState: Buffer, + transactionArgs: string, + timestamp: number +): Promise => { + const key = `txn:${transactionId}`; + logger.info( + 'Storing transaction details. Key: %s State: %s Args: %s Timestamp: %d', + key, + transactionState, + transactionArgs, + timestamp + ); + await redis + .multi() + .hset( + key, + 'state', + transactionState, + 'args', + transactionArgs, + 'timestamp', + timestamp, + 'retries', + '0' + ) + .zadd('index:txn:timestamp', timestamp, transactionId) + .exec(); +}; + +export const clearTransactionDetails = async ( + redis: Redis, + transactionId: string +): Promise => { + const key = `txn:${transactionId}`; + logger.info('Removing transaction details. Key: %s', key); + try { + await redis + .multi() + .del(key) + .zrem('index:txn:timestamp', transactionId) + .exec(); + } catch (err) { + logger.error(err, 'error remove saved transaction state'); + } +}; diff --git a/asset-transfer-basic/rest-api-typescript/src/index.ts b/asset-transfer-basic/rest-api-typescript/src/index.ts index 435a8022..b91a6c3f 100644 --- a/asset-transfer-basic/rest-api-typescript/src/index.ts +++ b/asset-transfer-basic/rest-api-typescript/src/index.ts @@ -2,16 +2,26 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { Contract } from 'fabric-network'; +import { Redis } from 'ioredis'; +import * as config from './config'; +import { startRetryLoop } from './fabric'; import { logger } from './logger'; import { createServer } from './server'; -import * as config from './config'; async function main() { const app = await createServer(); + const contract: Contract = app.get('contract'); + const redis: Redis = app.get('redis'); + startRetryLoop(contract, redis); + app.listen(config.port, () => { logger.info('Express server started on port: %d', config.port); }); } -main(); +// TODO handle errors! E.g. try starting with the wrong cert and private key! +main().catch((err) => { + logger.error(err, 'Unxepected error'); +}); diff --git a/asset-transfer-basic/rest-api-typescript/src/redis.ts b/asset-transfer-basic/rest-api-typescript/src/redis.ts new file mode 100644 index 00000000..9cc79dba --- /dev/null +++ b/asset-transfer-basic/rest-api-typescript/src/redis.ts @@ -0,0 +1,16 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + */ + +import IORedis, { RedisOptions } from 'ioredis'; + +import * as config from './config'; + +const redisOptions: RedisOptions = { + port: config.redisPort, + host: config.redisHost, + username: config.redisUsername, + password: config.redisPassword, +}; + +export const redis = new IORedis(redisOptions); diff --git a/asset-transfer-basic/rest-api-typescript/src/server.ts b/asset-transfer-basic/rest-api-typescript/src/server.ts index 19714d0e..f22f5fdd 100644 --- a/asset-transfer-basic/rest-api-typescript/src/server.ts +++ b/asset-transfer-basic/rest-api-typescript/src/server.ts @@ -10,6 +10,7 @@ import pinoMiddleware from 'pino-http'; import { logger } from './logger'; import { assetsRouter } from './assets.router'; import { getContract } from './fabric'; +import { redis } from './redis'; const { BAD_REQUEST, INTERNAL_SERVER_ERROR, NOT_FOUND, OK } = StatusCodes; @@ -49,6 +50,7 @@ export const createServer = async (): Promise => { const contract = await getContract(); app.set('contract', contract); + app.set('redis', redis); // Health routes app.get('/ready', (_req, res) => From d9e0de606bd5a703cff6da14281d326ad1c10625 Mon Sep 17 00:00:00 2001 From: James Taylor Date: Tue, 13 Jul 2021 14:14:08 +0100 Subject: [PATCH 07/59] Update readme Signed-off-by: James Taylor --- README.md | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 61 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 2059f1f5..a2dc791f 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,63 @@ -# fabric-rest-sample +# Fabric REST sample Prototype sample REST server to demonstrate good Fabric Node SDK practices + +The primary aim of this sample is to show how to write a long running client application using the Fabric Node SDK + +The REST API is intended to work with the [basic asset transfer example](https://github.com/hyperledger/fabric-samples/tree/main/asset-transfer-basic) + +To install the basic asset transfer chaincode on a local Fabric network, follow the [Using the Fabric test network](https://hyperledger-fabric.readthedocs.io/en/release-2.2/test_network.html) tutorial + +To build and start the sample REST server, you'll need to [download and install an LTS version of node](https://nodejs.org/en/download/) + +Clone this repository and change to the `fabric-rest-sample/asset-transfer-basic/rest-api-typescript` directory before running the following commands + +Install dependencies + +```shell +npm install +``` + +Build the REST server + +```shell +npm run build +``` + +Create a `.env` file to configure the server for the test network (make sure TEST_NETWORK_HOME is set to the fully qualified `test-network` directory) + +```shell +TEST_NETWORK_HOME=$HOME/fabric-samples/test-network npm run generateEnv +``` + +Start a Redis server + +```shell +npm run start:redis +``` + +Start the sample REST server + +```shell +npm run start:dev +``` + +If everything went well, you can now make REST calls! + +For example, check whether an asset exists... + +```shell +curl -v -X OPTIONS http://localhost:3000/api/assets/asset7 +``` + +Create an asset... + +```shell +curl --header "Content-Type: application/json" --request POST --data '{"id":"asset7","color":"red","size":42,"owner":"Jean","appraisedValue":101}' http://localhost:3000/api/assets +``` + +Get an asset... + +```shell +curl -v http://localhost:3000/api/assets/asset7 +``` From f06f1eed4b2cc90dfbfab84be038ff49f965af5d Mon Sep 17 00:00:00 2001 From: James Taylor Date: Wed, 14 Jul 2021 11:27:07 +0100 Subject: [PATCH 08/59] Update logging Signed-off-by: James Taylor --- .../rest-api-typescript/src/assets.router.ts | 16 +++++++----- .../rest-api-typescript/src/fabric.ts | 26 +++++++++---------- 2 files changed, 22 insertions(+), 20 deletions(-) diff --git a/asset-transfer-basic/rest-api-typescript/src/assets.router.ts b/asset-transfer-basic/rest-api-typescript/src/assets.router.ts index f3ad92e1..7058aa75 100644 --- a/asset-transfer-basic/rest-api-typescript/src/assets.router.ts +++ b/asset-transfer-basic/rest-api-typescript/src/assets.router.ts @@ -35,7 +35,7 @@ assetsRouter.post( body('owner', 'must be a string').notEmpty(), body('appraisedValue', 'must be a number').isNumeric(), async (req: Request, res: Response) => { - logger.info(req.body, 'Create asset request received'); + logger.debug(req.body, 'Create asset request received'); const errors = validationResult(req); if (!errors.isEmpty()) { @@ -93,7 +93,7 @@ assetsRouter.post( // codes that can be checked. await clearTransactionDetails(redis, txnId); - logger.error(err); + logger.error(err, 'Error processing create asset request for asset ID %s with transaction ID %s', req.body.id, txnId); return res.status(INTERNAL_SERVER_ERROR).json({ status: getReasonPhrase(INTERNAL_SERVER_ERROR), timestamp: new Date().toISOString(), @@ -103,12 +103,12 @@ assetsRouter.post( ); assetsRouter.options('/:assetId', async (req: Request, res: Response) => { - logger.info(req.body, 'Read asset request received'); + const assetId = req.params.assetId; + logger.debug('Asset options request received for asset ID %s', assetId); try { const contract: Contract = req.app.get('contract'); - const assetId = req.params.assetId; const data = await contract.evaluateTransaction('AssetExists', assetId); const exists = data.toString() === 'true'; @@ -129,7 +129,7 @@ assetsRouter.options('/:assetId', async (req: Request, res: Response) => { }); } } catch (err) { - logger.error(err); + logger.error(err, 'Error processing asset options request for asset ID %s', assetId); return res.status(INTERNAL_SERVER_ERROR).json({ status: getReasonPhrase(INTERNAL_SERVER_ERROR), timestamp: new Date().toISOString(), @@ -138,16 +138,18 @@ assetsRouter.options('/:assetId', async (req: Request, res: Response) => { }); assetsRouter.get('/:assetId', async (req: Request, res: Response) => { + const assetId = req.params.assetId; + logger.debug('Read asset request received for asset ID %s', assetId); + try { const contract: Contract = req.app.get('contract'); - const assetId = req.params.assetId; const data = await contract.evaluateTransaction('ReadAsset', assetId); const asset = JSON.parse(data.toString()); return res.status(OK).json(asset); } catch (err) { - logger.error(err); + logger.error(err, 'Error processing read asset request for asset ID %s', assetId); return res.status(INTERNAL_SERVER_ERROR).json({ status: getReasonPhrase(INTERNAL_SERVER_ERROR), timestamp: new Date().toISOString(), diff --git a/asset-transfer-basic/rest-api-typescript/src/fabric.ts b/asset-transfer-basic/rest-api-typescript/src/fabric.ts index 95db6b8b..6ce34866 100644 --- a/asset-transfer-basic/rest-api-typescript/src/fabric.ts +++ b/asset-transfer-basic/rest-api-typescript/src/fabric.ts @@ -75,7 +75,7 @@ export const createDeferredEventHandler = ( const removeCommitListener = async () => { network.removeCommitListener(listener); - logger.info('Stopped listening for transaction %s events', transactionId); + logger.debug('Stopped listening for transaction %s events', transactionId); const txnExists = await redis.exists(transactionId); if (txnExists) { @@ -92,7 +92,7 @@ export const createDeferredEventHandler = ( } if (event && event.isValid) { - logger.info('Transaction %s successfully committed', transactionId); + logger.debug('Transaction %s successfully committed', transactionId); await clearTransactionDetails(redis, transactionId); await removeCommitListener(); @@ -101,9 +101,9 @@ export const createDeferredEventHandler = ( const deferredEventHandler: TxEventHandler = { startListening: async () => { - logger.info('Setting timeout for %d ms', options.commitTimeout * 1000); + logger.debug('Setting timeout for %d ms', options.commitTimeout * 1000); setTimeout(async () => { - logger.info( + logger.debug( 'Timeout listening for transaction %s events', transactionId ); @@ -111,14 +111,14 @@ export const createDeferredEventHandler = ( }, options.commitTimeout * 1000); await network.addCommitListener(listener, peers, transactionId); - logger.info('Listening for transaction %s events', transactionId); + logger.debug('Listening for transaction %s events', transactionId); }, waitForEvents: async () => { // No-op }, cancelListening: async () => { // TODO this is what the doc says, but is it true?! - logger.info( + logger.warn( 'Submission of transaction %s to the orderer failed', transactionId ); @@ -137,7 +137,7 @@ export const startRetryLoop = (contract: Contract, redis: Redis): void => { const pendingTransactionCount = await (redis as Redis).zcard( 'index:txn:timestamp' ); - logger.info('Transactions awaiting retry: %d', pendingTransactionCount); + logger.debug('Transactions awaiting retry: %d', pendingTransactionCount); const transactionIds = await (redis as Redis).zrange( 'index:txn:timestamp', @@ -174,7 +174,7 @@ const retryTransaction = async ( transactionId: string, savedTransaction: Record ) => { - logger.info('Retrying transaction %s', transactionId); + logger.debug('Retrying transaction %s', transactionId); try { const transaction = contract.deserializeTransaction( @@ -186,7 +186,7 @@ const retryTransaction = async ( await clearTransactionDetails(redis, transactionId); } catch (err) { if (isDuplicateTransaction(err)) { - logger.info('Transaction %s has already been committed', transactionId); + logger.debug('Transaction %s has already been committed', transactionId); await clearTransactionDetails(redis, transactionId); } else { // TODO check for retry limit and update timestamp @@ -214,7 +214,7 @@ const isDuplicateTransaction = (error: { return isDuplicateTxn; } catch (err) { - logger.warn(err, 'error checking for duplicate transaction'); + logger.warn(err, 'Error checking for duplicate transaction'); } return false; @@ -230,7 +230,7 @@ export const storeTransactionDetails = async ( timestamp: number ): Promise => { const key = `txn:${transactionId}`; - logger.info( + logger.debug( 'Storing transaction details. Key: %s State: %s Args: %s Timestamp: %d', key, transactionState, @@ -259,7 +259,7 @@ export const clearTransactionDetails = async ( transactionId: string ): Promise => { const key = `txn:${transactionId}`; - logger.info('Removing transaction details. Key: %s', key); + logger.debug('Removing transaction details. Key: %s', key); try { await redis .multi() @@ -267,6 +267,6 @@ export const clearTransactionDetails = async ( .zrem('index:txn:timestamp', transactionId) .exec(); } catch (err) { - logger.error(err, 'error remove saved transaction state'); + logger.error(err, 'Error remove saved transaction state for transaction ID %s', transactionId); } }; From 8ebca62b40b46aeb11997a42a31c099d6587a378 Mon Sep 17 00:00:00 2001 From: James Taylor Date: Wed, 14 Jul 2021 11:31:29 +0100 Subject: [PATCH 09/59] Fix lint errors Signed-off-by: James Taylor --- .../rest-api-typescript/src/assets.router.ts | 19 ++++++++++++++++--- .../rest-api-typescript/src/fabric.ts | 16 +++++++++++++--- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/asset-transfer-basic/rest-api-typescript/src/assets.router.ts b/asset-transfer-basic/rest-api-typescript/src/assets.router.ts index 7058aa75..853e3e60 100644 --- a/asset-transfer-basic/rest-api-typescript/src/assets.router.ts +++ b/asset-transfer-basic/rest-api-typescript/src/assets.router.ts @@ -93,7 +93,12 @@ assetsRouter.post( // codes that can be checked. await clearTransactionDetails(redis, txnId); - logger.error(err, 'Error processing create asset request for asset ID %s with transaction ID %s', req.body.id, txnId); + logger.error( + err, + 'Error processing create asset request for asset ID %s with transaction ID %s', + req.body.id, + txnId + ); return res.status(INTERNAL_SERVER_ERROR).json({ status: getReasonPhrase(INTERNAL_SERVER_ERROR), timestamp: new Date().toISOString(), @@ -129,7 +134,11 @@ assetsRouter.options('/:assetId', async (req: Request, res: Response) => { }); } } catch (err) { - logger.error(err, 'Error processing asset options request for asset ID %s', assetId); + logger.error( + err, + 'Error processing asset options request for asset ID %s', + assetId + ); return res.status(INTERNAL_SERVER_ERROR).json({ status: getReasonPhrase(INTERNAL_SERVER_ERROR), timestamp: new Date().toISOString(), @@ -149,7 +158,11 @@ assetsRouter.get('/:assetId', async (req: Request, res: Response) => { return res.status(OK).json(asset); } catch (err) { - logger.error(err, 'Error processing read asset request for asset ID %s', assetId); + logger.error( + err, + 'Error processing read asset request for asset ID %s', + assetId + ); return res.status(INTERNAL_SERVER_ERROR).json({ status: getReasonPhrase(INTERNAL_SERVER_ERROR), timestamp: new Date().toISOString(), diff --git a/asset-transfer-basic/rest-api-typescript/src/fabric.ts b/asset-transfer-basic/rest-api-typescript/src/fabric.ts index 6ce34866..495238f3 100644 --- a/asset-transfer-basic/rest-api-typescript/src/fabric.ts +++ b/asset-transfer-basic/rest-api-typescript/src/fabric.ts @@ -75,7 +75,10 @@ export const createDeferredEventHandler = ( const removeCommitListener = async () => { network.removeCommitListener(listener); - logger.debug('Stopped listening for transaction %s events', transactionId); + logger.debug( + 'Stopped listening for transaction %s events', + transactionId + ); const txnExists = await redis.exists(transactionId); if (txnExists) { @@ -137,7 +140,10 @@ export const startRetryLoop = (contract: Contract, redis: Redis): void => { const pendingTransactionCount = await (redis as Redis).zcard( 'index:txn:timestamp' ); - logger.debug('Transactions awaiting retry: %d', pendingTransactionCount); + logger.debug( + 'Transactions awaiting retry: %d', + pendingTransactionCount + ); const transactionIds = await (redis as Redis).zrange( 'index:txn:timestamp', @@ -267,6 +273,10 @@ export const clearTransactionDetails = async ( .zrem('index:txn:timestamp', transactionId) .exec(); } catch (err) { - logger.error(err, 'Error remove saved transaction state for transaction ID %s', transactionId); + logger.error( + err, + 'Error remove saved transaction state for transaction ID %s', + transactionId + ); } }; From 8250359db67613ce3556063de7e1984ccfd77684 Mon Sep 17 00:00:00 2001 From: James Taylor Date: Wed, 14 Jul 2021 17:47:35 +0100 Subject: [PATCH 10/59] Add delete and put endpoints Signed-off-by: James Taylor --- README.md | 26 +++- .../rest-api-typescript/src/assets.router.ts | 144 +++++++++++++++++- 2 files changed, 168 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a2dc791f..9f004b8a 100644 --- a/README.md +++ b/README.md @@ -56,8 +56,32 @@ Create an asset... curl --header "Content-Type: application/json" --request POST --data '{"id":"asset7","color":"red","size":42,"owner":"Jean","appraisedValue":101}' http://localhost:3000/api/assets ``` -Get an asset... +Read an asset... ```shell curl -v http://localhost:3000/api/assets/asset7 ``` + +Update an asset... + +```shell +curl --header "Content-Type: application/json" --request PUT --data '{"id":"asset7","color":"red","size":11,"owner":"Jean","appraisedValue":101}' http://localhost:3000/api/assets/asset7 +``` + +Delete an asset... + +```shell +curl -v -X DELETE http://localhost:3000/api/assets/asset7 +``` + +Or all of the above for complete chaos! + +``` +curl --request OPTIONS http://localhost:3000/api/assets/asset7 \ + --next --header "Content-Type: application/json" --request POST --data '{"id":"asset7","color":"red","size":42,"owner":"Jean","appraisedValue":101}' http://localhost:3000/api/assets \ + --next --request READ http://localhost:3000/api/assets/asset7 \ + --next --header "Content-Type: application/json" --request PUT --data '{"id":"asset7","color":"red","size":11,"owner":"Jean","appraisedValue":101}' http://localhost:3000/api/assets/asset7 \ + --next --request READ http://localhost:3000/api/assets/asset7 \ + --next --request DELETE http://localhost:3000/api/assets/asset7 \ + --next --request READ http://localhost:3000/api/assets/asset7 +``` diff --git a/asset-transfer-basic/rest-api-typescript/src/assets.router.ts b/asset-transfer-basic/rest-api-typescript/src/assets.router.ts index 853e3e60..8711c56d 100644 --- a/asset-transfer-basic/rest-api-typescript/src/assets.router.ts +++ b/asset-transfer-basic/rest-api-typescript/src/assets.router.ts @@ -41,6 +41,7 @@ assetsRouter.post( if (!errors.isEmpty()) { return res.status(BAD_REQUEST).json({ status: getReasonPhrase(BAD_REQUEST), + message: 'Invalid request body', timestamp: new Date().toISOString(), errors: errors.array(), }); @@ -121,7 +122,7 @@ assetsRouter.options('/:assetId', async (req: Request, res: Response) => { return res .status(OK) .set({ - Allow: 'GET,OPTIONS', + Allow: 'DELETE,GET,OPTIONS,PUT', }) .json({ status: getReasonPhrase(OK), @@ -169,3 +170,144 @@ assetsRouter.get('/:assetId', async (req: Request, res: Response) => { }); } }); + +// TODO this shares a lot of code with the post endpoint! +assetsRouter.put( + '/:assetId', + body('id', 'must be a string').notEmpty(), + body('color', 'must be a string').notEmpty(), + body('size', 'must be a number').isNumeric(), + body('owner', 'must be a string').notEmpty(), + body('appraisedValue', 'must be a number').isNumeric(), + async (req: Request, res: Response) => { + logger.debug(req.body, 'Update asset request received'); + + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(BAD_REQUEST).json({ + status: getReasonPhrase(BAD_REQUEST), + message: 'Invalid request body', + timestamp: new Date().toISOString(), + errors: errors.array(), + }); + } + + if (req.params.assetId != req.body.id) { + return res.status(BAD_REQUEST).json({ + status: getReasonPhrase(BAD_REQUEST), + message: 'Asset IDs must match', + timestamp: new Date().toISOString(), + }); + } + + const contract: Contract = req.app.get('contract'); + const redis: Redis = req.app.get('redis'); + const txn = contract.createTransaction('UpdateAsset'); + const txnId = txn.getTransactionId(); + const txnState = txn.serialize(); + const txnArgs = JSON.stringify([ + req.params.assetId, + req.body.color, + req.body.size, + req.body.owner, + req.body.appraisedValue, + ]); + + try { + const timestamp = Date.now(); + + // Store the transaction details and set the event handler in case there + // are problems later with commiting the transaction + await storeTransactionDetails(redis, txnId, txnState, txnArgs, timestamp); + txn.setEventHandler(createDeferredEventHandler(redis)); + + await txn.submit( + req.params.assetId, + req.body.color, + req.body.size, + req.body.owner, + req.body.appraisedValue + ); + + return res.status(ACCEPTED).json({ + status: getReasonPhrase(ACCEPTED), + timestamp: new Date().toISOString(), + }); + } catch (err) { + // TODO will this always catch endorsement errors or can those + // arrive later? + + // There's no point retrying a transaction if there were business + // logic errors so clear the transaction details + // + // Note: it would be nice to pick out business logic errors returned + // from chaincode, e.g. asset already exists, and return those as a + // 400 error with message instead. Unfortunately the asset transfer + // sample or Fabric Node SDK do not provide any well defined error + // codes that can be checked. + await clearTransactionDetails(redis, txnId); + + logger.error( + err, + 'Error processing update asset request for asset ID %s with transaction ID %s', + req.params.assetId, + txnId + ); + return res.status(INTERNAL_SERVER_ERROR).json({ + status: getReasonPhrase(INTERNAL_SERVER_ERROR), + timestamp: new Date().toISOString(), + }); + } + } +); + +assetsRouter.delete('/:assetId', async (req: Request, res: Response) => { + logger.debug(req.body, 'Delete asset request received'); + + const contract: Contract = req.app.get('contract'); + const redis: Redis = req.app.get('redis'); + const txn = contract.createTransaction('DeleteAsset'); + const txnId = txn.getTransactionId(); + const txnState = txn.serialize(); + const txnArgs = JSON.stringify([req.params.assetId]); + + try { + const timestamp = Date.now(); + + // Store the transaction details and set the event handler in case there + // are problems later with commiting the transaction + await storeTransactionDetails(redis, txnId, txnState, txnArgs, timestamp); + txn.setEventHandler(createDeferredEventHandler(redis)); + + await txn.submit(req.params.assetId); + + return res.status(ACCEPTED).json({ + status: getReasonPhrase(ACCEPTED), + timestamp: new Date().toISOString(), + }); + } catch (err) { + // TODO will this always catch endorsement errors or can those + // arrive later? + + // There's no point retrying a transaction if there were business + // logic errors so clear the transaction details + // + // Note: it would be nice to pick out business logic errors returned + // from chaincode, e.g. asset already exists, and return those as a + // 400 error with message instead. Unfortunately the asset transfer + // sample or Fabric Node SDK do not provide any well defined error + // codes that can be checked. + await clearTransactionDetails(redis, txnId); + + logger.error( + err, + 'Error processing delete asset request for asset ID %s with transaction ID %s', + req.params.assetId, + txnId + ); + return res.status(INTERNAL_SERVER_ERROR).json({ + status: getReasonPhrase(INTERNAL_SERVER_ERROR), + timestamp: new Date().toISOString(), + }); + } +}); From d810128fcda6f6afa02dc945ce3709d90edb8b93 Mon Sep 17 00:00:00 2001 From: James Taylor Date: Thu, 15 Jul 2021 11:23:55 +0100 Subject: [PATCH 11/59] Add patch endpoint Signed-off-by: James Taylor --- README.md | 18 ++--- .../rest-api-typescript/src/assets.router.ts | 81 ++++++++++++++++++- 2 files changed, 86 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 9f004b8a..e422210d 100644 --- a/README.md +++ b/README.md @@ -68,20 +68,14 @@ Update an asset... curl --header "Content-Type: application/json" --request PUT --data '{"id":"asset7","color":"red","size":11,"owner":"Jean","appraisedValue":101}' http://localhost:3000/api/assets/asset7 ``` +Transfer an asset... + +```shell +curl --header "Content-Type: application/json" --request PATCH --data '[{"op":"replace","path":"/owner","value":"Ashleigh"}]' http://localhost:3000/api/assets/asset7 +``` + Delete an asset... ```shell curl -v -X DELETE http://localhost:3000/api/assets/asset7 ``` - -Or all of the above for complete chaos! - -``` -curl --request OPTIONS http://localhost:3000/api/assets/asset7 \ - --next --header "Content-Type: application/json" --request POST --data '{"id":"asset7","color":"red","size":42,"owner":"Jean","appraisedValue":101}' http://localhost:3000/api/assets \ - --next --request READ http://localhost:3000/api/assets/asset7 \ - --next --header "Content-Type: application/json" --request PUT --data '{"id":"asset7","color":"red","size":11,"owner":"Jean","appraisedValue":101}' http://localhost:3000/api/assets/asset7 \ - --next --request READ http://localhost:3000/api/assets/asset7 \ - --next --request DELETE http://localhost:3000/api/assets/asset7 \ - --next --request READ http://localhost:3000/api/assets/asset7 -``` diff --git a/asset-transfer-basic/rest-api-typescript/src/assets.router.ts b/asset-transfer-basic/rest-api-typescript/src/assets.router.ts index 8711c56d..d4c6361c 100644 --- a/asset-transfer-basic/rest-api-typescript/src/assets.router.ts +++ b/asset-transfer-basic/rest-api-typescript/src/assets.router.ts @@ -29,6 +29,7 @@ export const assetsRouter = express.Router(); assetsRouter.post( '/', + body().isObject().withMessage('body must contain an asset object'), body('id', 'must be a string').notEmpty(), body('color', 'must be a string').notEmpty(), body('size', 'must be a number').isNumeric(), @@ -122,7 +123,7 @@ assetsRouter.options('/:assetId', async (req: Request, res: Response) => { return res .status(OK) .set({ - Allow: 'DELETE,GET,OPTIONS,PUT', + Allow: 'DELETE,GET,OPTIONS,PATCH,PUT', }) .json({ status: getReasonPhrase(OK), @@ -174,6 +175,7 @@ assetsRouter.get('/:assetId', async (req: Request, res: Response) => { // TODO this shares a lot of code with the post endpoint! assetsRouter.put( '/:assetId', + body().isObject().withMessage('body must contain an asset object'), body('id', 'must be a string').notEmpty(), body('color', 'must be a string').notEmpty(), body('size', 'must be a number').isNumeric(), @@ -261,6 +263,83 @@ assetsRouter.put( } ); +// TODO this shares a lot of code with the post endpoint! +assetsRouter.patch( + '/:assetId', + body() + .isArray({ + min: 1, + max: 1, + }) + .withMessage('body must contain an array with a single patch operation'), + body('*.op', "operation must be 'replace'").equals('replace'), + body('*.path', "path must be '/owner'").equals('/owner'), + body('*.value', 'must be a string').isString(), + async (req: Request, res: Response) => { + logger.debug(req.body, 'Transfer asset request received'); + + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(BAD_REQUEST).json({ + status: getReasonPhrase(BAD_REQUEST), + message: 'Invalid request body', + timestamp: new Date().toISOString(), + errors: errors.array(), + }); + } + + const assetId = req.params.assetId; + const newOwner = req.body[0].value; + + const contract: Contract = req.app.get('contract'); + const redis: Redis = req.app.get('redis'); + const txn = contract.createTransaction('TransferAsset'); + const txnId = txn.getTransactionId(); + const txnState = txn.serialize(); + const txnArgs = JSON.stringify([assetId, newOwner]); + + try { + const timestamp = Date.now(); + + // Store the transaction details and set the event handler in case there + // are problems later with commiting the transaction + await storeTransactionDetails(redis, txnId, txnState, txnArgs, timestamp); + txn.setEventHandler(createDeferredEventHandler(redis)); + + await txn.submit(assetId, newOwner); + + return res.status(ACCEPTED).json({ + status: getReasonPhrase(ACCEPTED), + timestamp: new Date().toISOString(), + }); + } catch (err) { + // TODO will this always catch endorsement errors or can those + // arrive later? + + // There's no point retrying a transaction if there were business + // logic errors so clear the transaction details + // + // Note: it would be nice to pick out business logic errors returned + // from chaincode, e.g. asset already exists, and return those as a + // 400 error with message instead. Unfortunately the asset transfer + // sample or Fabric Node SDK do not provide any well defined error + // codes that can be checked. + await clearTransactionDetails(redis, txnId); + + logger.error( + err, + 'Error processing update asset request for asset ID %s with transaction ID %s', + req.params.assetId, + txnId + ); + return res.status(INTERNAL_SERVER_ERROR).json({ + status: getReasonPhrase(INTERNAL_SERVER_ERROR), + timestamp: new Date().toISOString(), + }); + } + } +); + assetsRouter.delete('/:assetId', async (req: Request, res: Response) => { logger.debug(req.body, 'Delete asset request received'); From 400367e8a6ea488cb013a34bd1570c2a5e6dd804 Mon Sep 17 00:00:00 2001 From: James Taylor Date: Thu, 15 Jul 2021 12:08:29 +0100 Subject: [PATCH 12/59] Add get all assets endpoint Signed-off-by: James Taylor --- README.md | 22 +++++++++++++------ .../rest-api-typescript/src/assets.router.ts | 19 ++++++++++++++++ 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index e422210d..24b890b0 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ The REST API is intended to work with the [basic asset transfer example](https:/ To install the basic asset transfer chaincode on a local Fabric network, follow the [Using the Fabric test network](https://hyperledger-fabric.readthedocs.io/en/release-2.2/test_network.html) tutorial +**Note:** these instructions should work with the release-2.2 branch of `fabric-samples` but later versions require some changes + To build and start the sample REST server, you'll need to [download and install an LTS version of node](https://nodejs.org/en/download/) Clone this repository and change to the `fabric-rest-sample/asset-transfer-basic/rest-api-typescript` directory before running the following commands @@ -44,38 +46,44 @@ npm run start:dev If everything went well, you can now make REST calls! -For example, check whether an asset exists... +For example, get all assets... ```shell -curl -v -X OPTIONS http://localhost:3000/api/assets/asset7 +curl http://localhost:3000/api/assets +``` + +Check whether an asset exists... + +```shell +curl --include --request OPTIONS http://localhost:3000/api/assets/asset7 ``` Create an asset... ```shell -curl --header "Content-Type: application/json" --request POST --data '{"id":"asset7","color":"red","size":42,"owner":"Jean","appraisedValue":101}' http://localhost:3000/api/assets +curl --include --header "Content-Type: application/json" --request POST --data '{"id":"asset7","color":"red","size":42,"owner":"Jean","appraisedValue":101}' http://localhost:3000/api/assets ``` Read an asset... ```shell -curl -v http://localhost:3000/api/assets/asset7 +curl http://localhost:3000/api/assets/asset7 ``` Update an asset... ```shell -curl --header "Content-Type: application/json" --request PUT --data '{"id":"asset7","color":"red","size":11,"owner":"Jean","appraisedValue":101}' http://localhost:3000/api/assets/asset7 +curl --include --header "Content-Type: application/json" --request PUT --data '{"id":"asset7","color":"red","size":11,"owner":"Jean","appraisedValue":101}' http://localhost:3000/api/assets/asset7 ``` Transfer an asset... ```shell -curl --header "Content-Type: application/json" --request PATCH --data '[{"op":"replace","path":"/owner","value":"Ashleigh"}]' http://localhost:3000/api/assets/asset7 +curl --include --header "Content-Type: application/json" --request PATCH --data '[{"op":"replace","path":"/owner","value":"Ashleigh"}]' http://localhost:3000/api/assets/asset7 ``` Delete an asset... ```shell -curl -v -X DELETE http://localhost:3000/api/assets/asset7 +curl --include --request DELETE http://localhost:3000/api/assets/asset7 ``` diff --git a/asset-transfer-basic/rest-api-typescript/src/assets.router.ts b/asset-transfer-basic/rest-api-typescript/src/assets.router.ts index d4c6361c..4a950636 100644 --- a/asset-transfer-basic/rest-api-typescript/src/assets.router.ts +++ b/asset-transfer-basic/rest-api-typescript/src/assets.router.ts @@ -27,6 +27,25 @@ const { ACCEPTED, BAD_REQUEST, INTERNAL_SERVER_ERROR, NOT_FOUND, OK } = export const assetsRouter = express.Router(); +assetsRouter.get('/', async (req: Request, res: Response) => { + logger.debug('Get all assets request received'); + + try { + const contract: Contract = req.app.get('contract'); + + const data = await contract.evaluateTransaction('GetAllAssets'); + const assets = JSON.parse(data.toString()); + + return res.status(OK).json(assets); + } catch (err) { + logger.error(err, 'Error processing get all assets request'); + return res.status(INTERNAL_SERVER_ERROR).json({ + status: getReasonPhrase(INTERNAL_SERVER_ERROR), + timestamp: new Date().toISOString(), + }); + } +}); + assetsRouter.post( '/', body().isObject().withMessage('body must contain an asset object'), From 273fc2833a754ced2a6770b6f11a5eeb41b15767 Mon Sep 17 00:00:00 2001 From: James Taylor Date: Thu, 15 Jul 2021 14:18:12 +0100 Subject: [PATCH 13/59] Add REST Client demo file Add demo file for use with the REST Client for Visual Studio Code Signed-off-by: James Taylor --- demo.http | 60 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 demo.http diff --git a/demo.http b/demo.http new file mode 100644 index 00000000..bf5e0961 --- /dev/null +++ b/demo.http @@ -0,0 +1,60 @@ +// Demo file for use with REST Client for Visual Studio Code +// See https://github.com/Huachao/vscode-restclient +@hostname = localhost +@port = 3000 +@baseUrl = http://{{hostname}}:{{port}}/api + +### Get all assets + +GET {{baseUrl}}/assets HTTP/1.1 + +### Check if asset exists + +OPTIONS {{baseUrl}}/assets/asset7 HTTP/1.1 + +### Create asset + +POST {{baseUrl}}/assets HTTP/1.1 +content-type: application/json + +{ + "id": "asset7", + "color": "red", + "size": 42, + "owner": "Jean", + "appraisedValue": 101 +} + +### Read asset + +GET {{baseUrl}}/assets/asset7 HTTP/1.1 + +### Update asset + +PUT {{baseUrl}}/assets/asset7 HTTP/1.1 +content-type: application/json + +{ + "id": "asset7", + "color": "red", + "size": 11, + "owner": "Jean", + "appraisedValue": 101 +} + +### Transfer asset + +PATCH {{baseUrl}}/assets/asset7 HTTP/1.1 +content-type: application/json + +[ + { + "op": "replace", + "path": "/owner", + "value": "Ashleigh" + } +] + +### Delete asset + +DELETE {{baseUrl}}/assets/asset7 HTTP/1.1 From 60aedf1b82e31772a43a9f93f2e7f04fd11a18ff Mon Sep 17 00:00:00 2001 From: James Taylor Date: Thu, 15 Jul 2021 18:30:38 +0100 Subject: [PATCH 14/59] Refactor transaction logic Remove duplication and handle errors from the asset transfer smart contract Signed-off-by: James Taylor --- .gitignore | 1 + .../rest-api-typescript/src/assets.router.ts | 225 +++++++----------- .../rest-api-typescript/src/errors.ts | 33 +++ .../rest-api-typescript/src/fabric.ts | 141 ++++++----- .../rest-api-typescript/src/redis.ts | 58 ++++- 5 files changed, 266 insertions(+), 192 deletions(-) create mode 100644 .gitignore create mode 100644 asset-transfer-basic/rest-api-typescript/src/errors.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..b0b21603 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +local.http diff --git a/asset-transfer-basic/rest-api-typescript/src/assets.router.ts b/asset-transfer-basic/rest-api-typescript/src/assets.router.ts index 4a950636..f70db1e3 100644 --- a/asset-transfer-basic/rest-api-typescript/src/assets.router.ts +++ b/asset-transfer-basic/rest-api-typescript/src/assets.router.ts @@ -15,15 +15,18 @@ import { body, validationResult } from 'express-validator'; import { Contract } from 'fabric-network'; import { getReasonPhrase, StatusCodes } from 'http-status-codes'; import { Redis } from 'ioredis'; -import { - clearTransactionDetails, - createDeferredEventHandler, - storeTransactionDetails, -} from './fabric'; +import { AssetExistsError, AssetNotFoundError } from './errors'; +import { evatuateTransaction, submitTransaction } from './fabric'; import { logger } from './logger'; -const { ACCEPTED, BAD_REQUEST, INTERNAL_SERVER_ERROR, NOT_FOUND, OK } = - StatusCodes; +const { + ACCEPTED, + BAD_REQUEST, + CONFLICT, + INTERNAL_SERVER_ERROR, + NOT_FOUND, + OK, +} = StatusCodes; export const assetsRouter = express.Router(); @@ -33,7 +36,7 @@ assetsRouter.get('/', async (req: Request, res: Response) => { try { const contract: Contract = req.app.get('contract'); - const data = await contract.evaluateTransaction('GetAllAssets'); + const data = await evatuateTransaction(contract, 'GetAllAssets'); const assets = JSON.parse(data.toString()); return res.status(OK).json(assets); @@ -61,6 +64,7 @@ assetsRouter.post( if (!errors.isEmpty()) { return res.status(BAD_REQUEST).json({ status: getReasonPhrase(BAD_REQUEST), + reason: 'VALIDATION_ERROR', message: 'Invalid request body', timestamp: new Date().toISOString(), errors: errors.array(), @@ -69,27 +73,14 @@ assetsRouter.post( const contract: Contract = req.app.get('contract'); const redis: Redis = req.app.get('redis'); - const txn = contract.createTransaction('CreateAsset'); - const txnId = txn.getTransactionId(); - const txnState = txn.serialize(); - const txnArgs = JSON.stringify([ - req.body.id, - req.body.color, - req.body.size, - req.body.owner, - req.body.appraisedValue, - ]); + const assetId = req.body.id; try { - const timestamp = Date.now(); - - // Store the transaction details and set the event handler in case there - // are problems later with commiting the transaction - await storeTransactionDetails(redis, txnId, txnState, txnArgs, timestamp); - txn.setEventHandler(createDeferredEventHandler(redis)); - - await txn.submit( - req.body.id, + await submitTransaction( + contract, + redis, + 'CreateAsset', + assetId, req.body.color, req.body.size, req.body.owner, @@ -101,25 +92,22 @@ assetsRouter.post( timestamp: new Date().toISOString(), }); } catch (err) { - // TODO will this always catch endorsement errors or can those - // arrive later? - - // There's no point retrying a transaction if there were business - // logic errors so clear the transaction details - // - // Note: it would be nice to pick out business logic errors returned - // from chaincode, e.g. asset already exists, and return those as a - // 400 error with message instead. Unfortunately the asset transfer - // sample or Fabric Node SDK do not provide any well defined error - // codes that can be checked. - await clearTransactionDetails(redis, txnId); - logger.error( err, 'Error processing create asset request for asset ID %s with transaction ID %s', - req.body.id, - txnId + assetId, + err.transactionId ); + + if (err instanceof AssetExistsError) { + return res.status(CONFLICT).json({ + status: getReasonPhrase(CONFLICT), + reason: 'ASSET_EXISTS', + message: err.message, + timestamp: new Date().toISOString(), + }); + } + return res.status(INTERNAL_SERVER_ERROR).json({ status: getReasonPhrase(INTERNAL_SERVER_ERROR), timestamp: new Date().toISOString(), @@ -135,7 +123,7 @@ assetsRouter.options('/:assetId', async (req: Request, res: Response) => { try { const contract: Contract = req.app.get('contract'); - const data = await contract.evaluateTransaction('AssetExists', assetId); + const data = await evatuateTransaction(contract, 'AssetExists', assetId); const exists = data.toString() === 'true'; if (exists) { @@ -174,7 +162,7 @@ assetsRouter.get('/:assetId', async (req: Request, res: Response) => { try { const contract: Contract = req.app.get('contract'); - const data = await contract.evaluateTransaction('ReadAsset', assetId); + const data = await evatuateTransaction(contract, 'ReadAsset', assetId); const asset = JSON.parse(data.toString()); return res.status(OK).json(asset); @@ -184,6 +172,14 @@ assetsRouter.get('/:assetId', async (req: Request, res: Response) => { 'Error processing read asset request for asset ID %s', assetId ); + + if (err instanceof AssetNotFoundError) { + return res.status(NOT_FOUND).json({ + status: getReasonPhrase(NOT_FOUND), + timestamp: new Date().toISOString(), + }); + } + return res.status(INTERNAL_SERVER_ERROR).json({ status: getReasonPhrase(INTERNAL_SERVER_ERROR), timestamp: new Date().toISOString(), @@ -191,7 +187,6 @@ assetsRouter.get('/:assetId', async (req: Request, res: Response) => { } }); -// TODO this shares a lot of code with the post endpoint! assetsRouter.put( '/:assetId', body().isObject().withMessage('body must contain an asset object'), @@ -207,6 +202,7 @@ assetsRouter.put( if (!errors.isEmpty()) { return res.status(BAD_REQUEST).json({ status: getReasonPhrase(BAD_REQUEST), + reason: 'VALIDATION_ERROR', message: 'Invalid request body', timestamp: new Date().toISOString(), errors: errors.array(), @@ -216,6 +212,7 @@ assetsRouter.put( if (req.params.assetId != req.body.id) { return res.status(BAD_REQUEST).json({ status: getReasonPhrase(BAD_REQUEST), + reason: 'ASSET_ID_MISMATCH', message: 'Asset IDs must match', timestamp: new Date().toISOString(), }); @@ -223,27 +220,14 @@ assetsRouter.put( const contract: Contract = req.app.get('contract'); const redis: Redis = req.app.get('redis'); - const txn = contract.createTransaction('UpdateAsset'); - const txnId = txn.getTransactionId(); - const txnState = txn.serialize(); - const txnArgs = JSON.stringify([ - req.params.assetId, - req.body.color, - req.body.size, - req.body.owner, - req.body.appraisedValue, - ]); + const assetId = req.params.assetId; try { - const timestamp = Date.now(); - - // Store the transaction details and set the event handler in case there - // are problems later with commiting the transaction - await storeTransactionDetails(redis, txnId, txnState, txnArgs, timestamp); - txn.setEventHandler(createDeferredEventHandler(redis)); - - await txn.submit( - req.params.assetId, + await submitTransaction( + contract, + redis, + 'UpdateAsset', + assetId, req.body.color, req.body.size, req.body.owner, @@ -255,25 +239,20 @@ assetsRouter.put( timestamp: new Date().toISOString(), }); } catch (err) { - // TODO will this always catch endorsement errors or can those - // arrive later? - - // There's no point retrying a transaction if there were business - // logic errors so clear the transaction details - // - // Note: it would be nice to pick out business logic errors returned - // from chaincode, e.g. asset already exists, and return those as a - // 400 error with message instead. Unfortunately the asset transfer - // sample or Fabric Node SDK do not provide any well defined error - // codes that can be checked. - await clearTransactionDetails(redis, txnId); - logger.error( err, 'Error processing update asset request for asset ID %s with transaction ID %s', - req.params.assetId, - txnId + assetId, + err.transactionId ); + + if (err instanceof AssetNotFoundError) { + return res.status(NOT_FOUND).json({ + status: getReasonPhrase(NOT_FOUND), + timestamp: new Date().toISOString(), + }); + } + return res.status(INTERNAL_SERVER_ERROR).json({ status: getReasonPhrase(INTERNAL_SERVER_ERROR), timestamp: new Date().toISOString(), @@ -282,7 +261,6 @@ assetsRouter.put( } ); -// TODO this shares a lot of code with the post endpoint! assetsRouter.patch( '/:assetId', body() @@ -301,56 +279,46 @@ assetsRouter.patch( if (!errors.isEmpty()) { return res.status(BAD_REQUEST).json({ status: getReasonPhrase(BAD_REQUEST), + reason: 'VALIDATION_ERROR', message: 'Invalid request body', timestamp: new Date().toISOString(), errors: errors.array(), }); } + const contract: Contract = req.app.get('contract'); + const redis: Redis = req.app.get('redis'); const assetId = req.params.assetId; const newOwner = req.body[0].value; - const contract: Contract = req.app.get('contract'); - const redis: Redis = req.app.get('redis'); - const txn = contract.createTransaction('TransferAsset'); - const txnId = txn.getTransactionId(); - const txnState = txn.serialize(); - const txnArgs = JSON.stringify([assetId, newOwner]); - try { - const timestamp = Date.now(); - - // Store the transaction details and set the event handler in case there - // are problems later with commiting the transaction - await storeTransactionDetails(redis, txnId, txnState, txnArgs, timestamp); - txn.setEventHandler(createDeferredEventHandler(redis)); - - await txn.submit(assetId, newOwner); + await submitTransaction( + contract, + redis, + 'TransferAsset', + assetId, + newOwner + ); return res.status(ACCEPTED).json({ status: getReasonPhrase(ACCEPTED), timestamp: new Date().toISOString(), }); } catch (err) { - // TODO will this always catch endorsement errors or can those - // arrive later? - - // There's no point retrying a transaction if there were business - // logic errors so clear the transaction details - // - // Note: it would be nice to pick out business logic errors returned - // from chaincode, e.g. asset already exists, and return those as a - // 400 error with message instead. Unfortunately the asset transfer - // sample or Fabric Node SDK do not provide any well defined error - // codes that can be checked. - await clearTransactionDetails(redis, txnId); - logger.error( err, 'Error processing update asset request for asset ID %s with transaction ID %s', req.params.assetId, - txnId + err.transactionId ); + + if (err instanceof AssetNotFoundError) { + return res.status(NOT_FOUND).json({ + status: getReasonPhrase(NOT_FOUND), + timestamp: new Date().toISOString(), + }); + } + return res.status(INTERNAL_SERVER_ERROR).json({ status: getReasonPhrase(INTERNAL_SERVER_ERROR), timestamp: new Date().toISOString(), @@ -364,45 +332,30 @@ assetsRouter.delete('/:assetId', async (req: Request, res: Response) => { const contract: Contract = req.app.get('contract'); const redis: Redis = req.app.get('redis'); - const txn = contract.createTransaction('DeleteAsset'); - const txnId = txn.getTransactionId(); - const txnState = txn.serialize(); - const txnArgs = JSON.stringify([req.params.assetId]); + const assetId = req.params.assetId; try { - const timestamp = Date.now(); - - // Store the transaction details and set the event handler in case there - // are problems later with commiting the transaction - await storeTransactionDetails(redis, txnId, txnState, txnArgs, timestamp); - txn.setEventHandler(createDeferredEventHandler(redis)); - - await txn.submit(req.params.assetId); + await submitTransaction(contract, redis, 'DeleteAsset', assetId); return res.status(ACCEPTED).json({ status: getReasonPhrase(ACCEPTED), timestamp: new Date().toISOString(), }); } catch (err) { - // TODO will this always catch endorsement errors or can those - // arrive later? - - // There's no point retrying a transaction if there were business - // logic errors so clear the transaction details - // - // Note: it would be nice to pick out business logic errors returned - // from chaincode, e.g. asset already exists, and return those as a - // 400 error with message instead. Unfortunately the asset transfer - // sample or Fabric Node SDK do not provide any well defined error - // codes that can be checked. - await clearTransactionDetails(redis, txnId); - logger.error( err, 'Error processing delete asset request for asset ID %s with transaction ID %s', - req.params.assetId, - txnId + assetId, + err.transactionId ); + + if (err instanceof AssetNotFoundError) { + return res.status(NOT_FOUND).json({ + status: getReasonPhrase(NOT_FOUND), + timestamp: new Date().toISOString(), + }); + } + return res.status(INTERNAL_SERVER_ERROR).json({ status: getReasonPhrase(INTERNAL_SERVER_ERROR), timestamp: new Date().toISOString(), diff --git a/asset-transfer-basic/rest-api-typescript/src/errors.ts b/asset-transfer-basic/rest-api-typescript/src/errors.ts new file mode 100644 index 00000000..beb03478 --- /dev/null +++ b/asset-transfer-basic/rest-api-typescript/src/errors.ts @@ -0,0 +1,33 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + */ + +export class TransactionError extends Error { + transactionId: string; + + constructor(message: string, transactionId: string) { + super(message); + Object.setPrototypeOf(this, TransactionError.prototype); + + this.name = 'TransactionError'; + this.transactionId = transactionId; + } +} + +export class AssetExistsError extends TransactionError { + constructor(message: string, transactionId: string) { + super(message, transactionId); + Object.setPrototypeOf(this, AssetExistsError.prototype); + + this.name = 'AssetExistsError'; + } +} + +export class AssetNotFoundError extends TransactionError { + constructor(message: string, transactionId: string) { + super(message, transactionId); + Object.setPrototypeOf(this, AssetNotFoundError.prototype); + + this.name = 'AssetNotFoundError'; + } +} diff --git a/asset-transfer-basic/rest-api-typescript/src/fabric.ts b/asset-transfer-basic/rest-api-typescript/src/fabric.ts index 495238f3..cb4e551a 100644 --- a/asset-transfer-basic/rest-api-typescript/src/fabric.ts +++ b/asset-transfer-basic/rest-api-typescript/src/fabric.ts @@ -16,6 +16,12 @@ import { import { Redis } from 'ioredis'; import * as config from './config'; import { logger } from './logger'; +import { storeTransactionDetails, clearTransactionDetails } from './redis'; +import { + AssetExistsError, + AssetNotFoundError, + TransactionError, +} from './errors'; export const getContract = async (): Promise => { const wallet = await Wallets.newInMemoryWallet(); @@ -174,6 +180,86 @@ export const startRetryLoop = (contract: Contract, redis: Redis): void => { ); }; +export const evatuateTransaction = async ( + contract: Contract, + transactionName: string, + ...transactionArgs: string[] +): Promise => { + const txn = contract.createTransaction(transactionName); + const txnId = txn.getTransactionId(); + + try { + return await txn.evaluate(...transactionArgs); + } catch (err) { + throw handleError(txnId, err); + } +}; + +export const submitTransaction = async ( + contract: Contract, + redis: Redis, + transactionName: string, + ...transactionArgs: string[] +): Promise => { + const txn = contract.createTransaction(transactionName); + const txnId = txn.getTransactionId(); + const txnState = txn.serialize(); + const txnArgs = JSON.stringify(transactionArgs); + const timestamp = Date.now(); + + try { + // Store the transaction details and set the event handler in case there + // are problems later with commiting the transaction + await storeTransactionDetails(redis, txnId, txnState, txnArgs, timestamp); + txn.setEventHandler(createDeferredEventHandler(redis)); + + await txn.submit(...transactionArgs); + } catch (err) { + // If the transaction failed to endorse, there is no point attempting + // to retry it later so clear the transaction details + // TODO will this always catch endorsement errors or can they + // arrive later? + await clearTransactionDetails(redis, txnId); + throw handleError(txnId, err); + } + + return txnId; +}; + +// Unfortunately the chaincode samples do not use error codes, and the error +// message text is not the same for each implementation +const handleError = (transactionId: string, err: Error): Error => { + // This regex needs to match the following error messages: + // "the asset %s already exists" + // "The asset ${id} already exists" + // "Asset %s already exists" + const assetAlreadyExistsRegex = /([tT]he )?[aA]sset \w* already exists/g; + const assetAlreadyExistsMatch = err.message.match(assetAlreadyExistsRegex); + logger.debug( + { message: err.message, result: assetAlreadyExistsMatch }, + 'Checking for asset already exists message' + ); + if (assetAlreadyExistsMatch) { + return new AssetExistsError(assetAlreadyExistsMatch[0], transactionId); + } + + // This regex needs to match the following error messages: + // "the asset %s does not exist" + // "The asset ${id} does not exist" + // "Asset %s does not exist" + const assetDoesNotExistRegex = /([tT]he )?[aA]sset \w* does not exist/g; + const assetDoesNotExistMatch = err.message.match(assetDoesNotExistRegex); + logger.debug( + { message: err.message, result: assetDoesNotExistMatch }, + 'Checking for asset does not exist message' + ); + if (assetDoesNotExistMatch) { + return new AssetNotFoundError(assetDoesNotExistMatch[0], transactionId); + } + + return new TransactionError('Transaction error', transactionId); +}; + const retryTransaction = async ( contract: Contract, redis: Redis, @@ -225,58 +311,3 @@ const isDuplicateTransaction = (error: { return false; }; - -// TODO move these to redis.ts? - -export const storeTransactionDetails = async ( - redis: Redis, - transactionId: string, - transactionState: Buffer, - transactionArgs: string, - timestamp: number -): Promise => { - const key = `txn:${transactionId}`; - logger.debug( - 'Storing transaction details. Key: %s State: %s Args: %s Timestamp: %d', - key, - transactionState, - transactionArgs, - timestamp - ); - await redis - .multi() - .hset( - key, - 'state', - transactionState, - 'args', - transactionArgs, - 'timestamp', - timestamp, - 'retries', - '0' - ) - .zadd('index:txn:timestamp', timestamp, transactionId) - .exec(); -}; - -export const clearTransactionDetails = async ( - redis: Redis, - transactionId: string -): Promise => { - const key = `txn:${transactionId}`; - logger.debug('Removing transaction details. Key: %s', key); - try { - await redis - .multi() - .del(key) - .zrem('index:txn:timestamp', transactionId) - .exec(); - } catch (err) { - logger.error( - err, - 'Error remove saved transaction state for transaction ID %s', - transactionId - ); - } -}; diff --git a/asset-transfer-basic/rest-api-typescript/src/redis.ts b/asset-transfer-basic/rest-api-typescript/src/redis.ts index 9cc79dba..d799d800 100644 --- a/asset-transfer-basic/rest-api-typescript/src/redis.ts +++ b/asset-transfer-basic/rest-api-typescript/src/redis.ts @@ -2,9 +2,10 @@ * SPDX-License-Identifier: Apache-2.0 */ -import IORedis, { RedisOptions } from 'ioredis'; +import IORedis, { Redis, RedisOptions } from 'ioredis'; import * as config from './config'; +import { logger } from './logger'; const redisOptions: RedisOptions = { port: config.redisPort, @@ -14,3 +15,58 @@ const redisOptions: RedisOptions = { }; export const redis = new IORedis(redisOptions); + +export const storeTransactionDetails = async ( + redis: Redis, + transactionId: string, + transactionState: Buffer, + transactionArgs: string, + timestamp: number +): Promise => { + const key = `txn:${transactionId}`; + logger.debug( + 'Storing transaction details. Key: %s State: %s Args: %s Timestamp: %d', + key, + transactionState, + transactionArgs, + timestamp + ); + await redis + .multi() + .hset( + key, + 'state', + transactionState, + 'args', + transactionArgs, + 'timestamp', + timestamp, + 'retries', + '0' + ) + .zadd('index:txn:timestamp', timestamp, transactionId) + .exec(); +}; + +export const clearTransactionDetails = async ( + redis: Redis, + transactionId: string +): Promise => { + const key = `txn:${transactionId}`; + logger.debug('Removing transaction details. Key: %s', key); + try { + await redis + .multi() + .del(key) + .zrem('index:txn:timestamp', transactionId) + .exec(); + } catch (err) { + logger.error( + err, + 'Error remove saved transaction state for transaction ID %s', + transactionId + ); + } +}; + +// TODO add getTransaction etc. helpers? From 432da5defde6f0c6b2c1c92870aa1ee0859148b4 Mon Sep 17 00:00:00 2001 From: James Taylor Date: Wed, 21 Jul 2021 12:17:10 +0100 Subject: [PATCH 15/59] Add get transaction endpoint Signed-off-by: James Taylor --- .../rest-api-typescript/src/assets.router.ts | 31 +++-- .../rest-api-typescript/src/errors.ts | 12 ++ .../rest-api-typescript/src/fabric.ts | 39 ++++++- .../rest-api-typescript/src/server.ts | 9 +- .../src/transactions.router.ts | 108 ++++++++++++++++++ 5 files changed, 181 insertions(+), 18 deletions(-) create mode 100644 asset-transfer-basic/rest-api-typescript/src/transactions.router.ts diff --git a/asset-transfer-basic/rest-api-typescript/src/assets.router.ts b/asset-transfer-basic/rest-api-typescript/src/assets.router.ts index f70db1e3..ccf5690a 100644 --- a/asset-transfer-basic/rest-api-typescript/src/assets.router.ts +++ b/asset-transfer-basic/rest-api-typescript/src/assets.router.ts @@ -34,7 +34,7 @@ assetsRouter.get('/', async (req: Request, res: Response) => { logger.debug('Get all assets request received'); try { - const contract: Contract = req.app.get('contract'); + const contract: Contract = req.app.get('contracts').contract; const data = await evatuateTransaction(contract, 'GetAllAssets'); const assets = JSON.parse(data.toString()); @@ -71,12 +71,12 @@ assetsRouter.post( }); } - const contract: Contract = req.app.get('contract'); + const contract: Contract = req.app.get('contracts').contract; const redis: Redis = req.app.get('redis'); const assetId = req.body.id; try { - await submitTransaction( + const transactionId = await submitTransaction( contract, redis, 'CreateAsset', @@ -89,6 +89,7 @@ assetsRouter.post( return res.status(ACCEPTED).json({ status: getReasonPhrase(ACCEPTED), + transactionId: transactionId, timestamp: new Date().toISOString(), }); } catch (err) { @@ -121,7 +122,7 @@ assetsRouter.options('/:assetId', async (req: Request, res: Response) => { logger.debug('Asset options request received for asset ID %s', assetId); try { - const contract: Contract = req.app.get('contract'); + const contract: Contract = req.app.get('contracts').contract; const data = await evatuateTransaction(contract, 'AssetExists', assetId); const exists = data.toString() === 'true'; @@ -160,7 +161,7 @@ assetsRouter.get('/:assetId', async (req: Request, res: Response) => { logger.debug('Read asset request received for asset ID %s', assetId); try { - const contract: Contract = req.app.get('contract'); + const contract: Contract = req.app.get('contracts').contract; const data = await evatuateTransaction(contract, 'ReadAsset', assetId); const asset = JSON.parse(data.toString()); @@ -218,12 +219,12 @@ assetsRouter.put( }); } - const contract: Contract = req.app.get('contract'); + const contract: Contract = req.app.get('contracts').contract; const redis: Redis = req.app.get('redis'); const assetId = req.params.assetId; try { - await submitTransaction( + const transactionId = await submitTransaction( contract, redis, 'UpdateAsset', @@ -236,6 +237,7 @@ assetsRouter.put( return res.status(ACCEPTED).json({ status: getReasonPhrase(ACCEPTED), + transactionId: transactionId, timestamp: new Date().toISOString(), }); } catch (err) { @@ -286,13 +288,13 @@ assetsRouter.patch( }); } - const contract: Contract = req.app.get('contract'); + const contract: Contract = req.app.get('contracts').contract; const redis: Redis = req.app.get('redis'); const assetId = req.params.assetId; const newOwner = req.body[0].value; try { - await submitTransaction( + const transactionId = await submitTransaction( contract, redis, 'TransferAsset', @@ -302,6 +304,7 @@ assetsRouter.patch( return res.status(ACCEPTED).json({ status: getReasonPhrase(ACCEPTED), + transactionId: transactionId, timestamp: new Date().toISOString(), }); } catch (err) { @@ -330,15 +333,21 @@ assetsRouter.patch( assetsRouter.delete('/:assetId', async (req: Request, res: Response) => { logger.debug(req.body, 'Delete asset request received'); - const contract: Contract = req.app.get('contract'); + const contract: Contract = req.app.get('contracts').contract; const redis: Redis = req.app.get('redis'); const assetId = req.params.assetId; try { - await submitTransaction(contract, redis, 'DeleteAsset', assetId); + const transactionId = await submitTransaction( + contract, + redis, + 'DeleteAsset', + assetId + ); return res.status(ACCEPTED).json({ status: getReasonPhrase(ACCEPTED), + transactionId: transactionId, timestamp: new Date().toISOString(), }); } catch (err) { diff --git a/asset-transfer-basic/rest-api-typescript/src/errors.ts b/asset-transfer-basic/rest-api-typescript/src/errors.ts index beb03478..21692ac1 100644 --- a/asset-transfer-basic/rest-api-typescript/src/errors.ts +++ b/asset-transfer-basic/rest-api-typescript/src/errors.ts @@ -14,6 +14,18 @@ export class TransactionError extends Error { } } +export class TransactionNotFoundError extends Error { + transactionId: string; + + constructor(message: string, transactionId: string) { + super(message); + Object.setPrototypeOf(this, TransactionNotFoundError.prototype); + + this.name = 'TransactionNotFoundError'; + this.transactionId = transactionId; + } +} + export class AssetExistsError extends TransactionError { constructor(message: string, transactionId: string) { super(message, transactionId); diff --git a/asset-transfer-basic/rest-api-typescript/src/fabric.ts b/asset-transfer-basic/rest-api-typescript/src/fabric.ts index cb4e551a..2419d0e1 100644 --- a/asset-transfer-basic/rest-api-typescript/src/fabric.ts +++ b/asset-transfer-basic/rest-api-typescript/src/fabric.ts @@ -21,9 +21,10 @@ import { AssetExistsError, AssetNotFoundError, TransactionError, + TransactionNotFoundError, } from './errors'; -export const getContract = async (): Promise => { +export const getGateway = async (): Promise => { const wallet = await Wallets.newInMemoryWallet(); const x509Identity = { @@ -55,10 +56,18 @@ export const getContract = async (): Promise => { await gateway.connect(config.connectionProfile, connectOptions); - const network = await gateway.getNetwork(config.channelName); - const contract = network.getContract(config.chaincodeName); + return gateway; +}; - return contract; +export const getContracts = async ( + gateway: Gateway +): Promise<{ contract: Contract; qscc: Contract }> => { + const network = await gateway.getNetwork(config.channelName); + + const contract = network.getContract(config.chaincodeName); + const qscc = network.getContract('qscc'); + + return { contract, qscc }; }; export const createDeferredEventHandler = ( @@ -257,6 +266,28 @@ const handleError = (transactionId: string, err: Error): Error => { return new AssetNotFoundError(assetDoesNotExistMatch[0], transactionId); } + // This regex needs to match the following error messages: + // "Failed to get transaction with id %s, error Entry not found in index" + const transactionDoesNotExistRegex = + /Failed to get transaction with id [^,]*, error Entry not found in index/g; + const transactionDoesNotExistMatch = err.message.match( + transactionDoesNotExistRegex + ); + logger.debug( + { message: err.message, result: transactionDoesNotExistMatch }, + 'Checking for transaction does not exist message' + ); + if (transactionDoesNotExistMatch) { + return new TransactionNotFoundError( + transactionDoesNotExistMatch[0], + transactionId + ); + } + + logger.error( + { transactionId: transactionId, error: err }, + 'Unhandled transaction error' + ); return new TransactionError('Transaction error', transactionId); }; diff --git a/asset-transfer-basic/rest-api-typescript/src/server.ts b/asset-transfer-basic/rest-api-typescript/src/server.ts index f22f5fdd..180278c4 100644 --- a/asset-transfer-basic/rest-api-typescript/src/server.ts +++ b/asset-transfer-basic/rest-api-typescript/src/server.ts @@ -9,7 +9,8 @@ import pinoMiddleware from 'pino-http'; import { logger } from './logger'; import { assetsRouter } from './assets.router'; -import { getContract } from './fabric'; +import { transactionsRouter } from './transactions.router'; +import { getContracts, getGateway } from './fabric'; import { redis } from './redis'; const { BAD_REQUEST, INTERNAL_SERVER_ERROR, NOT_FOUND, OK } = StatusCodes; @@ -48,8 +49,9 @@ export const createServer = async (): Promise => { app.use(helmet()); } - const contract = await getContract(); - app.set('contract', contract); + const gateway = await getGateway(); + const contracts = await getContracts(gateway); + app.set('contracts', contracts); app.set('redis', redis); // Health routes @@ -72,6 +74,7 @@ export const createServer = async (): Promise => { }); app.use('/api/assets', assetsRouter); + app.use('/api/transactions', transactionsRouter); // For everything else app.use((_req, res) => diff --git a/asset-transfer-basic/rest-api-typescript/src/transactions.router.ts b/asset-transfer-basic/rest-api-typescript/src/transactions.router.ts new file mode 100644 index 00000000..2a0bc987 --- /dev/null +++ b/asset-transfer-basic/rest-api-typescript/src/transactions.router.ts @@ -0,0 +1,108 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + */ + +import express, { Request, Response } from 'express'; +import { Contract } from 'fabric-network'; +import { protos } from 'fabric-protos'; +import { getReasonPhrase, StatusCodes } from 'http-status-codes'; +import { Redis } from 'ioredis'; +import { evatuateTransaction } from './fabric'; +import { logger } from './logger'; +import * as config from './config'; +import { TransactionNotFoundError } from './errors'; + +const { INTERNAL_SERVER_ERROR, NOT_FOUND, OK } = StatusCodes; + +export const transactionsRouter = express.Router(); + +type Progress = 'ACCEPTED' | 'RETRYING' | 'DONE'; + +transactionsRouter.get( + '/:transactionId', + async (req: Request, res: Response) => { + const transactionId = req.params.transactionId; + logger.debug('Read request received for transaction ID %s', transactionId); + + let foundTransaction = false; + let progress: Progress = 'DONE'; + let validationCode = ''; + + const qscc: Contract = req.app.get('contracts').qscc; + const redis: Redis = req.app.get('redis'); + + try { + const savedTransaction = await (redis as Redis).hgetall( + `txn:${transactionId}` + ); + logger.debug( + { transactionId: transactionId, state: savedTransaction }, + 'Saved transaction state' + ); + + if (savedTransaction.state) { + foundTransaction = true; + const retries = parseInt(savedTransaction.retries); + if (retries > 0) { + progress = 'RETRYING'; + } else { + progress = 'ACCEPTED'; + } + } + } catch (err) { + logger.error( + err, + 'Redis error processing read request for transaction ID %s', + transactionId + ); + + return res.status(INTERNAL_SERVER_ERROR).json({ + status: getReasonPhrase(INTERNAL_SERVER_ERROR), + timestamp: new Date().toISOString(), + }); + } + + try { + const data = await evatuateTransaction( + qscc, + 'GetTransactionByID', + config.channelName, + transactionId + ); + + foundTransaction = true; + // TODO is it possible to use the BlockDecoder decodeTransaction + // function in fabric-common? + const processedTransaction = protos.ProcessedTransaction.decode(data); + validationCode = + protos.TxValidationCode[processedTransaction.validationCode]; + } catch (err) { + if (!(err instanceof TransactionNotFoundError)) { + logger.error( + err, + 'Fabric error processing read request for transaction ID %s', + transactionId + ); + + return res.status(INTERNAL_SERVER_ERROR).json({ + status: getReasonPhrase(INTERNAL_SERVER_ERROR), + timestamp: new Date().toISOString(), + }); + } + } + + if (foundTransaction) { + return res.status(OK).json({ + status: getReasonPhrase(OK), + progress: progress, + validationCode: validationCode, + timestamp: new Date().toISOString(), + }); + } else { + return res.status(NOT_FOUND).json({ + status: getReasonPhrase(NOT_FOUND), + timestamp: new Date().toISOString(), + }); + } + } +); From 3667dd93228822b1affd358ad32677714704eb9d Mon Sep 17 00:00:00 2001 From: James Taylor Date: Thu, 22 Jul 2021 12:12:30 +0100 Subject: [PATCH 16/59] Add Jira link to readme Also adds new transactions endpoint example Signed-off-by: James Taylor --- README.md | 28 +++++++++++++++++++--------- demo.http | 4 ++++ 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 24b890b0..aaf539be 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Fabric REST sample -Prototype sample REST server to demonstrate good Fabric Node SDK practices +Prototype sample REST server to demonstrate good Fabric Node SDK practices for parts of [FAB-18511](https://jira.hyperledger.org/browse/FAB-18511) The primary aim of this sample is to show how to write a long running client application using the Fabric Node SDK @@ -8,6 +8,8 @@ The REST API is intended to work with the [basic asset transfer example](https:/ To install the basic asset transfer chaincode on a local Fabric network, follow the [Using the Fabric test network](https://hyperledger-fabric.readthedocs.io/en/release-2.2/test_network.html) tutorial +## Usage + **Note:** these instructions should work with the release-2.2 branch of `fabric-samples` but later versions require some changes To build and start the sample REST server, you'll need to [download and install an LTS version of node](https://nodejs.org/en/download/) @@ -44,45 +46,53 @@ Start the sample REST server npm run start:dev ``` -If everything went well, you can now make REST calls! +## REST API -For example, get all assets... +If everything went well, you can now make basic asset transfer REST calls! For example... + +### Get all assets... ```shell curl http://localhost:3000/api/assets ``` -Check whether an asset exists... +### Check whether an asset exists... ```shell curl --include --request OPTIONS http://localhost:3000/api/assets/asset7 ``` -Create an asset... +### Create an asset... ```shell curl --include --header "Content-Type: application/json" --request POST --data '{"id":"asset7","color":"red","size":42,"owner":"Jean","appraisedValue":101}' http://localhost:3000/api/assets ``` -Read an asset... +### Read transaction status... + +```shell +curl http://localhost:3000/api/transactions/__transaction_id__ +``` + +### Read an asset... ```shell curl http://localhost:3000/api/assets/asset7 ``` -Update an asset... +### Update an asset... ```shell curl --include --header "Content-Type: application/json" --request PUT --data '{"id":"asset7","color":"red","size":11,"owner":"Jean","appraisedValue":101}' http://localhost:3000/api/assets/asset7 ``` -Transfer an asset... +### Transfer an asset... ```shell curl --include --header "Content-Type: application/json" --request PATCH --data '[{"op":"replace","path":"/owner","value":"Ashleigh"}]' http://localhost:3000/api/assets/asset7 ``` -Delete an asset... +### Delete an asset... ```shell curl --include --request DELETE http://localhost:3000/api/assets/asset7 diff --git a/demo.http b/demo.http index bf5e0961..7873879f 100644 --- a/demo.http +++ b/demo.http @@ -25,6 +25,10 @@ content-type: application/json "appraisedValue": 101 } +### Read transaction status + +GET {{baseUrl}}/transactions/__transaction_id__ HTTP/1.1 + ### Read asset GET {{baseUrl}}/assets/asset7 HTTP/1.1 From 19e28d817be8a26500f89979bbc21041f6446654 Mon Sep 17 00:00:00 2001 From: James Taylor Date: Thu, 22 Jul 2021 17:45:59 +0100 Subject: [PATCH 17/59] Fix retry Adding multiple contracts had broken the retry loop Signed-off-by: James Taylor --- asset-transfer-basic/rest-api-typescript/src/fabric.ts | 6 +++++- asset-transfer-basic/rest-api-typescript/src/index.ts | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/asset-transfer-basic/rest-api-typescript/src/fabric.ts b/asset-transfer-basic/rest-api-typescript/src/fabric.ts index 2419d0e1..44066d65 100644 --- a/asset-transfer-basic/rest-api-typescript/src/fabric.ts +++ b/asset-transfer-basic/rest-api-typescript/src/fabric.ts @@ -160,6 +160,10 @@ export const startRetryLoop = (contract: Contract, redis: Redis): void => { pendingTransactionCount ); + // TODO pick a random transaction instead to reduce chances of + // clashing with other instances? Currently no zrandmember + // command though... + // https://github.com/luin/ioredis/issues/1374 const transactionIds = await (redis as Redis).zrange( 'index:txn:timestamp', -1, @@ -309,7 +313,7 @@ const retryTransaction = async ( await clearTransactionDetails(redis, transactionId); } catch (err) { if (isDuplicateTransaction(err)) { - logger.debug('Transaction %s has already been committed', transactionId); + logger.warn('Transaction %s has already been committed', transactionId); await clearTransactionDetails(redis, transactionId); } else { // TODO check for retry limit and update timestamp diff --git a/asset-transfer-basic/rest-api-typescript/src/index.ts b/asset-transfer-basic/rest-api-typescript/src/index.ts index b91a6c3f..12e167c0 100644 --- a/asset-transfer-basic/rest-api-typescript/src/index.ts +++ b/asset-transfer-basic/rest-api-typescript/src/index.ts @@ -12,7 +12,7 @@ import { createServer } from './server'; async function main() { const app = await createServer(); - const contract: Contract = app.get('contract'); + const contract: Contract = app.get('contracts').contract; const redis: Redis = app.get('redis'); startRetryLoop(contract, redis); From 804f4a6468010a2b2e2bf5308499efe34ce9784f Mon Sep 17 00:00:00 2001 From: sapthasurendran Date: Mon, 26 Jul 2021 11:59:41 +0530 Subject: [PATCH 18/59] added getNetwork functionality Signed-off-by: sapthasurendran --- asset-transfer-basic/rest-api-typescript/src/fabric.ts | 8 ++++++++ asset-transfer-basic/rest-api-typescript/src/server.ts | 4 +++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/asset-transfer-basic/rest-api-typescript/src/fabric.ts b/asset-transfer-basic/rest-api-typescript/src/fabric.ts index 44066d65..da6cc722 100644 --- a/asset-transfer-basic/rest-api-typescript/src/fabric.ts +++ b/asset-transfer-basic/rest-api-typescript/src/fabric.ts @@ -12,6 +12,7 @@ import { TxEventHandler, TxEventHandlerFactory, Wallets, + Network } from 'fabric-network'; import { Redis } from 'ioredis'; import * as config from './config'; @@ -24,6 +25,12 @@ import { TransactionNotFoundError, } from './errors'; + +export const getNetwork = async (gateway: Gateway): Promise => { + const network = await gateway.getNetwork(config.channelName); + return network; +}; + export const getGateway = async (): Promise => { const wallet = await Wallets.newInMemoryWallet(); @@ -346,3 +353,4 @@ const isDuplicateTransaction = (error: { return false; }; + diff --git a/asset-transfer-basic/rest-api-typescript/src/server.ts b/asset-transfer-basic/rest-api-typescript/src/server.ts index 180278c4..5d5f4269 100644 --- a/asset-transfer-basic/rest-api-typescript/src/server.ts +++ b/asset-transfer-basic/rest-api-typescript/src/server.ts @@ -10,7 +10,7 @@ import pinoMiddleware from 'pino-http'; import { logger } from './logger'; import { assetsRouter } from './assets.router'; import { transactionsRouter } from './transactions.router'; -import { getContracts, getGateway } from './fabric'; +import { getContracts, getGateway, getNetwork } from './fabric'; import { redis } from './redis'; const { BAD_REQUEST, INTERNAL_SERVER_ERROR, NOT_FOUND, OK } = StatusCodes; @@ -51,8 +51,10 @@ export const createServer = async (): Promise => { const gateway = await getGateway(); const contracts = await getContracts(gateway); + const network = await getNetwork(gateway) app.set('contracts', contracts); app.set('redis', redis); + app.set('network',network) // Health routes app.get('/ready', (_req, res) => From 550e95f09158bfdece3536803569ae1d3eb6f8e5 Mon Sep 17 00:00:00 2001 From: sapthasurendran Date: Mon, 26 Jul 2021 12:38:52 +0530 Subject: [PATCH 19/59] Added blockEvent handler Signed-off-by: sapthasurendran --- .../rest-api-typescript/src/fabric.ts | 24 +++++++++++++++++-- .../rest-api-typescript/src/index.ts | 6 +++-- .../rest-api-typescript/src/server.ts | 4 ++-- 3 files changed, 28 insertions(+), 6 deletions(-) diff --git a/asset-transfer-basic/rest-api-typescript/src/fabric.ts b/asset-transfer-basic/rest-api-typescript/src/fabric.ts index da6cc722..a2e08a8d 100644 --- a/asset-transfer-basic/rest-api-typescript/src/fabric.ts +++ b/asset-transfer-basic/rest-api-typescript/src/fabric.ts @@ -12,7 +12,10 @@ import { TxEventHandler, TxEventHandlerFactory, Wallets, - Network + Network, + BlockListener, + BlockEvent, + TransactionEvent, } from 'fabric-network'; import { Redis } from 'ioredis'; import * as config from './config'; @@ -25,7 +28,6 @@ import { TransactionNotFoundError, } from './errors'; - export const getNetwork = async (gateway: Gateway): Promise => { const network = await gateway.getNetwork(config.channelName); return network; @@ -354,3 +356,21 @@ const isDuplicateTransaction = (error: { return false; }; +export const blockEventHandler = (redis: Redis): BlockListener => { + const blockListner = async (event: BlockEvent) => { + logger.debug('Block event received '); + const transEvents: Array = event.getTransactionEvents(); + + for (const transEvent of transEvents) { + if (transEvent && transEvent.isValid) { + logger.debug( + 'Remove transation with txnId %s', + transEvent.transactionId + ); + await clearTransactionDetails(redis, transEvent.transactionId); + } + } + }; + + return blockListner; +}; diff --git a/asset-transfer-basic/rest-api-typescript/src/index.ts b/asset-transfer-basic/rest-api-typescript/src/index.ts index 12e167c0..9d65028e 100644 --- a/asset-transfer-basic/rest-api-typescript/src/index.ts +++ b/asset-transfer-basic/rest-api-typescript/src/index.ts @@ -2,10 +2,10 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { Contract } from 'fabric-network'; +import { Contract, Network } from 'fabric-network'; import { Redis } from 'ioredis'; import * as config from './config'; -import { startRetryLoop } from './fabric'; +import { startRetryLoop, blockEventHandler } from './fabric'; import { logger } from './logger'; import { createServer } from './server'; @@ -14,6 +14,8 @@ async function main() { const contract: Contract = app.get('contracts').contract; const redis: Redis = app.get('redis'); + const network: Network = app.get('network'); + await network.addBlockListener(blockEventHandler(redis)); startRetryLoop(contract, redis); app.listen(config.port, () => { diff --git a/asset-transfer-basic/rest-api-typescript/src/server.ts b/asset-transfer-basic/rest-api-typescript/src/server.ts index 5d5f4269..e3ed3fb6 100644 --- a/asset-transfer-basic/rest-api-typescript/src/server.ts +++ b/asset-transfer-basic/rest-api-typescript/src/server.ts @@ -51,10 +51,10 @@ export const createServer = async (): Promise => { const gateway = await getGateway(); const contracts = await getContracts(gateway); - const network = await getNetwork(gateway) + const network = await getNetwork(gateway); app.set('contracts', contracts); app.set('redis', redis); - app.set('network',network) + app.set('network', network); // Health routes app.get('/ready', (_req, res) => From 5d6e916436fa3e1b290f6b8bda7330ae87f981a3 Mon Sep 17 00:00:00 2001 From: sapthasurendran Date: Mon, 26 Jul 2021 14:53:53 +0530 Subject: [PATCH 20/59] changed getContract arg Signed-off-by: sapthasurendran --- asset-transfer-basic/rest-api-typescript/src/fabric.ts | 5 +---- asset-transfer-basic/rest-api-typescript/src/server.ts | 2 +- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/asset-transfer-basic/rest-api-typescript/src/fabric.ts b/asset-transfer-basic/rest-api-typescript/src/fabric.ts index a2e08a8d..af831efe 100644 --- a/asset-transfer-basic/rest-api-typescript/src/fabric.ts +++ b/asset-transfer-basic/rest-api-typescript/src/fabric.ts @@ -69,13 +69,10 @@ export const getGateway = async (): Promise => { }; export const getContracts = async ( - gateway: Gateway + network: Network ): Promise<{ contract: Contract; qscc: Contract }> => { - const network = await gateway.getNetwork(config.channelName); - const contract = network.getContract(config.chaincodeName); const qscc = network.getContract('qscc'); - return { contract, qscc }; }; diff --git a/asset-transfer-basic/rest-api-typescript/src/server.ts b/asset-transfer-basic/rest-api-typescript/src/server.ts index e3ed3fb6..e2586cba 100644 --- a/asset-transfer-basic/rest-api-typescript/src/server.ts +++ b/asset-transfer-basic/rest-api-typescript/src/server.ts @@ -50,8 +50,8 @@ export const createServer = async (): Promise => { } const gateway = await getGateway(); - const contracts = await getContracts(gateway); const network = await getNetwork(gateway); + const contracts = await getContracts(network); app.set('contracts', contracts); app.set('redis', redis); app.set('network', network); From 31b08b91515306362e87ef3534b703bc2786a296 Mon Sep 17 00:00:00 2001 From: sapthasurendran Date: Mon, 26 Jul 2021 15:02:06 +0530 Subject: [PATCH 21/59] removed txn commit event handler Signed-off-by: sapthasurendran --- .../rest-api-typescript/src/fabric.ts | 84 +------------------ 1 file changed, 1 insertion(+), 83 deletions(-) diff --git a/asset-transfer-basic/rest-api-typescript/src/fabric.ts b/asset-transfer-basic/rest-api-typescript/src/fabric.ts index af831efe..25bead39 100644 --- a/asset-transfer-basic/rest-api-typescript/src/fabric.ts +++ b/asset-transfer-basic/rest-api-typescript/src/fabric.ts @@ -3,14 +3,11 @@ */ import { - CommitListener, Contract, DefaultEventHandlerStrategies, DefaultQueryHandlerStrategies, Gateway, GatewayOptions, - TxEventHandler, - TxEventHandlerFactory, Wallets, Network, BlockListener, @@ -55,7 +52,7 @@ export const getGateway = async (): Promise => { eventHandlerOptions: { commitTimeout: config.commitTimeout, endorseTimeout: config.endorseTimeout, - strategy: DefaultEventHandlerStrategies.PREFER_MSPID_SCOPE_ANYFORTX, + strategy: DefaultEventHandlerStrategies.NONE, }, queryHandlerOptions: { timeout: 3, @@ -76,84 +73,6 @@ export const getContracts = async ( return { contract, qscc }; }; -export const createDeferredEventHandler = ( - redis: Redis -): TxEventHandlerFactory => { - return (transactionId, network): TxEventHandler => { - // TODO would like to store the transaction details here - // but doesn't seem possible to use await or handle errors - // in the TxEventHandlerFactory :( - - const mspId = network.getGateway().getIdentity().mspId; - const peers = network.getChannel().getEndorsers(mspId); - - const options = Object.assign( - { - commitTimeout: 30, - }, - network.getGateway().getOptions().eventHandlerOptions - ); - - const removeCommitListener = async () => { - network.removeCommitListener(listener); - logger.debug( - 'Stopped listening for transaction %s events', - transactionId - ); - - const txnExists = await redis.exists(transactionId); - if (txnExists) { - logger.warn( - 'Transaction %s was not successfully committed', - transactionId - ); - } - }; - - const listener: CommitListener = async (error, event) => { - if (error) { - logger.error(error, 'Commit error for transaction %s', transactionId); - } - - if (event && event.isValid) { - logger.debug('Transaction %s successfully committed', transactionId); - - await clearTransactionDetails(redis, transactionId); - await removeCommitListener(); - } - }; - - const deferredEventHandler: TxEventHandler = { - startListening: async () => { - logger.debug('Setting timeout for %d ms', options.commitTimeout * 1000); - setTimeout(async () => { - logger.debug( - 'Timeout listening for transaction %s events', - transactionId - ); - await removeCommitListener(); - }, options.commitTimeout * 1000); - - await network.addCommitListener(listener, peers, transactionId); - logger.debug('Listening for transaction %s events', transactionId); - }, - waitForEvents: async () => { - // No-op - }, - cancelListening: async () => { - // TODO this is what the doc says, but is it true?! - logger.warn( - 'Submission of transaction %s to the orderer failed', - transactionId - ); - await removeCommitListener(); - }, - }; - - return deferredEventHandler; - }; -}; - export const startRetryLoop = (contract: Contract, redis: Redis): void => { setInterval( async (redis) => { @@ -230,7 +149,6 @@ export const submitTransaction = async ( // Store the transaction details and set the event handler in case there // are problems later with commiting the transaction await storeTransactionDetails(redis, txnId, txnState, txnArgs, timestamp); - txn.setEventHandler(createDeferredEventHandler(redis)); await txn.submit(...transactionArgs); } catch (err) { From fc769cfcb0b7808de1d83b996dce6260c34eb40f Mon Sep 17 00:00:00 2001 From: sapthasurendran Date: Mon, 26 Jul 2021 15:19:26 +0530 Subject: [PATCH 22/59] event handler strategy changes Signed-off-by: sapthasurendran --- asset-transfer-basic/rest-api-typescript/src/fabric.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/asset-transfer-basic/rest-api-typescript/src/fabric.ts b/asset-transfer-basic/rest-api-typescript/src/fabric.ts index 25bead39..ab099842 100644 --- a/asset-transfer-basic/rest-api-typescript/src/fabric.ts +++ b/asset-transfer-basic/rest-api-typescript/src/fabric.ts @@ -52,7 +52,7 @@ export const getGateway = async (): Promise => { eventHandlerOptions: { commitTimeout: config.commitTimeout, endorseTimeout: config.endorseTimeout, - strategy: DefaultEventHandlerStrategies.NONE, + strategy: DefaultEventHandlerStrategies.PREFER_MSPID_SCOPE_ANYFORTX, }, queryHandlerOptions: { timeout: 3, @@ -149,7 +149,7 @@ export const submitTransaction = async ( // Store the transaction details and set the event handler in case there // are problems later with commiting the transaction await storeTransactionDetails(redis, txnId, txnState, txnArgs, timestamp); - + txn.setEventHandler(DefaultEventHandlerStrategies.NONE); await txn.submit(...transactionArgs); } catch (err) { // If the transaction failed to endorse, there is no point attempting From d4318c381adcc5ec7eb435ad37a6a00e2837a001 Mon Sep 17 00:00:00 2001 From: sapthasurendran Date: Fri, 23 Jul 2021 16:15:27 +0530 Subject: [PATCH 23/59] liveness used fabric protos insted of blockdecoder changed import statement Signed-off-by: sapthasurendran revert back eslintrc Signed-off-by: sapthasurendran function name change Signed-off-by: sapthasurendran format changes Signed-off-by: sapthasurendran --- .../rest-api-typescript/src/fabric.ts | 17 ++++++++++ .../rest-api-typescript/src/server.ts | 31 ++++++++++++++----- 2 files changed, 40 insertions(+), 8 deletions(-) diff --git a/asset-transfer-basic/rest-api-typescript/src/fabric.ts b/asset-transfer-basic/rest-api-typescript/src/fabric.ts index ab099842..d96abe9b 100644 --- a/asset-transfer-basic/rest-api-typescript/src/fabric.ts +++ b/asset-transfer-basic/rest-api-typescript/src/fabric.ts @@ -24,6 +24,7 @@ import { TransactionError, TransactionNotFoundError, } from './errors'; +import fabproto6 from 'fabric-protos'; export const getNetwork = async (gateway: Gateway): Promise => { const network = await gateway.getNetwork(config.channelName); @@ -289,3 +290,19 @@ export const blockEventHandler = (redis: Redis): BlockListener => { return blockListner; }; + +export const getChainInfo = async (qscc: Contract): Promise => { + try { + const data = await qscc.evaluateTransaction( + 'GetChainInfo', + config.channelName + ); + const info = fabproto6.common.BlockchainInfo.decode(data); + const blockHeight = info.height.toString(); + logger.info('Current block height: %s', blockHeight); + return true; + } catch (e) { + logger.error(e, 'Unable to get blockchain info'); + return false; + } +}; diff --git a/asset-transfer-basic/rest-api-typescript/src/server.ts b/asset-transfer-basic/rest-api-typescript/src/server.ts index e2586cba..c0b51a38 100644 --- a/asset-transfer-basic/rest-api-typescript/src/server.ts +++ b/asset-transfer-basic/rest-api-typescript/src/server.ts @@ -10,10 +10,17 @@ import pinoMiddleware from 'pino-http'; import { logger } from './logger'; import { assetsRouter } from './assets.router'; import { transactionsRouter } from './transactions.router'; -import { getContracts, getGateway, getNetwork } from './fabric'; +import { getContracts, getGateway, getNetwork, getChainInfo } from './fabric'; import { redis } from './redis'; +import { Contract } from 'fabric-network'; -const { BAD_REQUEST, INTERNAL_SERVER_ERROR, NOT_FOUND, OK } = StatusCodes; +const { + BAD_REQUEST, + INTERNAL_SERVER_ERROR, + NOT_FOUND, + OK, + SERVICE_UNAVAILABLE, +} = StatusCodes; export const createServer = async (): Promise => { const app = express(); @@ -63,12 +70,20 @@ export const createServer = async (): Promise => { timestamp: new Date().toISOString(), }) ); - app.get('/live', (_req, res) => - res.status(OK).json({ - status: getReasonPhrase(OK), - timestamp: new Date().toISOString(), - }) - ); + app.get('/live', async (_req, res) => { + const qscc: Contract = _req.app.get('contracts').qscc; + if ((await getChainInfo(qscc)) === true) { + res.status(OK).json({ + status: getReasonPhrase(OK), + timestamp: new Date().toISOString(), + }); + } else { + res.status(SERVICE_UNAVAILABLE).json({ + status: getReasonPhrase(SERVICE_UNAVAILABLE), + timestamp: new Date().toISOString(), + }); + } + }); // TODO delete me app.get('/error', (_req, _res) => { From c3a34ef5593ba82fe006fe12c327332c9be64841 Mon Sep 17 00:00:00 2001 From: sapthasurendran Date: Tue, 27 Jul 2021 13:13:18 +0530 Subject: [PATCH 24/59] apikey auth for Org1 Signed-off-by: sapthasurendran removed auth check from live,ready apis.. code format http file changes for apikey comments for getting api key readme update for apikey usage replaced -H with --header apikey config made mandatory fix linting Signed-off-by: sapthasurendran --- README.md | 12 +++--- .../rest-api-typescript/package-lock.json | 37 +++++++++++++++++++ .../rest-api-typescript/package.json | 3 ++ .../scripts/generateEnv.sh | 2 + .../rest-api-typescript/src/auth.ts | 24 ++++++++++++ .../rest-api-typescript/src/config.ts | 6 +++ .../rest-api-typescript/src/server.ts | 20 +++++++++- demo.http | 12 +++++- 8 files changed, 107 insertions(+), 9 deletions(-) create mode 100644 asset-transfer-basic/rest-api-typescript/src/auth.ts diff --git a/README.md b/README.md index aaf539be..33e8175e 100644 --- a/README.md +++ b/README.md @@ -65,35 +65,35 @@ curl --include --request OPTIONS http://localhost:3000/api/assets/asset7 ### Create an asset... ```shell -curl --include --header "Content-Type: application/json" --request POST --data '{"id":"asset7","color":"red","size":42,"owner":"Jean","appraisedValue":101}' http://localhost:3000/api/assets +curl --include --header "Content-Type: application/json" --header "api-key:Api-Key " --request POST --data '{"id":"asset7","color":"red","size":42,"owner":"Jean","appraisedValue":101}' http://localhost:3000/api/assets ``` ### Read transaction status... ```shell -curl http://localhost:3000/api/transactions/__transaction_id__ +curl --header "api-key:Api-Key " http://localhost:3000/api/transactions/__transaction_id__ ``` ### Read an asset... ```shell -curl http://localhost:3000/api/assets/asset7 +curl --header "api-key:Api-Key " http://localhost:3000/api/assets/asset7 ``` ### Update an asset... ```shell -curl --include --header "Content-Type: application/json" --request PUT --data '{"id":"asset7","color":"red","size":11,"owner":"Jean","appraisedValue":101}' http://localhost:3000/api/assets/asset7 +curl --include --header "Content-Type: application/json" --header "api-key:Api-Key " --request PUT --data '{"id":"asset7","color":"red","size":11,"owner":"Jean","appraisedValue":101}' http://localhost:3000/api/assets/asset7 ``` ### Transfer an asset... ```shell -curl --include --header "Content-Type: application/json" --request PATCH --data '[{"op":"replace","path":"/owner","value":"Ashleigh"}]' http://localhost:3000/api/assets/asset7 +curl --include --header "Content-Type: application/json" --header "api-key:Api-Key " --request PATCH --data '[{"op":"replace","path":"/owner","value":"Ashleigh"}]' http://localhost:3000/api/assets/asset7 ``` ### Delete an asset... ```shell -curl --include --request DELETE http://localhost:3000/api/assets/asset7 +curl --include --header "api-key:Api-Key " --request DELETE http://localhost:3000/api/assets/asset7 ``` diff --git a/asset-transfer-basic/rest-api-typescript/package-lock.json b/asset-transfer-basic/rest-api-typescript/package-lock.json index 4c4d1114..4e25209a 100644 --- a/asset-transfer-basic/rest-api-typescript/package-lock.json +++ b/asset-transfer-basic/rest-api-typescript/package-lock.json @@ -268,6 +268,15 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-15.12.4.tgz", "integrity": "sha512-zrNj1+yqYF4WskCMOHwN+w9iuD12+dGm0rQ35HLl9/Ouuq52cEtd0CH9qMgrdNmi5ejC1/V7vKEXYubB+65DkA==" }, + "@types/passport": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.7.tgz", + "integrity": "sha512-JtswU8N3kxBYgo+n9of7C97YQBT+AYPP2aBfNGTzABqPAZnK/WOAaKfh3XesUYMZRrXFuoPc2Hv0/G/nQFveHw==", + "dev": true, + "requires": { + "@types/express": "*" + } + }, "@types/pino": { "version": "6.3.8", "resolved": "https://registry.npmjs.org/@types/pino/-/pino-6.3.8.tgz", @@ -1961,6 +1970,29 @@ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==" }, + "passport": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/passport/-/passport-0.4.1.tgz", + "integrity": "sha512-IxXgZZs8d7uFSt3eqNjM9NQ3g3uQCW5avD8mRNoXV99Yig50vjuaez6dQK2qC0kVWPRTujxY0dWgGfT09adjYg==", + "requires": { + "passport-strategy": "1.x.x", + "pause": "0.0.1" + } + }, + "passport-headerapikey": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/passport-headerapikey/-/passport-headerapikey-1.2.2.tgz", + "integrity": "sha512-4BvVJRrWsNJPrd3UoZfcnnl4zvUWYKEtfYkoDsaOKBsrWHYmzTApCjs7qUbncOLexE9ul0IRiYBFfBG0y9IVQA==", + "requires": { + "lodash": "^4.17.15", + "passport-strategy": "^1.0.0" + } + }, + "passport-strategy": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", + "integrity": "sha1-tVOaqPwiWj0a0XlHbd8ja0QPUuQ=" + }, "path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -1984,6 +2016,11 @@ "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", "dev": true }, + "pause": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", + "integrity": "sha1-HUCLP9t2kjuVQ9lvtMnf1TXZy10=" + }, "picomatch": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.0.tgz", diff --git a/asset-transfer-basic/rest-api-typescript/package.json b/asset-transfer-basic/rest-api-typescript/package.json index cfee92bd..1b1aa0e6 100644 --- a/asset-transfer-basic/rest-api-typescript/package.json +++ b/asset-transfer-basic/rest-api-typescript/package.json @@ -12,6 +12,8 @@ "helmet": "^4.6.0", "http-status-codes": "^2.1.4", "ioredis": "^4.27.6", + "passport": "^0.4.1", + "passport-headerapikey": "^1.2.2", "pino": "^6.11.3", "pino-http": "^5.5.0", "source-map-support": "^0.5.19" @@ -20,6 +22,7 @@ "@types/express": "^4.17.12", "@types/ioredis": "^4.26.4", "@types/node": "^15.12.4", + "@types/passport": "^1.0.7", "@types/pino": "^6.3.8", "@types/pino-http": "^5.4.1", "@typescript-eslint/eslint-plugin": "^4.28.0", diff --git a/asset-transfer-basic/rest-api-typescript/scripts/generateEnv.sh b/asset-transfer-basic/rest-api-typescript/scripts/generateEnv.sh index fa30cbdc..3d2b7030 100755 --- a/asset-transfer-basic/rest-api-typescript/scripts/generateEnv.sh +++ b/asset-transfer-basic/rest-api-typescript/scripts/generateEnv.sh @@ -30,6 +30,8 @@ REDIS_HOST=localhost REDIS_PORT=6379 +ORG1_APIKEY=$(uuidgen) + #REDIS_USERNAME= #REDIS_PASSWORD= diff --git a/asset-transfer-basic/rest-api-typescript/src/auth.ts b/asset-transfer-basic/rest-api-typescript/src/auth.ts new file mode 100644 index 00000000..dcd3e724 --- /dev/null +++ b/asset-transfer-basic/rest-api-typescript/src/auth.ts @@ -0,0 +1,24 @@ +import { logger } from './logger'; +import { HeaderAPIKeyStrategy } from 'passport-headerapikey'; +import * as config from './config'; +export const fabricAPIKeyStrategy: HeaderAPIKeyStrategy = + new HeaderAPIKeyStrategy( + { header: 'api-key', prefix: 'Api-Key ' }, + true, + function (apikey, done) { + const user: { org: string } = { + org: '', + }; + if (apikey === config.org1ApiKey) { + user.org = 'Org1'; + logger.info('Organisation set to Org1'); + done(null, user); + + //todo + //add org2 apikey check + } else { + logger.debug('APIKEY Mismatch'); + return done(null, false); + } + } + ); diff --git a/asset-transfer-basic/rest-api-typescript/src/config.ts b/asset-transfer-basic/rest-api-typescript/src/config.ts index cf967dcf..08c33865 100644 --- a/asset-transfer-basic/rest-api-typescript/src/config.ts +++ b/asset-transfer-basic/rest-api-typescript/src/config.ts @@ -97,3 +97,9 @@ export const redisUsername = env .asString(); export const redisPassword = env.get('REDIS_PASSWORD').asString(); + +export const org1ApiKey = env + .get('ORG1_APIKEY') + .required() + .example('123') + .asString(); diff --git a/asset-transfer-basic/rest-api-typescript/src/server.ts b/asset-transfer-basic/rest-api-typescript/src/server.ts index c0b51a38..a57dfcda 100644 --- a/asset-transfer-basic/rest-api-typescript/src/server.ts +++ b/asset-transfer-basic/rest-api-typescript/src/server.ts @@ -22,6 +22,8 @@ const { SERVICE_UNAVAILABLE, } = StatusCodes; +import { fabricAPIKeyStrategy } from './auth'; +import passport from 'passport'; export const createServer = async (): Promise => { const app = express(); @@ -48,6 +50,12 @@ export const createServer = async (): Promise => { app.use(express.json()); app.use(express.urlencoded({ extended: true })); + //define passport startegy + passport.use(fabricAPIKeyStrategy); + + //initialize passport js + app.use(passport.initialize()); + if (process.env.NODE_ENV === 'development') { // TBC } @@ -90,8 +98,16 @@ export const createServer = async (): Promise => { throw new Error('Example error'); }); - app.use('/api/assets', assetsRouter); - app.use('/api/transactions', transactionsRouter); + app.use( + '/api/assets', + passport.authenticate('headerapikey', { session: false }), + assetsRouter + ); + app.use( + '/api/transactions', + passport.authenticate('headerapikey', { session: false }), + transactionsRouter + ); // For everything else app.use((_req, res) => diff --git a/demo.http b/demo.http index 7873879f..b682c53e 100644 --- a/demo.http +++ b/demo.http @@ -4,18 +4,23 @@ @port = 3000 @baseUrl = http://{{hostname}}:{{port}}/api -### Get all assets +//Get the apikey from .env file +@api-key= Api-Key 295069C9-ABF5-4D2A-A020-2FF9F4E8DF07 +### Get all assets GET {{baseUrl}}/assets HTTP/1.1 +api-key: {{api-key}} ### Check if asset exists OPTIONS {{baseUrl}}/assets/asset7 HTTP/1.1 +api-key: {{api-key}} ### Create asset POST {{baseUrl}}/assets HTTP/1.1 content-type: application/json +api-key: {{api-key}} { "id": "asset7", @@ -28,15 +33,18 @@ content-type: application/json ### Read transaction status GET {{baseUrl}}/transactions/__transaction_id__ HTTP/1.1 +api-key: {{api-key}} ### Read asset GET {{baseUrl}}/assets/asset7 HTTP/1.1 +api-key: {{api-key}} ### Update asset PUT {{baseUrl}}/assets/asset7 HTTP/1.1 content-type: application/json +api-key: {{api-key}} { "id": "asset7", @@ -50,6 +58,7 @@ content-type: application/json PATCH {{baseUrl}}/assets/asset7 HTTP/1.1 content-type: application/json +api-key: {{api-key}} [ { @@ -62,3 +71,4 @@ content-type: application/json ### Delete asset DELETE {{baseUrl}}/assets/asset7 HTTP/1.1 +api-key: {{api-key}} From 05f7026e581826b7716cb343fdfcbf3c5d4a2558 Mon Sep 17 00:00:00 2001 From: James Taylor Date: Tue, 27 Jul 2021 15:35:40 +0100 Subject: [PATCH 25/59] Update api key header Use more common X-Api-Key header with no prefix Also updates Unauthorized response to include a json error body and simplifies working with new API key via curl and the vscode rest client Signed-off-by: James Taylor --- README.md | 26 ++++-- .../rest-api-typescript/demo.http | 84 +++++++++++++++++++ .../rest-api-typescript/src/auth.ts | 45 +++++++++- .../rest-api-typescript/src/server.ts | 14 +--- demo.http | 74 ---------------- 5 files changed, 145 insertions(+), 98 deletions(-) create mode 100644 asset-transfer-basic/rest-api-typescript/demo.http delete mode 100644 demo.http diff --git a/README.md b/README.md index 33e8175e..f8ba15c9 100644 --- a/README.md +++ b/README.md @@ -48,52 +48,60 @@ npm run start:dev ## REST API -If everything went well, you can now make basic asset transfer REST calls! For example... +If everything went well, you can now make basic asset transfer REST calls! + +The examples below require a `SAMPLE_APIKEY` environment variable which must be set to an API key from the `.env` file created above. + +For example, to use the ORG1_APIKEY... + +``` +SAMPLE_APIKEY=$(grep ORG1_APIKEY .env | cut -d '=' -f 2-) +``` ### Get all assets... ```shell -curl http://localhost:3000/api/assets +curl --header "X-Api-Key: ${SAMPLE_APIKEY}" http://localhost:3000/api/assets ``` ### Check whether an asset exists... ```shell -curl --include --request OPTIONS http://localhost:3000/api/assets/asset7 +curl --include --header "X-Api-Key: ${SAMPLE_APIKEY}" --request OPTIONS http://localhost:3000/api/assets/asset7 ``` ### Create an asset... ```shell -curl --include --header "Content-Type: application/json" --header "api-key:Api-Key " --request POST --data '{"id":"asset7","color":"red","size":42,"owner":"Jean","appraisedValue":101}' http://localhost:3000/api/assets +curl --include --header "Content-Type: application/json" --header "X-Api-Key: ${SAMPLE_APIKEY}" --request POST --data '{"id":"asset7","color":"red","size":42,"owner":"Jean","appraisedValue":101}' http://localhost:3000/api/assets ``` ### Read transaction status... ```shell -curl --header "api-key:Api-Key " http://localhost:3000/api/transactions/__transaction_id__ +curl --header "X-Api-Key: ${SAMPLE_APIKEY}" http://localhost:3000/api/transactions/__transaction_id__ ``` ### Read an asset... ```shell -curl --header "api-key:Api-Key " http://localhost:3000/api/assets/asset7 +curl --header "X-Api-Key: ${SAMPLE_APIKEY}" http://localhost:3000/api/assets/asset7 ``` ### Update an asset... ```shell -curl --include --header "Content-Type: application/json" --header "api-key:Api-Key " --request PUT --data '{"id":"asset7","color":"red","size":11,"owner":"Jean","appraisedValue":101}' http://localhost:3000/api/assets/asset7 +curl --include --header "Content-Type: application/json" --header "X-Api-Key: ${SAMPLE_APIKEY}" --request PUT --data '{"id":"asset7","color":"red","size":11,"owner":"Jean","appraisedValue":101}' http://localhost:3000/api/assets/asset7 ``` ### Transfer an asset... ```shell -curl --include --header "Content-Type: application/json" --header "api-key:Api-Key " --request PATCH --data '[{"op":"replace","path":"/owner","value":"Ashleigh"}]' http://localhost:3000/api/assets/asset7 +curl --include --header "Content-Type: application/json" --header "X-Api-Key: ${SAMPLE_APIKEY}" --request PATCH --data '[{"op":"replace","path":"/owner","value":"Ashleigh"}]' http://localhost:3000/api/assets/asset7 ``` ### Delete an asset... ```shell -curl --include --header "api-key:Api-Key " --request DELETE http://localhost:3000/api/assets/asset7 +curl --include --header "X-Api-Key: ${SAMPLE_APIKEY}" --request DELETE http://localhost:3000/api/assets/asset7 ``` diff --git a/asset-transfer-basic/rest-api-typescript/demo.http b/asset-transfer-basic/rest-api-typescript/demo.http new file mode 100644 index 00000000..163c7632 --- /dev/null +++ b/asset-transfer-basic/rest-api-typescript/demo.http @@ -0,0 +1,84 @@ +// Demo file for use with REST Client for Visual Studio Code +// See https://github.com/Huachao/vscode-restclient +// +// Edit the values below to match your environment if required +@hostname = localhost +@port = {{$dotenv PORT}} +@baseUrl = http://{{hostname}}:{{port}} +@apiUrl = {{baseUrl}}/api +@api-key = {{$dotenv ORG1_APIKEY}} + +### Check the server is ready + +GET {{baseUrl}}/ready HTTP/1.1 + +### Check the server is still live + +GET {{baseUrl}}/live HTTP/1.1 + +### Get all assets + +GET {{apiUrl}}/assets HTTP/1.1 +X-Api-Key: {{api-key}} + +### Check if asset exists + +OPTIONS {{apiUrl}}/assets/asset7 HTTP/1.1 +X-Api-Key: {{api-key}} + +### Create asset + +POST {{apiUrl}}/assets HTTP/1.1 +content-type: application/json +X-Api-Key: {{api-key}} + +{ + "id": "asset7", + "color": "red", + "size": 42, + "owner": "Jean", + "appraisedValue": 101 +} + +### Read transaction status + +GET {{apiUrl}}/transactions/__transaction_id__ HTTP/1.1 +X-Api-Key: {{api-key}} + +### Read asset + +GET {{apiUrl}}/assets/asset7 HTTP/1.1 +X-Api-Key: {{api-key}} + +### Update asset + +PUT {{apiUrl}}/assets/asset7 HTTP/1.1 +content-type: application/json +X-Api-Key: {{api-key}} + +{ + "id": "asset7", + "color": "red", + "size": 11, + "owner": "Jean", + "appraisedValue": 101 +} + +### Transfer asset + +PATCH {{apiUrl}}/assets/asset7 HTTP/1.1 +content-type: application/json +X-Api-Key: {{api-key}} + +[ + { + "op": "replace", + "path": "/owner", + "value": "Ashleigh" + } +] + +### Delete asset + +DELETE {{apiUrl}}/assets/asset7 HTTP/1.1 +X-Api-Key: {{api-key}} diff --git a/asset-transfer-basic/rest-api-typescript/src/auth.ts b/asset-transfer-basic/rest-api-typescript/src/auth.ts index dcd3e724..fe56dd0e 100644 --- a/asset-transfer-basic/rest-api-typescript/src/auth.ts +++ b/asset-transfer-basic/rest-api-typescript/src/auth.ts @@ -1,24 +1,61 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + */ + import { logger } from './logger'; +import passport from 'passport'; +import { NextFunction, Request, Response } from 'express'; import { HeaderAPIKeyStrategy } from 'passport-headerapikey'; +import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import * as config from './config'; + +const { UNAUTHORIZED } = StatusCodes; + export const fabricAPIKeyStrategy: HeaderAPIKeyStrategy = new HeaderAPIKeyStrategy( - { header: 'api-key', prefix: 'Api-Key ' }, - true, + { header: 'X-API-Key', prefix: '' }, + false, function (apikey, done) { + logger.debug({ apikey }, 'Checking X-API-Key'); const user: { org: string } = { org: '', }; if (apikey === config.org1ApiKey) { user.org = 'Org1'; - logger.info('Organisation set to Org1'); + logger.debug('Organisation set to Org1'); done(null, user); //todo //add org2 apikey check } else { - logger.debug('APIKEY Mismatch'); + logger.debug({ apikey }, 'No valid X-API-Key'); return done(null, false); } } ); + +export const authenticateApiKey = ( + req: Request, + res: Response, + next: NextFunction +): void => { + passport.authenticate( + 'headerapikey', + { session: false }, + function (err, user, _info) { + if (err) return next(err); + if (!user) + return res.status(UNAUTHORIZED).json({ + status: getReasonPhrase(UNAUTHORIZED), + reason: 'NO_VALID_APIKEY', + timestamp: new Date().toISOString(), + }); + req.logIn(user, { session: false }, (err) => { + if (err) { + return next(err); + } + return next(); + }); + } + )(req, res, next); +}; diff --git a/asset-transfer-basic/rest-api-typescript/src/server.ts b/asset-transfer-basic/rest-api-typescript/src/server.ts index a57dfcda..1b900248 100644 --- a/asset-transfer-basic/rest-api-typescript/src/server.ts +++ b/asset-transfer-basic/rest-api-typescript/src/server.ts @@ -22,7 +22,7 @@ const { SERVICE_UNAVAILABLE, } = StatusCodes; -import { fabricAPIKeyStrategy } from './auth'; +import { authenticateApiKey, fabricAPIKeyStrategy } from './auth'; import passport from 'passport'; export const createServer = async (): Promise => { const app = express(); @@ -98,16 +98,8 @@ export const createServer = async (): Promise => { throw new Error('Example error'); }); - app.use( - '/api/assets', - passport.authenticate('headerapikey', { session: false }), - assetsRouter - ); - app.use( - '/api/transactions', - passport.authenticate('headerapikey', { session: false }), - transactionsRouter - ); + app.use('/api/assets', authenticateApiKey, assetsRouter); + app.use('/api/transactions', authenticateApiKey, transactionsRouter); // For everything else app.use((_req, res) => diff --git a/demo.http b/demo.http deleted file mode 100644 index b682c53e..00000000 --- a/demo.http +++ /dev/null @@ -1,74 +0,0 @@ -// Demo file for use with REST Client for Visual Studio Code -// See https://github.com/Huachao/vscode-restclient -@hostname = localhost -@port = 3000 -@baseUrl = http://{{hostname}}:{{port}}/api - -//Get the apikey from .env file -@api-key= Api-Key 295069C9-ABF5-4D2A-A020-2FF9F4E8DF07 - -### Get all assets -GET {{baseUrl}}/assets HTTP/1.1 -api-key: {{api-key}} - -### Check if asset exists - -OPTIONS {{baseUrl}}/assets/asset7 HTTP/1.1 -api-key: {{api-key}} - -### Create asset - -POST {{baseUrl}}/assets HTTP/1.1 -content-type: application/json -api-key: {{api-key}} - -{ - "id": "asset7", - "color": "red", - "size": 42, - "owner": "Jean", - "appraisedValue": 101 -} - -### Read transaction status - -GET {{baseUrl}}/transactions/__transaction_id__ HTTP/1.1 -api-key: {{api-key}} - -### Read asset - -GET {{baseUrl}}/assets/asset7 HTTP/1.1 -api-key: {{api-key}} - -### Update asset - -PUT {{baseUrl}}/assets/asset7 HTTP/1.1 -content-type: application/json -api-key: {{api-key}} - -{ - "id": "asset7", - "color": "red", - "size": 11, - "owner": "Jean", - "appraisedValue": 101 -} - -### Transfer asset - -PATCH {{baseUrl}}/assets/asset7 HTTP/1.1 -content-type: application/json -api-key: {{api-key}} - -[ - { - "op": "replace", - "path": "/owner", - "value": "Ashleigh" - } -] - -### Delete asset - -DELETE {{baseUrl}}/assets/asset7 HTTP/1.1 -api-key: {{api-key}} From 9ae66c76da03fb266111617fa33351a8c2e1b0ab Mon Sep 17 00:00:00 2001 From: sapthasurendran Date: Wed, 28 Jul 2021 17:33:45 +0530 Subject: [PATCH 26/59] switch gateway based on user org Signed-off-by: sapthasurendran initialized fabric components to global app const getcontractforOrg for transaction router removed org and reused identityOrg1 env.sample file update liveness api update Signed-off-by: sapthasurendran --- .../rest-api-typescript/.env.sample | 17 ++++- .../scripts/generateEnv.sh | 25 +++++-- .../rest-api-typescript/src/assets.router.ts | 21 +++--- .../rest-api-typescript/src/auth.ts | 15 +++-- .../rest-api-typescript/src/config.ts | 67 ++++++++++++++++--- .../rest-api-typescript/src/fabric.ts | 38 ++++++++--- .../rest-api-typescript/src/index.ts | 6 +- .../rest-api-typescript/src/server.ts | 51 +++++++++++--- .../src/transactions.router.ts | 4 +- 9 files changed, 190 insertions(+), 54 deletions(-) diff --git a/asset-transfer-basic/rest-api-typescript/.env.sample b/asset-transfer-basic/rest-api-typescript/.env.sample index 9fe7cf46..f81d0dcf 100644 --- a/asset-transfer-basic/rest-api-typescript/.env.sample +++ b/asset-transfer-basic/rest-api-typescript/.env.sample @@ -4,11 +4,18 @@ PORT=3000 RETRY_DELAY=3000 -HLF_CONNECTION_PROFILE={"name":"test-network-org1","version":"1.0.0","client":{"organization":"Org1" ... } +HLF_CONNECTION_PROFILE_ORG1={"name":"test-network-org1","version":"1.0.0","client":{"organization":"Org1" ... } -HLF_CERTIFICATE="-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----\n" +HLF_CERTIFICATE_ORG1="-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----\n" + +HLF_PRIVATE_KEY_ORG1="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n" + +HLF_CONNECTION_PROFILE_ORG2={"name":"test-network-org2","version":"1.0.0","client":{"organization":"Org2" ... } + +HLF_CERTIFICATE_ORG2="-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----\n" + +HLF_PRIVATE_KEY_ORG2="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n" -HLF_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n" HLF_COMMIT_TIMEOUT=3000 @@ -18,6 +25,10 @@ REDIS_HOST=localhost REDIS_PORT=6379 +ORG1_APIKEY=D2F66BFF-D68B-458D-8FA6-285F172D5B03 + +ORG2_APIKEY=92042C1F-8E58-48F9-9EAF-91A98A2B7648 + #REDIS_USERNAME= #REDIS_PASSWORD= diff --git a/asset-transfer-basic/rest-api-typescript/scripts/generateEnv.sh b/asset-transfer-basic/rest-api-typescript/scripts/generateEnv.sh index 3d2b7030..c0af2374 100755 --- a/asset-transfer-basic/rest-api-typescript/scripts/generateEnv.sh +++ b/asset-transfer-basic/rest-api-typescript/scripts/generateEnv.sh @@ -5,9 +5,14 @@ # : "${TEST_NETWORK_HOME:=../..}" -: "${CONNECTION_PROFILE_FILE:=${TEST_NETWORK_HOME}/organizations/peerOrganizations/org1.example.com/connection-org1.json}" -: "${CERTIFICATE_FILE:=${TEST_NETWORK_HOME}/organizations/peerOrganizations/org1.example.com/users/Admin@org1.example.com/msp/signcerts/Admin@org1.example.com-cert.pem}" -: "${PRIVATE_KEY_FILE:=${TEST_NETWORK_HOME}/organizations/peerOrganizations/org1.example.com/users/Admin@org1.example.com/msp/keystore/priv_sk}" +: "${CONNECTION_PROFILE_FILE_ORG1:=${TEST_NETWORK_HOME}/organizations/peerOrganizations/org1.example.com/connection-org1.json}" +: "${CERTIFICATE_FILE_ORG1:=${TEST_NETWORK_HOME}/organizations/peerOrganizations/org1.example.com/users/Admin@org1.example.com/msp/signcerts/Admin@org1.example.com-cert.pem}" +: "${PRIVATE_KEY_FILE_ORG1:=${TEST_NETWORK_HOME}/organizations/peerOrganizations/org1.example.com/users/Admin@org1.example.com/msp/keystore/priv_sk}" + +: "${CONNECTION_PROFILE_FILE_ORG2:=${TEST_NETWORK_HOME}/organizations/peerOrganizations/org2.example.com/connection-org2.json}" +: "${CERTIFICATE_FILE_ORG2:=${TEST_NETWORK_HOME}/organizations/peerOrganizations/org2.example.com/users/Admin@org2.example.com/msp/signcerts/Admin@org2.example.com-cert.pem}" +: "${PRIVATE_KEY_FILE_ORG2:=${TEST_NETWORK_HOME}/organizations/peerOrganizations/org2.example.com/users/Admin@org2.example.com/msp/keystore/priv_sk}" + cat << ENV_END > .env LOG_LEVEL=info @@ -16,11 +21,17 @@ PORT=3000 RETRY_DELAY=3000 -HLF_CONNECTION_PROFILE=$(cat ${CONNECTION_PROFILE_FILE} | jq -c .) +HLF_CONNECTION_PROFILE_ORG1=$(cat ${CONNECTION_PROFILE_FILE_ORG1} | jq -c .) -HLF_CERTIFICATE="$(cat ${CERTIFICATE_FILE} | sed -e 's/$/\\n/' | tr -d '\r\n')" +HLF_CERTIFICATE_ORG1="$(cat ${CERTIFICATE_FILE_ORG1} | sed -e 's/$/\\n/' | tr -d '\r\n')" -HLF_PRIVATE_KEY="$(cat ${PRIVATE_KEY_FILE} | sed -e 's/$/\\n/' | tr -d '\r\n')" +HLF_PRIVATE_KEY_ORG1="$(cat ${PRIVATE_KEY_FILE_ORG1} | sed -e 's/$/\\n/' | tr -d '\r\n')" + +HLF_CONNECTION_PROFILE_ORG2=$(cat ${CONNECTION_PROFILE_FILE_ORG2} | jq -c .) + +HLF_CERTIFICATE_ORG2="$(cat ${CERTIFICATE_FILE_ORG2} | sed -e 's/$/\\n/' | tr -d '\r\n')" + +HLF_PRIVATE_KEY_ORG2="$(cat ${PRIVATE_KEY_FILE_ORG2} | sed -e 's/$/\\n/' | tr -d '\r\n')" HLF_COMMIT_TIMEOUT=3000 @@ -32,6 +43,8 @@ REDIS_PORT=6379 ORG1_APIKEY=$(uuidgen) +ORG2_APIKEY=$(uuidgen) + #REDIS_USERNAME= #REDIS_PASSWORD= diff --git a/asset-transfer-basic/rest-api-typescript/src/assets.router.ts b/asset-transfer-basic/rest-api-typescript/src/assets.router.ts index ccf5690a..b1d89a71 100644 --- a/asset-transfer-basic/rest-api-typescript/src/assets.router.ts +++ b/asset-transfer-basic/rest-api-typescript/src/assets.router.ts @@ -16,7 +16,11 @@ import { Contract } from 'fabric-network'; import { getReasonPhrase, StatusCodes } from 'http-status-codes'; import { Redis } from 'ioredis'; import { AssetExistsError, AssetNotFoundError } from './errors'; -import { evatuateTransaction, submitTransaction } from './fabric'; +import { + evatuateTransaction, + submitTransaction, + getContractForOrg, +} from './fabric'; import { logger } from './logger'; const { @@ -34,8 +38,7 @@ assetsRouter.get('/', async (req: Request, res: Response) => { logger.debug('Get all assets request received'); try { - const contract: Contract = req.app.get('contracts').contract; - + const contract: Contract = getContractForOrg(req).contract; const data = await evatuateTransaction(contract, 'GetAllAssets'); const assets = JSON.parse(data.toString()); @@ -71,7 +74,7 @@ assetsRouter.post( }); } - const contract: Contract = req.app.get('contracts').contract; + const contract: Contract = getContractForOrg(req).contract; const redis: Redis = req.app.get('redis'); const assetId = req.body.id; @@ -122,7 +125,7 @@ assetsRouter.options('/:assetId', async (req: Request, res: Response) => { logger.debug('Asset options request received for asset ID %s', assetId); try { - const contract: Contract = req.app.get('contracts').contract; + const contract: Contract = getContractForOrg(req).contract; const data = await evatuateTransaction(contract, 'AssetExists', assetId); const exists = data.toString() === 'true'; @@ -161,7 +164,7 @@ assetsRouter.get('/:assetId', async (req: Request, res: Response) => { logger.debug('Read asset request received for asset ID %s', assetId); try { - const contract: Contract = req.app.get('contracts').contract; + const contract: Contract = getContractForOrg(req).contract; const data = await evatuateTransaction(contract, 'ReadAsset', assetId); const asset = JSON.parse(data.toString()); @@ -219,7 +222,7 @@ assetsRouter.put( }); } - const contract: Contract = req.app.get('contracts').contract; + const contract: Contract = getContractForOrg(req).contract; const redis: Redis = req.app.get('redis'); const assetId = req.params.assetId; @@ -288,7 +291,7 @@ assetsRouter.patch( }); } - const contract: Contract = req.app.get('contracts').contract; + const contract: Contract = getContractForOrg(req).contract; const redis: Redis = req.app.get('redis'); const assetId = req.params.assetId; const newOwner = req.body[0].value; @@ -333,7 +336,7 @@ assetsRouter.patch( assetsRouter.delete('/:assetId', async (req: Request, res: Response) => { logger.debug(req.body, 'Delete asset request received'); - const contract: Contract = req.app.get('contracts').contract; + const contract: Contract = getContractForOrg(req).contract; const redis: Redis = req.app.get('redis'); const assetId = req.params.assetId; diff --git a/asset-transfer-basic/rest-api-typescript/src/auth.ts b/asset-transfer-basic/rest-api-typescript/src/auth.ts index fe56dd0e..fd40ed43 100644 --- a/asset-transfer-basic/rest-api-typescript/src/auth.ts +++ b/asset-transfer-basic/rest-api-typescript/src/auth.ts @@ -21,12 +21,13 @@ export const fabricAPIKeyStrategy: HeaderAPIKeyStrategy = org: '', }; if (apikey === config.org1ApiKey) { - user.org = 'Org1'; + user.org = config.identityNameOrg1; logger.debug('Organisation set to Org1'); done(null, user); - - //todo - //add org2 apikey check + } else if (apikey === config.org2ApiKey) { + user.org = config.identityNameOrg2; + logger.info('Organisation set to Org2'); + done(null, user); } else { logger.debug({ apikey }, 'No valid X-API-Key'); return done(null, false); @@ -42,7 +43,8 @@ export const authenticateApiKey = ( passport.authenticate( 'headerapikey', { session: false }, - function (err, user, _info) { + (err, user, _info) => { + logger.debug({ user }, 'USERUSERUSER'); if (err) return next(err); if (!user) return res.status(UNAUTHORIZED).json({ @@ -50,7 +52,8 @@ export const authenticateApiKey = ( reason: 'NO_VALID_APIKEY', timestamp: new Date().toISOString(), }); - req.logIn(user, { session: false }, (err) => { + + req.logIn(user, { session: false }, async (err) => { if (err) { return next(err); } diff --git a/asset-transfer-basic/rest-api-typescript/src/config.ts b/asset-transfer-basic/rest-api-typescript/src/config.ts index 08c33865..94c4d998 100644 --- a/asset-transfer-basic/rest-api-typescript/src/config.ts +++ b/asset-transfer-basic/rest-api-typescript/src/config.ts @@ -27,14 +27,21 @@ export const asLocalHost = env .example('true') .asBoolStrict(); -export const identityName = 'restServerIdentity'; +export const identityNameOrg1 = 'Org1'; +export const identityNameOrg2 = 'Org2'; -export const mspId = env - .get('HLF_MSP_ID') +const mspIdOrg1 = env + .get('HLF_MSP_ID_ORG1') .default('Org1MSP') .example('Org1MSP') .asString(); +const mspIdOrg2 = env + .get('HLF_MSP_ID_ORG2') + .default('Org2MSP') + .example('Org2MSP') + .asString(); + export const channelName = env .get('HLF_CHANNEL_NAME') .default('mychannel') @@ -59,22 +66,42 @@ export const endorseTimeout = env .example('30') .asIntPositive(); -export const connectionProfile = env - .get('HLF_CONNECTION_PROFILE') +const connectionProfileOrg1 = env + .get('HLF_CONNECTION_PROFILE_ORG1') .required() .example( '{"name":"test-network-org1","version":"1.0.0","client":{"organization":"Org1" ... }' ) .asJsonObject(); -export const certificate = env - .get('HLF_CERTIFICATE') +const certificateOrg1 = env + .get('HLF_CERTIFICATE_ORG1') .required() .example('"-----BEGIN CERTIFICATE-----\\n...\\n-----END CERTIFICATE-----\\n"') .asString(); -export const privateKey = env - .get('HLF_PRIVATE_KEY') +const privateKeyOrg1 = env + .get('HLF_PRIVATE_KEY_ORG1') + .required() + .example('"-----BEGIN PRIVATE KEY-----\\n...\\n-----END PRIVATE KEY-----\\n"') + .asString(); + +const connectionProfileOrg2 = env + .get('HLF_CONNECTION_PROFILE_ORG2') + .required() + .example( + '{"name":"test-network-org2","version":"1.0.0","client":{"organization":"Org2" ... }' + ) + .asJsonObject(); + +const certificateOrg2 = env + .get('HLF_CERTIFICATE_ORG2') + .required() + .example('"-----BEGIN CERTIFICATE-----\\n...\\n-----END CERTIFICATE-----\\n"') + .asString(); + +const privateKeyOrg2 = env + .get('HLF_PRIVATE_KEY_ORG2') .required() .example('"-----BEGIN PRIVATE KEY-----\\n...\\n-----END PRIVATE KEY-----\\n"') .asString(); @@ -103,3 +130,25 @@ export const org1ApiKey = env .required() .example('123') .asString(); + +export const org2ApiKey = env + .get('ORG2_APIKEY') + .required() + .example('456') + .asString(); + +export const ORG1_CONFIG = { + identityName: identityNameOrg1, + mspId: mspIdOrg1, + connectionProfile: connectionProfileOrg1, + certificate: certificateOrg1, + privateKey: privateKeyOrg1, +}; + +export const ORG2_CONFIG = { + identityName: identityNameOrg2, + mspId: mspIdOrg2, + connectionProfile: connectionProfileOrg2, + certificate: certificateOrg2, + privateKey: privateKeyOrg2, +}; diff --git a/asset-transfer-basic/rest-api-typescript/src/fabric.ts b/asset-transfer-basic/rest-api-typescript/src/fabric.ts index d96abe9b..1db73941 100644 --- a/asset-transfer-basic/rest-api-typescript/src/fabric.ts +++ b/asset-transfer-basic/rest-api-typescript/src/fabric.ts @@ -14,6 +14,7 @@ import { BlockEvent, TransactionEvent, } from 'fabric-network'; +import { Request } from 'express'; import { Redis } from 'ioredis'; import * as config from './config'; import { logger } from './logger'; @@ -31,24 +32,39 @@ export const getNetwork = async (gateway: Gateway): Promise => { return network; }; -export const getGateway = async (): Promise => { +interface FabricConfigType { + identityName: string; + mspId: string; + connectionProfile: { [key: string]: any }; + certificate: string; + privateKey: string; +} + +const FabricDataMapper: { [key: string]: FabricConfigType } = { + [config.identityNameOrg1]: config.ORG1_CONFIG, + [config.identityNameOrg2]: config.ORG2_CONFIG, +}; + +export const getGateway = async (org: string): Promise => { + const fabricConfig = FabricDataMapper[org]; + logger.debug('Configuring fabric gateway for %s', org); const wallet = await Wallets.newInMemoryWallet(); const x509Identity = { credentials: { - certificate: config.certificate, - privateKey: config.privateKey, + certificate: fabricConfig.certificate, + privateKey: fabricConfig.privateKey, }, - mspId: config.mspId, + mspId: fabricConfig.mspId, type: 'X.509', }; - await wallet.put(config.identityName, x509Identity); + await wallet.put(fabricConfig.identityName, x509Identity); const gateway = new Gateway(); const connectOptions: GatewayOptions = { wallet, - identity: config.identityName, + identity: fabricConfig.identityName, discovery: { enabled: true, asLocalhost: config.asLocalHost }, eventHandlerOptions: { commitTimeout: config.commitTimeout, @@ -61,8 +77,7 @@ export const getGateway = async (): Promise => { }, }; - await gateway.connect(config.connectionProfile, connectOptions); - + await gateway.connect(fabricConfig.connectionProfile, connectOptions); return gateway; }; @@ -306,3 +321,10 @@ export const getChainInfo = async (qscc: Contract): Promise => { return false; } }; + +export const getContractForOrg = ( + req: Request +): { contract: Contract; qscc: Contract } => { + const user: { org: string } = req.user as { org: string }; + return req.app.get('fabric')[user.org as string].contracts; +}; diff --git a/asset-transfer-basic/rest-api-typescript/src/index.ts b/asset-transfer-basic/rest-api-typescript/src/index.ts index 9d65028e..94814eb6 100644 --- a/asset-transfer-basic/rest-api-typescript/src/index.ts +++ b/asset-transfer-basic/rest-api-typescript/src/index.ts @@ -12,9 +12,11 @@ import { createServer } from './server'; async function main() { const app = await createServer(); - const contract: Contract = app.get('contracts').contract; + const contract: Contract = + app.get('fabric')[config.identityNameOrg1].contracts.contract; const redis: Redis = app.get('redis'); - const network: Network = app.get('network'); + const network: Network = app.get('fabric')[config.identityNameOrg1].network; + await network.addBlockListener(blockEventHandler(redis)); startRetryLoop(contract, redis); diff --git a/asset-transfer-basic/rest-api-typescript/src/server.ts b/asset-transfer-basic/rest-api-typescript/src/server.ts index 1b900248..31dc77c7 100644 --- a/asset-transfer-basic/rest-api-typescript/src/server.ts +++ b/asset-transfer-basic/rest-api-typescript/src/server.ts @@ -10,10 +10,16 @@ import pinoMiddleware from 'pino-http'; import { logger } from './logger'; import { assetsRouter } from './assets.router'; import { transactionsRouter } from './transactions.router'; -import { getContracts, getGateway, getNetwork, getChainInfo } from './fabric'; +import { + getContracts, + getGateway, + getNetwork, + getChainInfo, + getContractForOrg, +} from './fabric'; import { redis } from './redis'; import { Contract } from 'fabric-network'; - +import * as config from './config'; const { BAD_REQUEST, INTERNAL_SERVER_ERROR, @@ -24,6 +30,7 @@ const { import { authenticateApiKey, fabricAPIKeyStrategy } from './auth'; import passport from 'passport'; + export const createServer = async (): Promise => { const app = express(); @@ -63,13 +70,31 @@ export const createServer = async (): Promise => { if (process.env.NODE_ENV === 'production') { app.use(helmet()); } + // + const gatewayOrg1 = await getGateway(config.identityNameOrg1); + const gatewayOrg2 = await getGateway(config.identityNameOrg2); + const networkOrg1 = await getNetwork(gatewayOrg1); + const networkOrg2 = await getNetwork(gatewayOrg2); + + const contractsOrg1 = await getContracts(networkOrg1); + const contractsOrg2 = await getContracts(networkOrg2); + + const fabric = { + [config.identityNameOrg1]: { + gateway: gatewayOrg1, + contracts: contractsOrg1, + network: networkOrg1, + }, + [config.identityNameOrg2]: { + gateway: gatewayOrg2, + contracts: contractsOrg2, + network: networkOrg2, + }, + }; + + app.set('fabric', fabric); - const gateway = await getGateway(); - const network = await getNetwork(gateway); - const contracts = await getContracts(network); - app.set('contracts', contracts); app.set('redis', redis); - app.set('network', network); // Health routes app.get('/ready', (_req, res) => @@ -79,8 +104,16 @@ export const createServer = async (): Promise => { }) ); app.get('/live', async (_req, res) => { - const qscc: Contract = _req.app.get('contracts').qscc; - if ((await getChainInfo(qscc)) === true) { + _req.user = { org: config.identityNameOrg1 }; + const qsccOrg1: Contract = getContractForOrg(_req).qscc; + const Org1Liveness = await getChainInfo(qsccOrg1); + logger.debug('Org1 liveness %s', Org1Liveness); + _req.user = { org: config.identityNameOrg2 }; + const qsccOrg2: Contract = getContractForOrg(_req).qscc; + const Org2Liveness = await getChainInfo(qsccOrg2); + logger.debug('Org2 liveness %s', Org2Liveness); + + if (Org1Liveness && Org2Liveness) { res.status(OK).json({ status: getReasonPhrase(OK), timestamp: new Date().toISOString(), diff --git a/asset-transfer-basic/rest-api-typescript/src/transactions.router.ts b/asset-transfer-basic/rest-api-typescript/src/transactions.router.ts index 2a0bc987..c3dd49bf 100644 --- a/asset-transfer-basic/rest-api-typescript/src/transactions.router.ts +++ b/asset-transfer-basic/rest-api-typescript/src/transactions.router.ts @@ -7,7 +7,7 @@ import { Contract } from 'fabric-network'; import { protos } from 'fabric-protos'; import { getReasonPhrase, StatusCodes } from 'http-status-codes'; import { Redis } from 'ioredis'; -import { evatuateTransaction } from './fabric'; +import { evatuateTransaction, getContractForOrg } from './fabric'; import { logger } from './logger'; import * as config from './config'; import { TransactionNotFoundError } from './errors'; @@ -28,7 +28,7 @@ transactionsRouter.get( let progress: Progress = 'DONE'; let validationCode = ''; - const qscc: Contract = req.app.get('contracts').qscc; + const qscc: Contract = getContractForOrg(req).qscc; const redis: Redis = req.app.get('redis'); try { From 9f48a42418800e6a03b3d20374b7e11d04b7c231 Mon Sep 17 00:00:00 2001 From: James Taylor Date: Mon, 26 Jul 2021 12:06:31 +0100 Subject: [PATCH 27/59] Initial test framework Add Jest test framework Signed-off-by: James Taylor --- .../rest-api-typescript/jest.config.ts | 192 + .../rest-api-typescript/package-lock.json | 3825 ++++++++++++++++- .../rest-api-typescript/package.json | 10 +- .../src/__mocks__/config.ts | 55 + .../src/__mocks__/fabric-network.ts | 41 + .../src/__tests__/api.test.ts | 31 + .../rest-api-typescript/src/config.ts | 33 +- .../rest-api-typescript/src/fabric.ts | 20 +- .../rest-api-typescript/src/server.ts | 4 + 9 files changed, 4180 insertions(+), 31 deletions(-) create mode 100644 asset-transfer-basic/rest-api-typescript/jest.config.ts create mode 100644 asset-transfer-basic/rest-api-typescript/src/__mocks__/config.ts create mode 100644 asset-transfer-basic/rest-api-typescript/src/__mocks__/fabric-network.ts create mode 100644 asset-transfer-basic/rest-api-typescript/src/__tests__/api.test.ts diff --git a/asset-transfer-basic/rest-api-typescript/jest.config.ts b/asset-transfer-basic/rest-api-typescript/jest.config.ts new file mode 100644 index 00000000..ba3bbbc4 --- /dev/null +++ b/asset-transfer-basic/rest-api-typescript/jest.config.ts @@ -0,0 +1,192 @@ +/* + * For a detailed explanation regarding each configuration property and type check, visit: + * https://jestjs.io/docs/configuration + */ + +export default { + // All imported modules in your tests should be mocked automatically + // automock: false, + + // Stop running tests after `n` failures + // bail: 0, + + // The directory where Jest should store its cached dependency information + // cacheDirectory: "/private/var/folders/04/rqvxpdk52gvf1_qq9l8gt4d40000gn/T/jest_dx", + + // Automatically clear mock calls and instances between every test + clearMocks: true, + + // Indicates whether the coverage information should be collected while executing the test + collectCoverage: true, + + // An array of glob patterns indicating a set of files for which coverage information should be collected + // collectCoverageFrom: undefined, + + // The directory where Jest should output its coverage files + coverageDirectory: 'coverage', + + // An array of regexp pattern strings used to skip coverage collection + // coveragePathIgnorePatterns: [ + // "/node_modules/" + // ], + + // Indicates which provider should be used to instrument code for coverage + coverageProvider: 'v8', + + // A list of reporter names that Jest uses when writing coverage reports + // coverageReporters: [ + // "json", + // "text", + // "lcov", + // "clover" + // ], + + // An object that configures minimum threshold enforcement for coverage results + // coverageThreshold: undefined, + + // A path to a custom dependency extractor + // dependencyExtractor: undefined, + + // Make calling deprecated APIs throw helpful error messages + // errorOnDeprecated: false, + + // Force coverage collection from ignored files using an array of glob patterns + // forceCoverageMatch: [], + + // A path to a module which exports an async function that is triggered once before all test suites + // globalSetup: undefined, + + // A path to a module which exports an async function that is triggered once after all test suites + // globalTeardown: undefined, + + // A set of global variables that need to be available in all test environments + // globals: {}, + + // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers. + // maxWorkers: "50%", + + // An array of directory names to be searched recursively up from the requiring module's location + // moduleDirectories: [ + // "node_modules" + // ], + + // An array of file extensions your modules use + // moduleFileExtensions: [ + // "js", + // "jsx", + // "ts", + // "tsx", + // "json", + // "node" + // ], + + // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module + // moduleNameMapper: {}, + + // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader + // modulePathIgnorePatterns: [], + + // Activates notifications for test results + // notify: false, + + // An enum that specifies notification mode. Requires { notify: true } + // notifyMode: "failure-change", + + // A preset that is used as a base for Jest's configuration + preset: 'ts-jest', + + // Run tests from one or more projects + // projects: undefined, + + // Use this configuration option to add custom reporters to Jest + // reporters: undefined, + + // Automatically reset mock state between every test + // resetMocks: false, + + // Reset the module registry before running each individual test + // resetModules: false, + + // A path to a custom resolver + // resolver: undefined, + + // Automatically restore mock state between every test + // restoreMocks: false, + + // The root directory that Jest should scan for tests and modules within + // rootDir: undefined, + + // A list of paths to directories that Jest should use to search for files in + roots: ['/src'], + + // Allows you to use a custom runner instead of Jest's default test runner + // runner: "jest-runner", + + // The paths to modules that run some code to configure or set up the testing environment before each test + // setupFiles: [], + + // A list of paths to modules that run some code to configure or set up the testing framework before each test + // setupFilesAfterEnv: [], + + // The number of seconds after which a test is considered as slow and reported as such in the results. + // slowTestThreshold: 5, + + // A list of paths to snapshot serializer modules Jest should use for snapshot testing + // snapshotSerializers: [], + + // The test environment that will be used for testing + // testEnvironment: "jest-environment-node", + + // Options that will be passed to the testEnvironment + // testEnvironmentOptions: {}, + + // Adds a location field to test results + // testLocationInResults: false, + + // The glob patterns Jest uses to detect test files + testMatch: [ + // "**/__tests__/**/*.[jt]s?(x)", + '**/?(*.)+(spec|test).[tj]s?(x)', + ], + + // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped + // testPathIgnorePatterns: [ + // "/node_modules/" + // ], + + // The regexp pattern or array of patterns that Jest uses to detect test files + // testRegex: [], + + // This option allows the use of a custom results processor + // testResultsProcessor: undefined, + + // This option allows use of a custom test runner + // testRunner: "jest-circus/runner", + + // This option sets the URL for the jsdom environment. It is reflected in properties such as location.href + // testURL: "http://localhost", + + // Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout" + // timers: "real", + + // A map from regular expressions to paths to transformers + // transform: undefined, + + // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation + // transformIgnorePatterns: [ + // "/node_modules/", + // "\\.pnp\\.[^\\/]+$" + // ], + + // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them + // unmockedModulePathPatterns: undefined, + + // Indicates whether each individual test should be reported during the run + // verbose: undefined, + + // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode + // watchPathIgnorePatterns: [], + + // Whether to use watchman for file crawling + // watchman: true, +}; diff --git a/asset-transfer-basic/rest-api-typescript/package-lock.json b/asset-transfer-basic/rest-api-typescript/package-lock.json index 4e25209a..4270a380 100644 --- a/asset-transfer-basic/rest-api-typescript/package-lock.json +++ b/asset-transfer-basic/rest-api-typescript/package-lock.json @@ -13,12 +13,251 @@ "@babel/highlight": "^7.10.4" } }, + "@babel/compat-data": { + "version": "7.14.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.14.7.tgz", + "integrity": "sha512-nS6dZaISCXJ3+518CWiBfEr//gHyMO02uDxBkXTKZDN5POruCnOZ1N4YBRZDCabwF8nZMWBpRxIicmXtBs+fvw==", + "dev": true + }, + "@babel/core": { + "version": "7.14.8", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.14.8.tgz", + "integrity": "sha512-/AtaeEhT6ErpDhInbXmjHcUQXH0L0TEgscfcxk1qbOvLuKCa5aZT0SOOtDKFY96/CLROwbLSKyFor6idgNaU4Q==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.14.5", + "@babel/generator": "^7.14.8", + "@babel/helper-compilation-targets": "^7.14.5", + "@babel/helper-module-transforms": "^7.14.8", + "@babel/helpers": "^7.14.8", + "@babel/parser": "^7.14.8", + "@babel/template": "^7.14.5", + "@babel/traverse": "^7.14.8", + "@babel/types": "^7.14.8", + "convert-source-map": "^1.7.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.1.2", + "semver": "^6.3.0", + "source-map": "^0.5.0" + }, + "dependencies": { + "@babel/code-frame": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.14.5.tgz", + "integrity": "sha512-9pzDqyc6OLDaqe+zbACgFkb6fKMNG6CObKpnYXChRsvYGyEdc7CA2BaqeOM+vOtCS5ndmJicPJhKAwYRI6UfFw==", + "dev": true, + "requires": { + "@babel/highlight": "^7.14.5" + } + }, + "debug": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.2.tgz", + "integrity": "sha512-mOp8wKcvj7XxC78zLgw/ZA+6TSgkoE2C/ienthhRD298T7UNwAg9diBpLRxC0mOezLl4B0xV7M0cCO6P/O0Xhw==", + "dev": true, + "requires": { + "ms": "2.1.2" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true + }, + "source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=", + "dev": true + } + } + }, + "@babel/generator": { + "version": "7.14.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.14.8.tgz", + "integrity": "sha512-cYDUpvIzhBVnMzRoY1fkSEhK/HmwEVwlyULYgn/tMQYd6Obag3ylCjONle3gdErfXBW61SVTlR9QR7uWlgeIkg==", + "dev": true, + "requires": { + "@babel/types": "^7.14.8", + "jsesc": "^2.5.1", + "source-map": "^0.5.0" + }, + "dependencies": { + "source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=", + "dev": true + } + } + }, + "@babel/helper-compilation-targets": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.14.5.tgz", + "integrity": "sha512-v+QtZqXEiOnpO6EYvlImB6zCD2Lel06RzOPzmkz/D/XgQiUu3C/Jb1LOqSt/AIA34TYi/Q+KlT8vTQrgdxkbLw==", + "dev": true, + "requires": { + "@babel/compat-data": "^7.14.5", + "@babel/helper-validator-option": "^7.14.5", + "browserslist": "^4.16.6", + "semver": "^6.3.0" + }, + "dependencies": { + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true + } + } + }, + "@babel/helper-function-name": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.14.5.tgz", + "integrity": "sha512-Gjna0AsXWfFvrAuX+VKcN/aNNWonizBj39yGwUzVDVTlMYJMK2Wp6xdpy72mfArFq5uK+NOuexfzZlzI1z9+AQ==", + "dev": true, + "requires": { + "@babel/helper-get-function-arity": "^7.14.5", + "@babel/template": "^7.14.5", + "@babel/types": "^7.14.5" + } + }, + "@babel/helper-get-function-arity": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/helper-get-function-arity/-/helper-get-function-arity-7.14.5.tgz", + "integrity": "sha512-I1Db4Shst5lewOM4V+ZKJzQ0JGGaZ6VY1jYvMghRjqs6DWgxLCIyFt30GlnKkfUeFLpJt2vzbMVEXVSXlIFYUg==", + "dev": true, + "requires": { + "@babel/types": "^7.14.5" + } + }, + "@babel/helper-hoist-variables": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.14.5.tgz", + "integrity": "sha512-R1PXiz31Uc0Vxy4OEOm07x0oSjKAdPPCh3tPivn/Eo8cvz6gveAeuyUUPB21Hoiif0uoPQSSdhIPS3352nvdyQ==", + "dev": true, + "requires": { + "@babel/types": "^7.14.5" + } + }, + "@babel/helper-member-expression-to-functions": { + "version": "7.14.7", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.14.7.tgz", + "integrity": "sha512-TMUt4xKxJn6ccjcOW7c4hlwyJArizskAhoSTOCkA0uZ+KghIaci0Qg9R043kUMWI9mtQfgny+NQ5QATnZ+paaA==", + "dev": true, + "requires": { + "@babel/types": "^7.14.5" + } + }, + "@babel/helper-module-imports": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.14.5.tgz", + "integrity": "sha512-SwrNHu5QWS84XlHwGYPDtCxcA0hrSlL2yhWYLgeOc0w7ccOl2qv4s/nARI0aYZW+bSwAL5CukeXA47B/1NKcnQ==", + "dev": true, + "requires": { + "@babel/types": "^7.14.5" + } + }, + "@babel/helper-module-transforms": { + "version": "7.14.8", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.14.8.tgz", + "integrity": "sha512-RyE+NFOjXn5A9YU1dkpeBaduagTlZ0+fccnIcAGbv1KGUlReBj7utF7oEth8IdIBQPcux0DDgW5MFBH2xu9KcA==", + "dev": true, + "requires": { + "@babel/helper-module-imports": "^7.14.5", + "@babel/helper-replace-supers": "^7.14.5", + "@babel/helper-simple-access": "^7.14.8", + "@babel/helper-split-export-declaration": "^7.14.5", + "@babel/helper-validator-identifier": "^7.14.8", + "@babel/template": "^7.14.5", + "@babel/traverse": "^7.14.8", + "@babel/types": "^7.14.8" + }, + "dependencies": { + "@babel/helper-validator-identifier": { + "version": "7.14.8", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.14.8.tgz", + "integrity": "sha512-ZGy6/XQjllhYQrNw/3zfWRwZCTVSiBLZ9DHVZxn9n2gip/7ab8mv2TWlKPIBk26RwedCBoWdjLmn+t9na2Gcow==", + "dev": true + } + } + }, + "@babel/helper-optimise-call-expression": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.14.5.tgz", + "integrity": "sha512-IqiLIrODUOdnPU9/F8ib1Fx2ohlgDhxnIDU7OEVi+kAbEZcyiF7BLU8W6PfvPi9LzztjS7kcbzbmL7oG8kD6VA==", + "dev": true, + "requires": { + "@babel/types": "^7.14.5" + } + }, + "@babel/helper-plugin-utils": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.14.5.tgz", + "integrity": "sha512-/37qQCE3K0vvZKwoK4XU/irIJQdIfCJuhU5eKnNxpFDsOkgFaUAwbv+RYw6eYgsC0E4hS7r5KqGULUogqui0fQ==", + "dev": true + }, + "@babel/helper-replace-supers": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.14.5.tgz", + "integrity": "sha512-3i1Qe9/8x/hCHINujn+iuHy+mMRLoc77b2nI9TB0zjH1hvn9qGlXjWlggdwUcju36PkPCy/lpM7LLUdcTyH4Ow==", + "dev": true, + "requires": { + "@babel/helper-member-expression-to-functions": "^7.14.5", + "@babel/helper-optimise-call-expression": "^7.14.5", + "@babel/traverse": "^7.14.5", + "@babel/types": "^7.14.5" + } + }, + "@babel/helper-simple-access": { + "version": "7.14.8", + "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.14.8.tgz", + "integrity": "sha512-TrFN4RHh9gnWEU+s7JloIho2T76GPwRHhdzOWLqTrMnlas8T9O7ec+oEDNsRXndOmru9ymH9DFrEOxpzPoSbdg==", + "dev": true, + "requires": { + "@babel/types": "^7.14.8" + } + }, + "@babel/helper-split-export-declaration": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.14.5.tgz", + "integrity": "sha512-hprxVPu6e5Kdp2puZUmvOGjaLv9TCe58E/Fl6hRq4YiVQxIcNvuq6uTM2r1mT/oPskuS9CgR+I94sqAYv0NGKA==", + "dev": true, + "requires": { + "@babel/types": "^7.14.5" + } + }, "@babel/helper-validator-identifier": { "version": "7.14.5", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.14.5.tgz", "integrity": "sha512-5lsetuxCLilmVGyiLEfoHBRX8UCFD+1m2x3Rj97WrW3V7H3u4RWRXA4evMjImCsin2J2YT0QaVDGf+z8ondbAg==", "dev": true }, + "@babel/helper-validator-option": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.14.5.tgz", + "integrity": "sha512-OX8D5eeX4XwcroVW45NMvoYaIuFI+GQpA2a8Gi+X/U/cDUIRsV37qQfF905F0htTRCREQIB4KqPeaveRJUl3Ow==", + "dev": true + }, + "@babel/helpers": { + "version": "7.14.8", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.14.8.tgz", + "integrity": "sha512-ZRDmI56pnV+p1dH6d+UN6GINGz7Krps3+270qqI9UJ4wxYThfAIcI5i7j5vXC4FJ3Wap+S9qcebxeYiqn87DZw==", + "dev": true, + "requires": { + "@babel/template": "^7.14.5", + "@babel/traverse": "^7.14.8", + "@babel/types": "^7.14.8" + } + }, "@babel/highlight": { "version": "7.14.5", "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.14.5.tgz", @@ -49,6 +288,224 @@ } } }, + "@babel/parser": { + "version": "7.14.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.14.8.tgz", + "integrity": "sha512-syoCQFOoo/fzkWDeM0dLEZi5xqurb5vuyzwIMNZRNun+N/9A4cUZeQaE7dTrB8jGaKuJRBtEOajtnmw0I5hvvA==", + "dev": true + }, + "@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.0" + } + }, + "@babel/plugin-syntax-bigint": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", + "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.0" + } + }, + "@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.12.13" + } + }, + "@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.10.4" + } + }, + "@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.0" + } + }, + "@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.10.4" + } + }, + "@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.0" + } + }, + "@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.10.4" + } + }, + "@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.0" + } + }, + "@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.0" + } + }, + "@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.0" + } + }, + "@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.14.5" + } + }, + "@babel/plugin-syntax-typescript": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.14.5.tgz", + "integrity": "sha512-u6OXzDaIXjEstBRRoBCQ/uKQKlbuaeE5in0RvWdA4pN6AhqxTIwUsnHPU1CFZA/amYObMsuWhYfRl3Ch90HD0Q==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.14.5" + } + }, + "@babel/template": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.14.5.tgz", + "integrity": "sha512-6Z3Po85sfxRGachLULUhOmvAaOo7xCvqGQtxINai2mEGPFm6pQ4z5QInFnUrRpfoSV60BnjyF5F3c+15fxFV1g==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.14.5", + "@babel/parser": "^7.14.5", + "@babel/types": "^7.14.5" + }, + "dependencies": { + "@babel/code-frame": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.14.5.tgz", + "integrity": "sha512-9pzDqyc6OLDaqe+zbACgFkb6fKMNG6CObKpnYXChRsvYGyEdc7CA2BaqeOM+vOtCS5ndmJicPJhKAwYRI6UfFw==", + "dev": true, + "requires": { + "@babel/highlight": "^7.14.5" + } + } + } + }, + "@babel/traverse": { + "version": "7.14.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.14.8.tgz", + "integrity": "sha512-kexHhzCljJcFNn1KYAQ6A5wxMRzq9ebYpEDV4+WdNyr3i7O44tanbDOR/xjiG2F3sllan+LgwK+7OMk0EmydHg==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.14.5", + "@babel/generator": "^7.14.8", + "@babel/helper-function-name": "^7.14.5", + "@babel/helper-hoist-variables": "^7.14.5", + "@babel/helper-split-export-declaration": "^7.14.5", + "@babel/parser": "^7.14.8", + "@babel/types": "^7.14.8", + "debug": "^4.1.0", + "globals": "^11.1.0" + }, + "dependencies": { + "@babel/code-frame": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.14.5.tgz", + "integrity": "sha512-9pzDqyc6OLDaqe+zbACgFkb6fKMNG6CObKpnYXChRsvYGyEdc7CA2BaqeOM+vOtCS5ndmJicPJhKAwYRI6UfFw==", + "dev": true, + "requires": { + "@babel/highlight": "^7.14.5" + } + }, + "debug": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.2.tgz", + "integrity": "sha512-mOp8wKcvj7XxC78zLgw/ZA+6TSgkoE2C/ienthhRD298T7UNwAg9diBpLRxC0mOezLl4B0xV7M0cCO6P/O0Xhw==", + "dev": true, + "requires": { + "ms": "2.1.2" + } + }, + "globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + } + } + }, + "@babel/types": { + "version": "7.14.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.14.8.tgz", + "integrity": "sha512-iob4soQa7dZw8nodR/KlOQkPh9S4I8RwCxwRIFuiMRYjOzH/KJzdUfDgz6cGi5dDaclXF4P2PAhCdrBJNIg68Q==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.14.8", + "to-fast-properties": "^2.0.0" + }, + "dependencies": { + "@babel/helper-validator-identifier": { + "version": "7.14.8", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.14.8.tgz", + "integrity": "sha512-ZGy6/XQjllhYQrNw/3zfWRwZCTVSiBLZ9DHVZxn9n2gip/7ab8mv2TWlKPIBk26RwedCBoWdjLmn+t9na2Gcow==", + "dev": true + } + } + }, + "@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true + }, "@eslint/eslintrc": { "version": "0.4.2", "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-0.4.2.tgz", @@ -115,6 +572,422 @@ "integrity": "sha512-WEezM1FWztfbzqIUbsDzFRVMxSoLy3HugVcux6KDDtTqzPsLE8NDRHfXvev66aH1i2oOKKar3/XDjbvh/OUBdg==", "dev": true }, + "@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, + "requires": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "dependencies": { + "camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true + }, + "resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true + } + } + }, + "@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true + }, + "@jest/console": { + "version": "27.0.6", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-27.0.6.tgz", + "integrity": "sha512-fMlIBocSHPZ3JxgWiDNW/KPj6s+YRd0hicb33IrmelCcjXo/pXPwvuiKFmZz+XuqI/1u7nbUK10zSsWL/1aegg==", + "dev": true, + "requires": { + "@jest/types": "^27.0.6", + "@types/node": "*", + "chalk": "^4.0.0", + "jest-message-util": "^27.0.6", + "jest-util": "^27.0.6", + "slash": "^3.0.0" + }, + "dependencies": { + "@jest/types": { + "version": "27.0.6", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.0.6.tgz", + "integrity": "sha512-aSquT1qa9Pik26JK5/3rvnYb4bGtm1VFNesHKmNTwmPIgOrixvhL2ghIvFRNEpzy3gU+rUgjIF/KodbkFAl++g==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + } + }, + "@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "dev": true, + "requires": { + "@types/yargs-parser": "*" + } + } + } + }, + "@jest/core": { + "version": "27.0.6", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-27.0.6.tgz", + "integrity": "sha512-SsYBm3yhqOn5ZLJCtccaBcvD/ccTLCeuDv8U41WJH/V1MW5eKUkeMHT9U+Pw/v1m1AIWlnIW/eM2XzQr0rEmow==", + "dev": true, + "requires": { + "@jest/console": "^27.0.6", + "@jest/reporters": "^27.0.6", + "@jest/test-result": "^27.0.6", + "@jest/transform": "^27.0.6", + "@jest/types": "^27.0.6", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "emittery": "^0.8.1", + "exit": "^0.1.2", + "graceful-fs": "^4.2.4", + "jest-changed-files": "^27.0.6", + "jest-config": "^27.0.6", + "jest-haste-map": "^27.0.6", + "jest-message-util": "^27.0.6", + "jest-regex-util": "^27.0.6", + "jest-resolve": "^27.0.6", + "jest-resolve-dependencies": "^27.0.6", + "jest-runner": "^27.0.6", + "jest-runtime": "^27.0.6", + "jest-snapshot": "^27.0.6", + "jest-util": "^27.0.6", + "jest-validate": "^27.0.6", + "jest-watcher": "^27.0.6", + "micromatch": "^4.0.4", + "p-each-series": "^2.1.0", + "rimraf": "^3.0.0", + "slash": "^3.0.0", + "strip-ansi": "^6.0.0" + }, + "dependencies": { + "@jest/types": { + "version": "27.0.6", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.0.6.tgz", + "integrity": "sha512-aSquT1qa9Pik26JK5/3rvnYb4bGtm1VFNesHKmNTwmPIgOrixvhL2ghIvFRNEpzy3gU+rUgjIF/KodbkFAl++g==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + } + }, + "@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "dev": true, + "requires": { + "@types/yargs-parser": "*" + } + } + } + }, + "@jest/environment": { + "version": "27.0.6", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-27.0.6.tgz", + "integrity": "sha512-4XywtdhwZwCpPJ/qfAkqExRsERW+UaoSRStSHCCiQTUpoYdLukj+YJbQSFrZjhlUDRZeNiU9SFH0u7iNimdiIg==", + "dev": true, + "requires": { + "@jest/fake-timers": "^27.0.6", + "@jest/types": "^27.0.6", + "@types/node": "*", + "jest-mock": "^27.0.6" + }, + "dependencies": { + "@jest/types": { + "version": "27.0.6", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.0.6.tgz", + "integrity": "sha512-aSquT1qa9Pik26JK5/3rvnYb4bGtm1VFNesHKmNTwmPIgOrixvhL2ghIvFRNEpzy3gU+rUgjIF/KodbkFAl++g==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + } + }, + "@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "dev": true, + "requires": { + "@types/yargs-parser": "*" + } + } + } + }, + "@jest/fake-timers": { + "version": "27.0.6", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-27.0.6.tgz", + "integrity": "sha512-sqd+xTWtZ94l3yWDKnRTdvTeZ+A/V7SSKrxsrOKSqdyddb9CeNRF8fbhAU0D7ZJBpTTW2nbp6MftmKJDZfW2LQ==", + "dev": true, + "requires": { + "@jest/types": "^27.0.6", + "@sinonjs/fake-timers": "^7.0.2", + "@types/node": "*", + "jest-message-util": "^27.0.6", + "jest-mock": "^27.0.6", + "jest-util": "^27.0.6" + }, + "dependencies": { + "@jest/types": { + "version": "27.0.6", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.0.6.tgz", + "integrity": "sha512-aSquT1qa9Pik26JK5/3rvnYb4bGtm1VFNesHKmNTwmPIgOrixvhL2ghIvFRNEpzy3gU+rUgjIF/KodbkFAl++g==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + } + }, + "@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "dev": true, + "requires": { + "@types/yargs-parser": "*" + } + } + } + }, + "@jest/globals": { + "version": "27.0.6", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-27.0.6.tgz", + "integrity": "sha512-DdTGCP606rh9bjkdQ7VvChV18iS7q0IMJVP1piwTWyWskol4iqcVwthZmoJEf7obE1nc34OpIyoVGPeqLC+ryw==", + "dev": true, + "requires": { + "@jest/environment": "^27.0.6", + "@jest/types": "^27.0.6", + "expect": "^27.0.6" + }, + "dependencies": { + "@jest/types": { + "version": "27.0.6", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.0.6.tgz", + "integrity": "sha512-aSquT1qa9Pik26JK5/3rvnYb4bGtm1VFNesHKmNTwmPIgOrixvhL2ghIvFRNEpzy3gU+rUgjIF/KodbkFAl++g==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + } + }, + "@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "dev": true, + "requires": { + "@types/yargs-parser": "*" + } + } + } + }, + "@jest/reporters": { + "version": "27.0.6", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-27.0.6.tgz", + "integrity": "sha512-TIkBt09Cb2gptji3yJXb3EE+eVltW6BjO7frO7NEfjI9vSIYoISi5R3aI3KpEDXlB1xwB+97NXIqz84qYeYsfA==", + "dev": true, + "requires": { + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "^27.0.6", + "@jest/test-result": "^27.0.6", + "@jest/transform": "^27.0.6", + "@jest/types": "^27.0.6", + "chalk": "^4.0.0", + "collect-v8-coverage": "^1.0.0", + "exit": "^0.1.2", + "glob": "^7.1.2", + "graceful-fs": "^4.2.4", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^4.0.3", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^4.0.0", + "istanbul-reports": "^3.0.2", + "jest-haste-map": "^27.0.6", + "jest-resolve": "^27.0.6", + "jest-util": "^27.0.6", + "jest-worker": "^27.0.6", + "slash": "^3.0.0", + "source-map": "^0.6.0", + "string-length": "^4.0.1", + "terminal-link": "^2.0.0", + "v8-to-istanbul": "^8.0.0" + }, + "dependencies": { + "@jest/types": { + "version": "27.0.6", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.0.6.tgz", + "integrity": "sha512-aSquT1qa9Pik26JK5/3rvnYb4bGtm1VFNesHKmNTwmPIgOrixvhL2ghIvFRNEpzy3gU+rUgjIF/KodbkFAl++g==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + } + }, + "@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "dev": true, + "requires": { + "@types/yargs-parser": "*" + } + } + } + }, + "@jest/source-map": { + "version": "27.0.6", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-27.0.6.tgz", + "integrity": "sha512-Fek4mi5KQrqmlY07T23JRi0e7Z9bXTOOD86V/uS0EIW4PClvPDqZOyFlLpNJheS6QI0FNX1CgmPjtJ4EA/2M+g==", + "dev": true, + "requires": { + "callsites": "^3.0.0", + "graceful-fs": "^4.2.4", + "source-map": "^0.6.0" + } + }, + "@jest/test-result": { + "version": "27.0.6", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-27.0.6.tgz", + "integrity": "sha512-ja/pBOMTufjX4JLEauLxE3LQBPaI2YjGFtXexRAjt1I/MbfNlMx0sytSX3tn5hSLzQsR3Qy2rd0hc1BWojtj9w==", + "dev": true, + "requires": { + "@jest/console": "^27.0.6", + "@jest/types": "^27.0.6", + "@types/istanbul-lib-coverage": "^2.0.0", + "collect-v8-coverage": "^1.0.0" + }, + "dependencies": { + "@jest/types": { + "version": "27.0.6", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.0.6.tgz", + "integrity": "sha512-aSquT1qa9Pik26JK5/3rvnYb4bGtm1VFNesHKmNTwmPIgOrixvhL2ghIvFRNEpzy3gU+rUgjIF/KodbkFAl++g==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + } + }, + "@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "dev": true, + "requires": { + "@types/yargs-parser": "*" + } + } + } + }, + "@jest/test-sequencer": { + "version": "27.0.6", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-27.0.6.tgz", + "integrity": "sha512-bISzNIApazYOlTHDum9PwW22NOyDa6VI31n6JucpjTVM0jD6JDgqEZ9+yn575nDdPF0+4csYDxNNW13NvFQGZA==", + "dev": true, + "requires": { + "@jest/test-result": "^27.0.6", + "graceful-fs": "^4.2.4", + "jest-haste-map": "^27.0.6", + "jest-runtime": "^27.0.6" + } + }, + "@jest/transform": { + "version": "27.0.6", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-27.0.6.tgz", + "integrity": "sha512-rj5Dw+mtIcntAUnMlW/Vju5mr73u8yg+irnHwzgtgoeI6cCPOvUwQ0D1uQtc/APmWgvRweEb1g05pkUpxH3iCA==", + "dev": true, + "requires": { + "@babel/core": "^7.1.0", + "@jest/types": "^27.0.6", + "babel-plugin-istanbul": "^6.0.0", + "chalk": "^4.0.0", + "convert-source-map": "^1.4.0", + "fast-json-stable-stringify": "^2.0.0", + "graceful-fs": "^4.2.4", + "jest-haste-map": "^27.0.6", + "jest-regex-util": "^27.0.6", + "jest-util": "^27.0.6", + "micromatch": "^4.0.4", + "pirates": "^4.0.1", + "slash": "^3.0.0", + "source-map": "^0.6.1", + "write-file-atomic": "^3.0.0" + }, + "dependencies": { + "@jest/types": { + "version": "27.0.6", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.0.6.tgz", + "integrity": "sha512-aSquT1qa9Pik26JK5/3rvnYb4bGtm1VFNesHKmNTwmPIgOrixvhL2ghIvFRNEpzy3gU+rUgjIF/KodbkFAl++g==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + } + }, + "@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "dev": true, + "requires": { + "@types/yargs-parser": "*" + } + } + } + }, + "@jest/types": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-26.6.2.tgz", + "integrity": "sha512-fC6QCp7Sc5sX6g8Tvbmj4XUTbyrik0akgRy03yjXbQaBWWNWGE7SGtJk98m0N8nzegD/7SggrUlivxo5ax4KWQ==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^15.0.0", + "chalk": "^4.0.0" + } + }, "@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -195,6 +1068,95 @@ "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", "integrity": "sha1-p3c2C1s5oaLlEG+OhY8v0tBgxXA=" }, + "@sinonjs/commons": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.3.tgz", + "integrity": "sha512-xkNcLAn/wZaX14RPlwizcKicDk9G3F8m2nU3L7Ukm5zBgTwiT0wsoFAHx9Jq56fJA1z/7uKGtCRu16sOUCLIHQ==", + "dev": true, + "requires": { + "type-detect": "4.0.8" + } + }, + "@sinonjs/fake-timers": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-7.1.2.tgz", + "integrity": "sha512-iQADsW4LBMISqZ6Ci1dupJL9pprqwcVFTcOsEmQOEhW+KLCVn/Y4Jrvg2k19fIHCp+iFprriYPTdRcQR8NbUPg==", + "dev": true, + "requires": { + "@sinonjs/commons": "^1.7.0" + } + }, + "@tootallnate/once": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", + "integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==", + "dev": true + }, + "@tsconfig/node10": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.8.tgz", + "integrity": "sha512-6XFfSQmMgq0CFLY1MslA/CPUfhIL919M1rMsa5lP2P097N2Wd1sSX0tx1u4olM16fLNhtHZpRhedZJphNJqmZg==", + "dev": true + }, + "@tsconfig/node12": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.9.tgz", + "integrity": "sha512-/yBMcem+fbvhSREH+s14YJi18sp7J9jpuhYByADT2rypfajMZZN4WQ6zBGgBKp53NKmqI36wFYDb3yaMPurITw==", + "dev": true + }, + "@tsconfig/node14": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.1.tgz", + "integrity": "sha512-509r2+yARFfHHE7T6Puu2jjkoycftovhXRqW328PDXTVGKihlb1P8Z9mMZH04ebyajfRY7dedfGynlrFHJUQCg==", + "dev": true + }, + "@tsconfig/node16": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.2.tgz", + "integrity": "sha512-eZxlbI8GZscaGS7kkc/trHTT5xgrjH3/1n2JDwusC9iahPKWMRvRjJSAN5mCXviuTGQ/lHnhvv8Q1YTpnfz9gA==", + "dev": true + }, + "@types/babel__core": { + "version": "7.1.15", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.1.15.tgz", + "integrity": "sha512-bxlMKPDbY8x5h6HBwVzEOk2C8fb6SLfYQ5Jw3uBYuYF1lfWk/kbLd81la82vrIkBb0l+JdmrZaDikPrNxpS/Ew==", + "dev": true, + "requires": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "@types/babel__generator": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.6.3.tgz", + "integrity": "sha512-/GWCmzJWqV7diQW54smJZzWbSFf4QYtF71WCKhcx6Ru/tFyQIY2eiiITcCAeuPbNSvT9YCGkVMqqvSk2Z0mXiA==", + "dev": true, + "requires": { + "@babel/types": "^7.0.0" + } + }, + "@types/babel__template": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.1.tgz", + "integrity": "sha512-azBFKemX6kMg5Io+/rdGT0dkGreboUVR0Cdm3fz9QJWpaQGJRQXl7C+6hOTCZcMll7KFyEQpgbYI2lHdsS4U7g==", + "dev": true, + "requires": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "@types/babel__traverse": { + "version": "7.14.2", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.14.2.tgz", + "integrity": "sha512-K2waXdXBi2302XUdcHcR1jCeU0LL4TD9HRs/gk0N2Xvrht+G/BfJa4QObBQZfhMdxiCpV3COl5Nfq4uKTeTnJA==", + "dev": true, + "requires": { + "@babel/types": "^7.3.0" + } + }, "@types/body-parser": { "version": "1.19.0", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.0.tgz", @@ -214,6 +1176,12 @@ "@types/node": "*" } }, + "@types/cookiejar": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.2.tgz", + "integrity": "sha512-t73xJJrvdTjXrn4jLS9VSGRbz0nUY3cl2DMGDU48lKl+HR9dbbjW2A9r3g40VA++mQpy6uuHg33gy7du2BKpog==", + "dev": true + }, "@types/express": { "version": "4.17.12", "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.12.tgz", @@ -237,6 +1205,15 @@ "@types/range-parser": "*" } }, + "@types/graceful-fs": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.5.tgz", + "integrity": "sha512-anKkLmZZ+xm4p8JWBf4hElkM4XR+EZeA2M9BAkkTldmcyDY4mbdIJnRghDJH3Ov5ooY7/UAoENtmdMSkaAd7Cw==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/ioredis": { "version": "4.26.4", "resolved": "https://registry.npmjs.org/@types/ioredis/-/ioredis-4.26.4.tgz", @@ -246,6 +1223,40 @@ "@types/node": "*" } }, + "@types/istanbul-lib-coverage": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.3.tgz", + "integrity": "sha512-sz7iLqvVUg1gIedBOvlkxPlc8/uVzyS5OwGz1cKjXzkl3FpL3al0crU8YGU1WoHkxn0Wxbw5tyi6hvzJKNzFsw==", + "dev": true + }, + "@types/istanbul-lib-report": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz", + "integrity": "sha512-plGgXAPfVKFoYfa9NpYDAkseG+g6Jr294RqeqcqDixSbU34MZVJRi/P+7Y8GDpzkEwLaGZZOpKIEmeVZNtKsrg==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "*" + } + }, + "@types/istanbul-reports": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.1.tgz", + "integrity": "sha512-c3mAZEuK0lvBp8tmuL74XRKn1+y2dcwOUpH7x4WrF6gk1GIgiluDRgMYQtw2OFcBvAJWlt6ASU3tSqxp0Uu0Aw==", + "dev": true, + "requires": { + "@types/istanbul-lib-report": "*" + } + }, + "@types/jest": { + "version": "26.0.24", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-26.0.24.tgz", + "integrity": "sha512-E/X5Vib8BWqZNRlDxj9vYXhsDwPYbPINqKF9BsnSoon4RQ0D9moEuLD8txgyypFLH7J4+Lho9Nr/c8H0Fi+17w==", + "dev": true, + "requires": { + "jest-diff": "^26.0.0", + "pretty-format": "^26.0.0" + } + }, "@types/json-schema": { "version": "7.0.7", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.7.tgz", @@ -316,6 +1327,12 @@ "@types/node": "*" } }, + "@types/prettier": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.3.2.tgz", + "integrity": "sha512-eI5Yrz3Qv4KPUa/nSIAi0h+qX0XyewOliug5F2QAtuRg6Kjg6jfmxe1GIwoIRhZspD1A0RP8ANrPwvEXXtRFog==", + "dev": true + }, "@types/qs": { "version": "6.9.6", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.6.tgz", @@ -347,11 +1364,51 @@ "@types/node": "*" } }, + "@types/stack-utils": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.1.tgz", + "integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==", + "dev": true + }, + "@types/superagent": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-4.1.12.tgz", + "integrity": "sha512-1GQvD6sySQPD6p9EopDFI3f5OogdICl1sU/2ij3Esobz/RtL9fWZZDPmsuv7eiy5ya+XNiPAxUcI3HIUTJa+3A==", + "dev": true, + "requires": { + "@types/cookiejar": "*", + "@types/node": "*" + } + }, + "@types/supertest": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-2.0.11.tgz", + "integrity": "sha512-uci4Esokrw9qGb9bvhhSVEjd6rkny/dk5PK/Qz4yxKiyppEI+dOPlNrZBahE3i+PoKFYyDxChVXZ/ysS/nrm1Q==", + "dev": true, + "requires": { + "@types/superagent": "*" + } + }, "@types/tough-cookie": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.0.tgz", "integrity": "sha512-I99sngh224D0M7XgW1s120zxCt3VYQ3IQsuw3P3jbq5GG4yc79+ZjyKznyOGIQrflfylLgcfekeZW/vk0yng6A==" }, + "@types/yargs": { + "version": "15.0.14", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-15.0.14.tgz", + "integrity": "sha512-yEJzHoxf6SyQGhBhIYGXQDSCkJjB6HohDShto7m8vaKg9Yp0Yn8+71J9eakh2bnPg6BfsH9PRMhiRTZnd4eXGQ==", + "dev": true, + "requires": { + "@types/yargs-parser": "*" + } + }, + "@types/yargs-parser": { + "version": "20.2.1", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-20.2.1.tgz", + "integrity": "sha512-7tFImggNeNBVMsn0vLrpn1H1uPrUBdnARPTpZoitY37ZrdJREzf7I16tMrlK3hen349gr1NYh8CmZQa7CTG6Aw==", + "dev": true + }, "@typescript-eslint/eslint-plugin": { "version": "4.28.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-4.28.0.tgz", @@ -485,6 +1542,12 @@ "eslint-visitor-keys": "^2.0.0" } }, + "abab": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.5.tgz", + "integrity": "sha512-9IK9EadsbHo6jLWIpxpR6pL0sazTXV6+SQv25ZB+F7Bj9mJNaOc4nCRabwd5M/JwmUa8idz6Eci6eKfJryPs6Q==", + "dev": true + }, "accepts": { "version": "1.3.7", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.7.tgz", @@ -500,12 +1563,54 @@ "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", "dev": true }, + "acorn-globals": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-6.0.0.tgz", + "integrity": "sha512-ZQl7LOWaF5ePqqcX4hLuv/bLXYQNfNWw2c0/yX/TsPRKamzHcTGQnlCjHT3TsmkOUVEPS3crCxiPfdzE/Trlhg==", + "dev": true, + "requires": { + "acorn": "^7.1.1", + "acorn-walk": "^7.1.1" + } + }, "acorn-jsx": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.1.tgz", "integrity": "sha512-K0Ptm/47OKfQRpNQ2J/oIN/3QYiK6FwW+eJbILhsdxh2WTLdl+30o8aGdTbm5JbffpFFAg/g+zi1E+jvJha5ng==", "dev": true }, + "acorn-walk": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-7.2.0.tgz", + "integrity": "sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA==", + "dev": true + }, + "agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, + "requires": { + "debug": "4" + }, + "dependencies": { + "debug": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.2.tgz", + "integrity": "sha512-mOp8wKcvj7XxC78zLgw/ZA+6TSgkoE2C/ienthhRD298T7UNwAg9diBpLRxC0mOezLl4B0xV7M0cCO6P/O0Xhw==", + "dev": true, + "requires": { + "ms": "2.1.2" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + } + } + }, "ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -524,6 +1629,23 @@ "integrity": "sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA==", "dev": true }, + "ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "requires": { + "type-fest": "^0.21.3" + }, + "dependencies": { + "type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true + } + } + }, "ansi-regex": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz", @@ -538,6 +1660,22 @@ "color-convert": "^1.9.0" } }, + "anymatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.2.tgz", + "integrity": "sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==", + "dev": true, + "requires": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + } + }, + "arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true + }, "argparse": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", @@ -600,6 +1738,12 @@ "resolved": "https://registry.npmjs.org/async/-/async-1.5.2.tgz", "integrity": "sha1-7GphrlZIDAw8skHJVhjiCJL5Zyo=" }, + "asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=", + "dev": true + }, "atomic-sleep": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", @@ -622,6 +1766,101 @@ "pify": "^5.0.0" } }, + "babel-jest": { + "version": "27.0.6", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-27.0.6.tgz", + "integrity": "sha512-iTJyYLNc4wRofASmofpOc5NK9QunwMk+TLFgGXsTFS8uEqmd8wdI7sga0FPe2oVH3b5Agt/EAK1QjPEuKL8VfA==", + "dev": true, + "requires": { + "@jest/transform": "^27.0.6", + "@jest/types": "^27.0.6", + "@types/babel__core": "^7.1.14", + "babel-plugin-istanbul": "^6.0.0", + "babel-preset-jest": "^27.0.6", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.4", + "slash": "^3.0.0" + }, + "dependencies": { + "@jest/types": { + "version": "27.0.6", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.0.6.tgz", + "integrity": "sha512-aSquT1qa9Pik26JK5/3rvnYb4bGtm1VFNesHKmNTwmPIgOrixvhL2ghIvFRNEpzy3gU+rUgjIF/KodbkFAl++g==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + } + }, + "@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "dev": true, + "requires": { + "@types/yargs-parser": "*" + } + } + } + }, + "babel-plugin-istanbul": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.0.0.tgz", + "integrity": "sha512-AF55rZXpe7trmEylbaE1Gv54wn6rwU03aptvRoVIGP8YykoSxqdVLV1TfwflBCE/QtHmqtP8SWlTENqbK8GCSQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-instrument": "^4.0.0", + "test-exclude": "^6.0.0" + } + }, + "babel-plugin-jest-hoist": { + "version": "27.0.6", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-27.0.6.tgz", + "integrity": "sha512-CewFeM9Vv2gM7Yr9n5eyyLVPRSiBnk6lKZRjgwYnGKSl9M14TMn2vkN02wTF04OGuSDLEzlWiMzvjXuW9mB6Gw==", + "dev": true, + "requires": { + "@babel/template": "^7.3.3", + "@babel/types": "^7.3.3", + "@types/babel__core": "^7.0.0", + "@types/babel__traverse": "^7.0.6" + } + }, + "babel-preset-current-node-syntax": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.0.1.tgz", + "integrity": "sha512-M7LQ0bxarkxQoN+vz5aJPsLBn77n8QgTFmo8WK0/44auK2xlCXrYcUxHFxgU7qW5Yzw/CjmLRK2uJzaCd7LvqQ==", + "dev": true, + "requires": { + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-bigint": "^7.8.3", + "@babel/plugin-syntax-class-properties": "^7.8.3", + "@babel/plugin-syntax-import-meta": "^7.8.3", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.8.3", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.8.3", + "@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-top-level-await": "^7.8.3" + } + }, + "babel-preset-jest": { + "version": "27.0.6", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-27.0.6.tgz", + "integrity": "sha512-WObA0/Biw2LrVVwZkF/2GqbOdzhKD6Fkdwhoy9ASIrOWr/zodcSpQh72JOkEn6NWyjmnPDjNSqaGN4KnpKzhXw==", + "dev": true, + "requires": { + "babel-plugin-jest-hoist": "^27.0.6", + "babel-preset-current-node-syntax": "^1.0.0" + } + }, "balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -674,6 +1913,43 @@ "resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz", "integrity": "sha1-EsJe/kCkXjwyPrhnWgoM5XsiNx8=" }, + "browser-process-hrtime": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz", + "integrity": "sha512-9o5UecI3GhkpM6DrXr69PblIuWxPKk9Y0jHBRhdocZ2y7YECBFCsHm79Pr3OyR2AvjhDkabFJaDJMYRazHgsow==", + "dev": true + }, + "browserslist": { + "version": "4.16.6", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.16.6.tgz", + "integrity": "sha512-Wspk/PqO+4W9qp5iUTJsa1B/QrYn1keNCcEP5OvP7WBwT4KaDly0uONYmC6Xa3Z5IqnUgS0KcgLYu1l74x0ZXQ==", + "dev": true, + "requires": { + "caniuse-lite": "^1.0.30001219", + "colorette": "^1.2.2", + "electron-to-chromium": "^1.3.723", + "escalade": "^3.1.1", + "node-releases": "^1.1.71" + } + }, + "bs-logger": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz", + "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", + "dev": true, + "requires": { + "fast-json-stable-stringify": "2.x" + } + }, + "bser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "dev": true, + "requires": { + "node-int64": "^0.4.0" + } + }, "buffer-from": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz", @@ -710,6 +1986,12 @@ "integrity": "sha512-faqwZqnWxbxn+F1d399ygeamQNy3lPp/H9H6rNrqYh4FSVCtcY+3cub1MxA8o9mDd55mM8Aghuu/kuyYA6VTsA==", "dev": true }, + "caniuse-lite": { + "version": "1.0.30001248", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001248.tgz", + "integrity": "sha512-NwlQbJkxUFJ8nMErnGtT0QTM2TJ33xgz4KXJSMIrjXIbDVdaYueGyjOrLKRtJC+rTiWfi6j5cnZN1NBiSBJGNw==", + "dev": true + }, "chalk": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.1.tgz", @@ -761,6 +2043,24 @@ } } }, + "char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "dev": true + }, + "ci-info": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.2.0.tgz", + "integrity": "sha512-dVqRX7fLUm8J6FgHJ418XuIgDLZDkYcDFTeL6TA2gt5WlIZUQrrH6EZrNClwT/H0FateUsZkGIOPRrLbP+PR9A==", + "dev": true + }, + "cjs-module-lexer": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.2.2.tgz", + "integrity": "sha512-cOU9usZw8/dXIXKtwa8pM0OTJQuJkxMN6w30csNRUerHfeQ5R6U3kkU/FtJeIf3M202OHfY2U8ccInBG7/xogA==", + "dev": true + }, "cliui": { "version": "7.0.4", "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", @@ -776,6 +2076,18 @@ "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.0.tgz", "integrity": "sha512-2Nii8p3RwAPiFwsnZvukotvow2rIHM+yQ6ZcBXGHdniadkYGZYiGmkHJIbZPIV9nfv7m/U1IPMVVcAhoWFeklw==" }, + "co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ=", + "dev": true + }, + "collect-v8-coverage": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.1.tgz", + "integrity": "sha512-iBPtljfCNcTKNAto0KEtDfZ3qzjJvqE3aTGZsbhjSBlorqpXJlaWWtPO35D+ZImoC3KWejX64o+yPGxhWSTzfg==", + "dev": true + }, "color-convert": { "version": "1.9.3", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", @@ -791,11 +2103,32 @@ "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=", "dev": true }, + "colorette": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.2.2.tgz", + "integrity": "sha512-MKGMzyfeuutC/ZJ1cba9NqcNpfeqMUcYmyF1ZFY6/Cn7CNSAKx6a+s48sqLqyAiZuaP2TcqMhoo+dlwFnVxT9w==", + "dev": true + }, "colors": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/colors/-/colors-1.0.3.tgz", "integrity": "sha1-BDP0TYCWgP3rYO0mDxsMJi6CpAs=" }, + "combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "requires": { + "delayed-stream": "~1.0.0" + } + }, + "component-emitter": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz", + "integrity": "sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==", + "dev": true + }, "concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -815,6 +2148,15 @@ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.4.tgz", "integrity": "sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==" }, + "convert-source-map": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.8.0.tgz", + "integrity": "sha512-+OQdjP49zViI/6i7nIJpA8rAl4sV/JdPfU9nZs3VqOwGIgizICvuN2ru6fMd+4llL0tar18UYJXfZ/TWtmhUjA==", + "dev": true, + "requires": { + "safe-buffer": "~5.1.1" + } + }, "cookie": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.0.tgz", @@ -825,6 +2167,18 @@ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", "integrity": "sha1-4wOogrNCzD7oylE6eZmXNNqzriw=" }, + "cookiejar": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.2.tgz", + "integrity": "sha512-Mw+adcfzPxcPeI+0WlvRrr/3lGVO0bD75SxX6811cxSh1Wbxx7xZBGK1eVtDf6si8rg2lhnUjsVLMFMfbRIuwA==", + "dev": true + }, + "create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true + }, "cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -836,11 +2190,45 @@ "which": "^2.0.1" } }, + "cssom": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.4.4.tgz", + "integrity": "sha512-p3pvU7r1MyyqbTk+WbNJIgJjG2VmTIaB10rI93LzVPrmDJKkzKYMtxxyAvQXR/NS6otuzveI7+7BBq3SjBS2mw==", + "dev": true + }, + "cssstyle": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-2.3.0.tgz", + "integrity": "sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==", + "dev": true, + "requires": { + "cssom": "~0.3.6" + }, + "dependencies": { + "cssom": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.3.8.tgz", + "integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==", + "dev": true + } + } + }, "cycle": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/cycle/-/cycle-1.0.3.tgz", "integrity": "sha1-IegLK+hYD5i0aPN5QwZisEbDStI=" }, + "data-urls": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-2.0.0.tgz", + "integrity": "sha512-X5eWTSXO/BJmpdIKCRuKUgSCgAN0OwliVK3yPKbwIWU1Tdw5BRajxlzMidvh+gwko9AfQ9zIj52pzF91Q3YAvQ==", + "dev": true, + "requires": { + "abab": "^2.0.3", + "whatwg-mimetype": "^2.3.0", + "whatwg-url": "^8.0.0" + } + }, "dateformat": { "version": "4.5.1", "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.5.1.tgz", @@ -855,12 +2243,36 @@ "ms": "2.0.0" } }, + "decimal.js": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.3.1.tgz", + "integrity": "sha512-V0pfhfr8suzyPGOx3nmq4aHqabehUZn6Ch9kyFpV79TGDTWFmHqUqXdabR7QHqxzrYolF4+tVmJhUG4OURg5dQ==", + "dev": true + }, + "dedent": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz", + "integrity": "sha1-JJXduvbrh0q7Dhvp3yLS5aVEMmw=", + "dev": true + }, "deep-is": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.3.tgz", "integrity": "sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ=", "dev": true }, + "deepmerge": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.2.2.tgz", + "integrity": "sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==", + "dev": true + }, + "delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=", + "dev": true + }, "denque": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/denque/-/denque-1.5.0.tgz", @@ -876,6 +2288,24 @@ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.0.4.tgz", "integrity": "sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA=" }, + "detect-newline": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", + "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", + "dev": true + }, + "diff": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "dev": true + }, + "diff-sequences": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-26.6.2.tgz", + "integrity": "sha512-Mv/TDa3nZ9sbc5soK+OoA74BsS3mL37yixCvUAQkiuA4Wz6YtwP/K47n2rv2ovzHZvoiQeA5FTQOschKkEwB0Q==", + "dev": true + }, "dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -894,6 +2324,23 @@ "esutils": "^2.0.2" } }, + "domexception": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/domexception/-/domexception-2.0.1.tgz", + "integrity": "sha512-yxJ2mFy/sibVQlu5qHjOkf9J3K6zgmCxgJ94u2EdvDOV09H+32LtRswEcUsmUWN72pVLOEnTSRaIVVzVQgS0dg==", + "dev": true, + "requires": { + "webidl-conversions": "^5.0.0" + }, + "dependencies": { + "webidl-conversions": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-5.0.0.tgz", + "integrity": "sha512-VlZwKPCkYKxQgeSbH5EyngOmRp7Ww7I9rQLERETtf5ofd9pGeswWiOtogpEO850jziPRarreGxn5QIiTqpb2wA==", + "dev": true + } + } + }, "dotenv": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-10.0.0.tgz", @@ -904,6 +2351,12 @@ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=" }, + "electron-to-chromium": { + "version": "1.3.789", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.789.tgz", + "integrity": "sha512-lK4xn6C6ZF1kgLaC/EhOtC1MSKENExj3rMwGVnBTfHW81Z/Hb1Rge5YaWawN/YOXy3xCaESuE4KWSD50kOZ9rQ==", + "dev": true + }, "elliptic": { "version": "6.5.4", "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.4.tgz", @@ -925,6 +2378,12 @@ } } }, + "emittery": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.8.1.tgz", + "integrity": "sha512-uDfvUjVrfGJJhymx/kz6prltenw1u7WrCg1oa94zYY8xxVpLLUu045LAT0dhDZdXG58/EpPL/5kA180fQ/qudg==", + "dev": true + }, "emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -974,6 +2433,66 @@ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", "dev": true }, + "escodegen": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.0.0.tgz", + "integrity": "sha512-mmHKys/C8BFUGI+MAWNcSYoORYLMdPzjrknd2Vc+bUsjN5bXcr8EhrNB+UTqfL1y3I9c4fw2ihgtMPQLBRiQxw==", + "dev": true, + "requires": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2", + "optionator": "^0.8.1", + "source-map": "~0.6.1" + }, + "dependencies": { + "estraverse": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.2.0.tgz", + "integrity": "sha512-BxbNGGNm0RyRYvUdHpIwv9IWzeM9XClbOxwoATuFdOE7ZE6wHL+HQ5T8hoPM+zHvmKzzsEqhgy0GrQ5X13afiQ==", + "dev": true + }, + "levn": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz", + "integrity": "sha1-OwmSTt+fCDwEkP3UwLxEIeBHZO4=", + "dev": true, + "requires": { + "prelude-ls": "~1.1.2", + "type-check": "~0.3.2" + } + }, + "optionator": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.3.tgz", + "integrity": "sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==", + "dev": true, + "requires": { + "deep-is": "~0.1.3", + "fast-levenshtein": "~2.0.6", + "levn": "~0.3.0", + "prelude-ls": "~1.1.2", + "type-check": "~0.3.2", + "word-wrap": "~1.2.3" + } + }, + "prelude-ls": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", + "integrity": "sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ=", + "dev": true + }, + "type-check": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz", + "integrity": "sha1-WITKtRLPHTVeP7eE8wgEsrUg23I=", + "dev": true, + "requires": { + "prelude-ls": "~1.1.2" + } + } + } + }, "eslint": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/eslint/-/eslint-7.29.0.tgz", @@ -1177,6 +2696,79 @@ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", "integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=" }, + "execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "requires": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + } + }, + "exit": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", + "integrity": "sha1-BjJjj42HfMghB9MKD/8aF8uhzQw=", + "dev": true + }, + "expect": { + "version": "27.0.6", + "resolved": "https://registry.npmjs.org/expect/-/expect-27.0.6.tgz", + "integrity": "sha512-psNLt8j2kwg42jGBDSfAlU49CEZxejN1f1PlANWDZqIhBOVU/c2Pm888FcjWJzFewhIsNWfZJeLjUjtKGiPuSw==", + "dev": true, + "requires": { + "@jest/types": "^27.0.6", + "ansi-styles": "^5.0.0", + "jest-get-type": "^27.0.6", + "jest-matcher-utils": "^27.0.6", + "jest-message-util": "^27.0.6", + "jest-regex-util": "^27.0.6" + }, + "dependencies": { + "@jest/types": { + "version": "27.0.6", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.0.6.tgz", + "integrity": "sha512-aSquT1qa9Pik26JK5/3rvnYb4bGtm1VFNesHKmNTwmPIgOrixvhL2ghIvFRNEpzy3gU+rUgjIF/KodbkFAl++g==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + } + }, + "@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "dev": true, + "requires": { + "@types/yargs-parser": "*" + } + }, + "ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true + }, + "jest-get-type": { + "version": "27.0.6", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-27.0.6.tgz", + "integrity": "sha512-XTkK5exIeUbbveehcSR8w0bhH+c0yloW/Wpl+9vZrjzztCPWrxhHwkIFpZzCt71oRBsgxmuUfxEqOYoZI2macg==", + "dev": true + } + } + }, "express": { "version": "4.17.1", "resolved": "https://registry.npmjs.org/express/-/express-4.17.1.tgz", @@ -1333,6 +2925,15 @@ "reusify": "^1.0.4" } }, + "fb-watchman": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.1.tgz", + "integrity": "sha512-DkPJKQeY6kKwmuMretBhr7G6Vodr7bFwDYTXIkfG1gjvNpaxBTQV3PbXg6bR1c1UP4jPOX0jHUbbHANL9vRjVg==", + "dev": true, + "requires": { + "bser": "2.1.1" + } + }, "file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -1365,6 +2966,16 @@ "unpipe": "~1.0.0" } }, + "find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "requires": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + } + }, "flat-cache": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz", @@ -1391,6 +3002,23 @@ "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.1.tgz", "integrity": "sha512-HWqDgT7ZEkqRzBvc2s64vSZ/hfOceEol3ac/7tKwzuvEyWx3/4UegXh5oBOIotkGsObyk3xznnSRVADBgWSQVg==" }, + "form-data": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.1.tgz", + "integrity": "sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg==", + "dev": true, + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + } + }, + "formidable": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-1.2.2.tgz", + "integrity": "sha512-V8gLm+41I/8kguQ4/o1D3RIHRmhYFG4pnNyonvua+40rqcEmT4+V71yaZ3B457xbbgCsCfjSPi65u/W6vK1U5Q==", + "dev": true + }, "forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -1407,6 +3035,13 @@ "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", "dev": true }, + "fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "optional": true + }, "function-bind": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", @@ -1418,6 +3053,12 @@ "integrity": "sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=", "dev": true }, + "gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true + }, "get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -1433,6 +3074,18 @@ "has-symbols": "^1.0.1" } }, + "get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "dev": true + }, + "get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true + }, "glob": { "version": "7.1.7", "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.7.tgz", @@ -1479,6 +3132,12 @@ "slash": "^3.0.0" } }, + "graceful-fs": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.6.tgz", + "integrity": "sha512-nTnJ528pbqxYanhpDYsi4Rd8MAeaBA67+RZ10CM1m3bTAVFEDcd5AuA4a6W5YkGZ1iNXHzZz8T6TBKLeBuNriQ==", + "dev": true + }, "has": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", @@ -1522,6 +3181,21 @@ "minimalistic-crypto-utils": "^1.0.1" } }, + "html-encoding-sniffer": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-2.0.1.tgz", + "integrity": "sha512-D5JbOMBIR/TVZkubHT+OyT2705QvogUW4IBn6nHd756OwieSF9aDYFj4dv6HHEVGYbHaLETa3WggZYWWMyy3ZQ==", + "dev": true, + "requires": { + "whatwg-encoding": "^1.0.5" + } + }, + "html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true + }, "http-errors": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.7.2.tgz", @@ -1534,11 +3208,72 @@ "toidentifier": "1.0.0" } }, + "http-proxy-agent": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz", + "integrity": "sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==", + "dev": true, + "requires": { + "@tootallnate/once": "1", + "agent-base": "6", + "debug": "4" + }, + "dependencies": { + "debug": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.2.tgz", + "integrity": "sha512-mOp8wKcvj7XxC78zLgw/ZA+6TSgkoE2C/ienthhRD298T7UNwAg9diBpLRxC0mOezLl4B0xV7M0cCO6P/O0Xhw==", + "dev": true, + "requires": { + "ms": "2.1.2" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + } + } + }, "http-status-codes": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/http-status-codes/-/http-status-codes-2.1.4.tgz", "integrity": "sha512-MZVIsLKGVOVE1KEnldppe6Ij+vmemMuApDfjhVSLzyYP+td0bREEYyAoIw9yFePoBXManCuBqmiNP5FqJS5Xkg==" }, + "https-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.0.tgz", + "integrity": "sha512-EkYm5BcKUGiduxzSt3Eppko+PiNWNEpa4ySk9vTC6wDsQJW9rHSa+UhGNJoRYp7bz6Ht1eaRIa6QaJqO5rCFbA==", + "dev": true, + "requires": { + "agent-base": "6", + "debug": "4" + }, + "dependencies": { + "debug": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.2.tgz", + "integrity": "sha512-mOp8wKcvj7XxC78zLgw/ZA+6TSgkoE2C/ienthhRD298T7UNwAg9diBpLRxC0mOezLl4B0xV7M0cCO6P/O0Xhw==", + "dev": true, + "requires": { + "ms": "2.1.2" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + } + } + }, + "human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true + }, "iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -1563,6 +3298,16 @@ "resolve-from": "^4.0.0" } }, + "import-local": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.0.2.tgz", + "integrity": "sha512-vjL3+w0oulAVZ0hBHnxa/Nm5TAurf9YLQJDhqRZyqb+VKGOB6LU8t9H1Nr5CIo16vh9XfJTOoHwU0B71S557gA==", + "dev": true, + "requires": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + } + }, "imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -1626,6 +3371,24 @@ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==" }, + "is-ci": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-3.0.0.tgz", + "integrity": "sha512-kDXyttuLeslKAHYL/K28F2YkM3x5jvFPEw3yXbRptXydjD9rpLEz+C5K5iutY9ZiUu6AP41JdvRQwF4Iqs4ZCQ==", + "dev": true, + "requires": { + "ci-info": "^3.1.1" + } + }, + "is-core-module": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.5.0.tgz", + "integrity": "sha512-TXCMSDsEHMEEZ6eCA8rwRDbLu55MRGmrctljsBX/2v1d9/GzqHOxW5c5oPSgrUt2vBFXebu9rGqckXGPWOlYpg==", + "dev": true, + "requires": { + "has": "^1.0.3" + } + }, "is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -1637,6 +3400,12 @@ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==" }, + "is-generator-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", + "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", + "dev": true + }, "is-glob": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.1.tgz", @@ -1652,11 +3421,29 @@ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", "dev": true }, + "is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true + }, "is-redirect": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-redirect/-/is-redirect-1.0.0.tgz", "integrity": "sha1-HQPd7VO9jbDzDCbk+V02/HyH3CQ=" }, + "is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true + }, + "is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=", + "dev": true + }, "isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -1668,6 +3455,1337 @@ "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", "integrity": "sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=" }, + "istanbul-lib-coverage": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.0.0.tgz", + "integrity": "sha512-UiUIqxMgRDET6eR+o5HbfRYP1l0hqkWOs7vNxC/mggutCMUIhWMm8gAHb8tHlyfD3/l6rlgNA5cKdDzEAf6hEg==", + "dev": true + }, + "istanbul-lib-instrument": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-4.0.3.tgz", + "integrity": "sha512-BXgQl9kf4WTCPCCpmFGoJkz/+uhvm7h7PFKUYxh7qarQd3ER33vHG//qaE8eN25l07YqZPpHXU9I09l/RD5aGQ==", + "dev": true, + "requires": { + "@babel/core": "^7.7.5", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-coverage": "^3.0.0", + "semver": "^6.3.0" + }, + "dependencies": { + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true + } + } + }, + "istanbul-lib-report": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz", + "integrity": "sha512-wcdi+uAKzfiGT2abPpKZ0hSU1rGQjUQnLvtY5MpQ7QCTahD3VODhcu4wcfY1YtkGaDD5yuydOLINXsfbus9ROw==", + "dev": true, + "requires": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^3.0.0", + "supports-color": "^7.1.0" + }, + "dependencies": { + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, + "istanbul-lib-source-maps": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.0.tgz", + "integrity": "sha512-c16LpFRkR8vQXyHZ5nLpY35JZtzj1PQY1iZmesUbf1FZHbIupcWfjgOXBY9YHkLEQ6puz1u4Dgj6qmU/DisrZg==", + "dev": true, + "requires": { + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0", + "source-map": "^0.6.1" + }, + "dependencies": { + "debug": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.2.tgz", + "integrity": "sha512-mOp8wKcvj7XxC78zLgw/ZA+6TSgkoE2C/ienthhRD298T7UNwAg9diBpLRxC0mOezLl4B0xV7M0cCO6P/O0Xhw==", + "dev": true, + "requires": { + "ms": "2.1.2" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + } + } + }, + "istanbul-reports": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.0.2.tgz", + "integrity": "sha512-9tZvz7AiR3PEDNGiV9vIouQ/EAcqMXFmkcA1CDFTwOB98OZVDL0PH9glHotf5Ugp6GCOTypfzGWI/OqjWNCRUw==", + "dev": true, + "requires": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + } + }, + "jest": { + "version": "27.0.6", + "resolved": "https://registry.npmjs.org/jest/-/jest-27.0.6.tgz", + "integrity": "sha512-EjV8aETrsD0wHl7CKMibKwQNQc3gIRBXlTikBmmHUeVMKaPFxdcUIBfoDqTSXDoGJIivAYGqCWVlzCSaVjPQsA==", + "dev": true, + "requires": { + "@jest/core": "^27.0.6", + "import-local": "^3.0.2", + "jest-cli": "^27.0.6" + }, + "dependencies": { + "@jest/types": { + "version": "27.0.6", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.0.6.tgz", + "integrity": "sha512-aSquT1qa9Pik26JK5/3rvnYb4bGtm1VFNesHKmNTwmPIgOrixvhL2ghIvFRNEpzy3gU+rUgjIF/KodbkFAl++g==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + } + }, + "@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "dev": true, + "requires": { + "@types/yargs-parser": "*" + } + }, + "jest-cli": { + "version": "27.0.6", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-27.0.6.tgz", + "integrity": "sha512-qUUVlGb9fdKir3RDE+B10ULI+LQrz+MCflEH2UJyoUjoHHCbxDrMxSzjQAPUMsic4SncI62ofYCcAvW6+6rhhg==", + "dev": true, + "requires": { + "@jest/core": "^27.0.6", + "@jest/test-result": "^27.0.6", + "@jest/types": "^27.0.6", + "chalk": "^4.0.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.4", + "import-local": "^3.0.2", + "jest-config": "^27.0.6", + "jest-util": "^27.0.6", + "jest-validate": "^27.0.6", + "prompts": "^2.0.1", + "yargs": "^16.0.3" + } + } + } + }, + "jest-changed-files": { + "version": "27.0.6", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-27.0.6.tgz", + "integrity": "sha512-BuL/ZDauaq5dumYh5y20sn4IISnf1P9A0TDswTxUi84ORGtVa86ApuBHqICL0vepqAnZiY6a7xeSPWv2/yy4eA==", + "dev": true, + "requires": { + "@jest/types": "^27.0.6", + "execa": "^5.0.0", + "throat": "^6.0.1" + }, + "dependencies": { + "@jest/types": { + "version": "27.0.6", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.0.6.tgz", + "integrity": "sha512-aSquT1qa9Pik26JK5/3rvnYb4bGtm1VFNesHKmNTwmPIgOrixvhL2ghIvFRNEpzy3gU+rUgjIF/KodbkFAl++g==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + } + }, + "@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "dev": true, + "requires": { + "@types/yargs-parser": "*" + } + } + } + }, + "jest-circus": { + "version": "27.0.6", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-27.0.6.tgz", + "integrity": "sha512-OJlsz6BBeX9qR+7O9lXefWoc2m9ZqcZ5Ohlzz0pTEAG4xMiZUJoacY8f4YDHxgk0oKYxj277AfOk9w6hZYvi1Q==", + "dev": true, + "requires": { + "@jest/environment": "^27.0.6", + "@jest/test-result": "^27.0.6", + "@jest/types": "^27.0.6", + "@types/node": "*", + "chalk": "^4.0.0", + "co": "^4.6.0", + "dedent": "^0.7.0", + "expect": "^27.0.6", + "is-generator-fn": "^2.0.0", + "jest-each": "^27.0.6", + "jest-matcher-utils": "^27.0.6", + "jest-message-util": "^27.0.6", + "jest-runtime": "^27.0.6", + "jest-snapshot": "^27.0.6", + "jest-util": "^27.0.6", + "pretty-format": "^27.0.6", + "slash": "^3.0.0", + "stack-utils": "^2.0.3", + "throat": "^6.0.1" + }, + "dependencies": { + "@jest/types": { + "version": "27.0.6", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.0.6.tgz", + "integrity": "sha512-aSquT1qa9Pik26JK5/3rvnYb4bGtm1VFNesHKmNTwmPIgOrixvhL2ghIvFRNEpzy3gU+rUgjIF/KodbkFAl++g==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + } + }, + "@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "dev": true, + "requires": { + "@types/yargs-parser": "*" + } + }, + "ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true + }, + "pretty-format": { + "version": "27.0.6", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.0.6.tgz", + "integrity": "sha512-8tGD7gBIENgzqA+UBzObyWqQ5B778VIFZA/S66cclyd5YkFLYs2Js7gxDKf0MXtTc9zcS7t1xhdfcElJ3YIvkQ==", + "dev": true, + "requires": { + "@jest/types": "^27.0.6", + "ansi-regex": "^5.0.0", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + } + } + } + }, + "jest-config": { + "version": "27.0.6", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-27.0.6.tgz", + "integrity": "sha512-JZRR3I1Plr2YxPBhgqRspDE2S5zprbga3swYNrvY3HfQGu7p/GjyLOqwrYad97tX3U3mzT53TPHVmozacfP/3w==", + "dev": true, + "requires": { + "@babel/core": "^7.1.0", + "@jest/test-sequencer": "^27.0.6", + "@jest/types": "^27.0.6", + "babel-jest": "^27.0.6", + "chalk": "^4.0.0", + "deepmerge": "^4.2.2", + "glob": "^7.1.1", + "graceful-fs": "^4.2.4", + "is-ci": "^3.0.0", + "jest-circus": "^27.0.6", + "jest-environment-jsdom": "^27.0.6", + "jest-environment-node": "^27.0.6", + "jest-get-type": "^27.0.6", + "jest-jasmine2": "^27.0.6", + "jest-regex-util": "^27.0.6", + "jest-resolve": "^27.0.6", + "jest-runner": "^27.0.6", + "jest-util": "^27.0.6", + "jest-validate": "^27.0.6", + "micromatch": "^4.0.4", + "pretty-format": "^27.0.6" + }, + "dependencies": { + "@jest/types": { + "version": "27.0.6", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.0.6.tgz", + "integrity": "sha512-aSquT1qa9Pik26JK5/3rvnYb4bGtm1VFNesHKmNTwmPIgOrixvhL2ghIvFRNEpzy3gU+rUgjIF/KodbkFAl++g==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + } + }, + "@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "dev": true, + "requires": { + "@types/yargs-parser": "*" + } + }, + "ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true + }, + "jest-get-type": { + "version": "27.0.6", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-27.0.6.tgz", + "integrity": "sha512-XTkK5exIeUbbveehcSR8w0bhH+c0yloW/Wpl+9vZrjzztCPWrxhHwkIFpZzCt71oRBsgxmuUfxEqOYoZI2macg==", + "dev": true + }, + "pretty-format": { + "version": "27.0.6", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.0.6.tgz", + "integrity": "sha512-8tGD7gBIENgzqA+UBzObyWqQ5B778VIFZA/S66cclyd5YkFLYs2Js7gxDKf0MXtTc9zcS7t1xhdfcElJ3YIvkQ==", + "dev": true, + "requires": { + "@jest/types": "^27.0.6", + "ansi-regex": "^5.0.0", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + } + } + } + }, + "jest-diff": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-26.6.2.tgz", + "integrity": "sha512-6m+9Z3Gv9wN0WFVasqjCL/06+EFCMTqDEUl/b87HYK2rAPTyfz4ZIuSlPhY51PIQRWx5TaxeF1qmXKe9gfN3sA==", + "dev": true, + "requires": { + "chalk": "^4.0.0", + "diff-sequences": "^26.6.2", + "jest-get-type": "^26.3.0", + "pretty-format": "^26.6.2" + } + }, + "jest-docblock": { + "version": "27.0.6", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-27.0.6.tgz", + "integrity": "sha512-Fid6dPcjwepTFraz0YxIMCi7dejjJ/KL9FBjPYhBp4Sv1Y9PdhImlKZqYU555BlN4TQKaTc+F2Av1z+anVyGkA==", + "dev": true, + "requires": { + "detect-newline": "^3.0.0" + } + }, + "jest-each": { + "version": "27.0.6", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-27.0.6.tgz", + "integrity": "sha512-m6yKcV3bkSWrUIjxkE9OC0mhBZZdhovIW5ergBYirqnkLXkyEn3oUUF/QZgyecA1cF1QFyTE8bRRl8Tfg1pfLA==", + "dev": true, + "requires": { + "@jest/types": "^27.0.6", + "chalk": "^4.0.0", + "jest-get-type": "^27.0.6", + "jest-util": "^27.0.6", + "pretty-format": "^27.0.6" + }, + "dependencies": { + "@jest/types": { + "version": "27.0.6", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.0.6.tgz", + "integrity": "sha512-aSquT1qa9Pik26JK5/3rvnYb4bGtm1VFNesHKmNTwmPIgOrixvhL2ghIvFRNEpzy3gU+rUgjIF/KodbkFAl++g==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + } + }, + "@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "dev": true, + "requires": { + "@types/yargs-parser": "*" + } + }, + "ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true + }, + "jest-get-type": { + "version": "27.0.6", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-27.0.6.tgz", + "integrity": "sha512-XTkK5exIeUbbveehcSR8w0bhH+c0yloW/Wpl+9vZrjzztCPWrxhHwkIFpZzCt71oRBsgxmuUfxEqOYoZI2macg==", + "dev": true + }, + "pretty-format": { + "version": "27.0.6", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.0.6.tgz", + "integrity": "sha512-8tGD7gBIENgzqA+UBzObyWqQ5B778VIFZA/S66cclyd5YkFLYs2Js7gxDKf0MXtTc9zcS7t1xhdfcElJ3YIvkQ==", + "dev": true, + "requires": { + "@jest/types": "^27.0.6", + "ansi-regex": "^5.0.0", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + } + } + } + }, + "jest-environment-jsdom": { + "version": "27.0.6", + "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-27.0.6.tgz", + "integrity": "sha512-FvetXg7lnXL9+78H+xUAsra3IeZRTiegA3An01cWeXBspKXUhAwMM9ycIJ4yBaR0L7HkoMPaZsozCLHh4T8fuw==", + "dev": true, + "requires": { + "@jest/environment": "^27.0.6", + "@jest/fake-timers": "^27.0.6", + "@jest/types": "^27.0.6", + "@types/node": "*", + "jest-mock": "^27.0.6", + "jest-util": "^27.0.6", + "jsdom": "^16.6.0" + }, + "dependencies": { + "@jest/types": { + "version": "27.0.6", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.0.6.tgz", + "integrity": "sha512-aSquT1qa9Pik26JK5/3rvnYb4bGtm1VFNesHKmNTwmPIgOrixvhL2ghIvFRNEpzy3gU+rUgjIF/KodbkFAl++g==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + } + }, + "@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "dev": true, + "requires": { + "@types/yargs-parser": "*" + } + } + } + }, + "jest-environment-node": { + "version": "27.0.6", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-27.0.6.tgz", + "integrity": "sha512-+Vi6yLrPg/qC81jfXx3IBlVnDTI6kmRr08iVa2hFCWmJt4zha0XW7ucQltCAPhSR0FEKEoJ3i+W4E6T0s9is0w==", + "dev": true, + "requires": { + "@jest/environment": "^27.0.6", + "@jest/fake-timers": "^27.0.6", + "@jest/types": "^27.0.6", + "@types/node": "*", + "jest-mock": "^27.0.6", + "jest-util": "^27.0.6" + }, + "dependencies": { + "@jest/types": { + "version": "27.0.6", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.0.6.tgz", + "integrity": "sha512-aSquT1qa9Pik26JK5/3rvnYb4bGtm1VFNesHKmNTwmPIgOrixvhL2ghIvFRNEpzy3gU+rUgjIF/KodbkFAl++g==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + } + }, + "@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "dev": true, + "requires": { + "@types/yargs-parser": "*" + } + } + } + }, + "jest-get-type": { + "version": "26.3.0", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-26.3.0.tgz", + "integrity": "sha512-TpfaviN1R2pQWkIihlfEanwOXK0zcxrKEE4MlU6Tn7keoXdN6/3gK/xl0yEh8DOunn5pOVGKf8hB4R9gVh04ig==", + "dev": true + }, + "jest-haste-map": { + "version": "27.0.6", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-27.0.6.tgz", + "integrity": "sha512-4ldjPXX9h8doB2JlRzg9oAZ2p6/GpQUNAeiYXqcpmrKbP0Qev0wdZlxSMOmz8mPOEnt4h6qIzXFLDi8RScX/1w==", + "dev": true, + "requires": { + "@jest/types": "^27.0.6", + "@types/graceful-fs": "^4.1.2", + "@types/node": "*", + "anymatch": "^3.0.3", + "fb-watchman": "^2.0.0", + "fsevents": "^2.3.2", + "graceful-fs": "^4.2.4", + "jest-regex-util": "^27.0.6", + "jest-serializer": "^27.0.6", + "jest-util": "^27.0.6", + "jest-worker": "^27.0.6", + "micromatch": "^4.0.4", + "walker": "^1.0.7" + }, + "dependencies": { + "@jest/types": { + "version": "27.0.6", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.0.6.tgz", + "integrity": "sha512-aSquT1qa9Pik26JK5/3rvnYb4bGtm1VFNesHKmNTwmPIgOrixvhL2ghIvFRNEpzy3gU+rUgjIF/KodbkFAl++g==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + } + }, + "@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "dev": true, + "requires": { + "@types/yargs-parser": "*" + } + } + } + }, + "jest-jasmine2": { + "version": "27.0.6", + "resolved": "https://registry.npmjs.org/jest-jasmine2/-/jest-jasmine2-27.0.6.tgz", + "integrity": "sha512-cjpH2sBy+t6dvCeKBsHpW41mjHzXgsavaFMp+VWRf0eR4EW8xASk1acqmljFtK2DgyIECMv2yCdY41r2l1+4iA==", + "dev": true, + "requires": { + "@babel/traverse": "^7.1.0", + "@jest/environment": "^27.0.6", + "@jest/source-map": "^27.0.6", + "@jest/test-result": "^27.0.6", + "@jest/types": "^27.0.6", + "@types/node": "*", + "chalk": "^4.0.0", + "co": "^4.6.0", + "expect": "^27.0.6", + "is-generator-fn": "^2.0.0", + "jest-each": "^27.0.6", + "jest-matcher-utils": "^27.0.6", + "jest-message-util": "^27.0.6", + "jest-runtime": "^27.0.6", + "jest-snapshot": "^27.0.6", + "jest-util": "^27.0.6", + "pretty-format": "^27.0.6", + "throat": "^6.0.1" + }, + "dependencies": { + "@jest/types": { + "version": "27.0.6", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.0.6.tgz", + "integrity": "sha512-aSquT1qa9Pik26JK5/3rvnYb4bGtm1VFNesHKmNTwmPIgOrixvhL2ghIvFRNEpzy3gU+rUgjIF/KodbkFAl++g==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + } + }, + "@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "dev": true, + "requires": { + "@types/yargs-parser": "*" + } + }, + "ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true + }, + "pretty-format": { + "version": "27.0.6", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.0.6.tgz", + "integrity": "sha512-8tGD7gBIENgzqA+UBzObyWqQ5B778VIFZA/S66cclyd5YkFLYs2Js7gxDKf0MXtTc9zcS7t1xhdfcElJ3YIvkQ==", + "dev": true, + "requires": { + "@jest/types": "^27.0.6", + "ansi-regex": "^5.0.0", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + } + } + } + }, + "jest-leak-detector": { + "version": "27.0.6", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-27.0.6.tgz", + "integrity": "sha512-2/d6n2wlH5zEcdctX4zdbgX8oM61tb67PQt4Xh8JFAIy6LRKUnX528HulkaG6nD5qDl5vRV1NXejCe1XRCH5gQ==", + "dev": true, + "requires": { + "jest-get-type": "^27.0.6", + "pretty-format": "^27.0.6" + }, + "dependencies": { + "@jest/types": { + "version": "27.0.6", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.0.6.tgz", + "integrity": "sha512-aSquT1qa9Pik26JK5/3rvnYb4bGtm1VFNesHKmNTwmPIgOrixvhL2ghIvFRNEpzy3gU+rUgjIF/KodbkFAl++g==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + } + }, + "@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "dev": true, + "requires": { + "@types/yargs-parser": "*" + } + }, + "ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true + }, + "jest-get-type": { + "version": "27.0.6", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-27.0.6.tgz", + "integrity": "sha512-XTkK5exIeUbbveehcSR8w0bhH+c0yloW/Wpl+9vZrjzztCPWrxhHwkIFpZzCt71oRBsgxmuUfxEqOYoZI2macg==", + "dev": true + }, + "pretty-format": { + "version": "27.0.6", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.0.6.tgz", + "integrity": "sha512-8tGD7gBIENgzqA+UBzObyWqQ5B778VIFZA/S66cclyd5YkFLYs2Js7gxDKf0MXtTc9zcS7t1xhdfcElJ3YIvkQ==", + "dev": true, + "requires": { + "@jest/types": "^27.0.6", + "ansi-regex": "^5.0.0", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + } + } + } + }, + "jest-matcher-utils": { + "version": "27.0.6", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-27.0.6.tgz", + "integrity": "sha512-OFgF2VCQx9vdPSYTHWJ9MzFCehs20TsyFi6bIHbk5V1u52zJOnvF0Y/65z3GLZHKRuTgVPY4Z6LVePNahaQ+tA==", + "dev": true, + "requires": { + "chalk": "^4.0.0", + "jest-diff": "^27.0.6", + "jest-get-type": "^27.0.6", + "pretty-format": "^27.0.6" + }, + "dependencies": { + "@jest/types": { + "version": "27.0.6", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.0.6.tgz", + "integrity": "sha512-aSquT1qa9Pik26JK5/3rvnYb4bGtm1VFNesHKmNTwmPIgOrixvhL2ghIvFRNEpzy3gU+rUgjIF/KodbkFAl++g==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + } + }, + "@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "dev": true, + "requires": { + "@types/yargs-parser": "*" + } + }, + "ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true + }, + "diff-sequences": { + "version": "27.0.6", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-27.0.6.tgz", + "integrity": "sha512-ag6wfpBFyNXZ0p8pcuIDS//D8H062ZQJ3fzYxjpmeKjnz8W4pekL3AI8VohmyZmsWW2PWaHgjsmqR6L13101VQ==", + "dev": true + }, + "jest-diff": { + "version": "27.0.6", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-27.0.6.tgz", + "integrity": "sha512-Z1mqgkTCSYaFgwTlP/NUiRzdqgxmmhzHY1Tq17zL94morOHfHu3K4bgSgl+CR4GLhpV8VxkuOYuIWnQ9LnFqmg==", + "dev": true, + "requires": { + "chalk": "^4.0.0", + "diff-sequences": "^27.0.6", + "jest-get-type": "^27.0.6", + "pretty-format": "^27.0.6" + } + }, + "jest-get-type": { + "version": "27.0.6", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-27.0.6.tgz", + "integrity": "sha512-XTkK5exIeUbbveehcSR8w0bhH+c0yloW/Wpl+9vZrjzztCPWrxhHwkIFpZzCt71oRBsgxmuUfxEqOYoZI2macg==", + "dev": true + }, + "pretty-format": { + "version": "27.0.6", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.0.6.tgz", + "integrity": "sha512-8tGD7gBIENgzqA+UBzObyWqQ5B778VIFZA/S66cclyd5YkFLYs2Js7gxDKf0MXtTc9zcS7t1xhdfcElJ3YIvkQ==", + "dev": true, + "requires": { + "@jest/types": "^27.0.6", + "ansi-regex": "^5.0.0", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + } + } + } + }, + "jest-message-util": { + "version": "27.0.6", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-27.0.6.tgz", + "integrity": "sha512-rBxIs2XK7rGy+zGxgi+UJKP6WqQ+KrBbD1YMj517HYN3v2BG66t3Xan3FWqYHKZwjdB700KiAJ+iES9a0M+ixw==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "^27.0.6", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.4", + "micromatch": "^4.0.4", + "pretty-format": "^27.0.6", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "dependencies": { + "@babel/code-frame": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.14.5.tgz", + "integrity": "sha512-9pzDqyc6OLDaqe+zbACgFkb6fKMNG6CObKpnYXChRsvYGyEdc7CA2BaqeOM+vOtCS5ndmJicPJhKAwYRI6UfFw==", + "dev": true, + "requires": { + "@babel/highlight": "^7.14.5" + } + }, + "@jest/types": { + "version": "27.0.6", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.0.6.tgz", + "integrity": "sha512-aSquT1qa9Pik26JK5/3rvnYb4bGtm1VFNesHKmNTwmPIgOrixvhL2ghIvFRNEpzy3gU+rUgjIF/KodbkFAl++g==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + } + }, + "@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "dev": true, + "requires": { + "@types/yargs-parser": "*" + } + }, + "ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true + }, + "pretty-format": { + "version": "27.0.6", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.0.6.tgz", + "integrity": "sha512-8tGD7gBIENgzqA+UBzObyWqQ5B778VIFZA/S66cclyd5YkFLYs2Js7gxDKf0MXtTc9zcS7t1xhdfcElJ3YIvkQ==", + "dev": true, + "requires": { + "@jest/types": "^27.0.6", + "ansi-regex": "^5.0.0", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + } + } + } + }, + "jest-mock": { + "version": "27.0.6", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-27.0.6.tgz", + "integrity": "sha512-lzBETUoK8cSxts2NYXSBWT+EJNzmUVtVVwS1sU9GwE1DLCfGsngg+ZVSIe0yd0ZSm+y791esiuo+WSwpXJQ5Bw==", + "dev": true, + "requires": { + "@jest/types": "^27.0.6", + "@types/node": "*" + }, + "dependencies": { + "@jest/types": { + "version": "27.0.6", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.0.6.tgz", + "integrity": "sha512-aSquT1qa9Pik26JK5/3rvnYb4bGtm1VFNesHKmNTwmPIgOrixvhL2ghIvFRNEpzy3gU+rUgjIF/KodbkFAl++g==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + } + }, + "@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "dev": true, + "requires": { + "@types/yargs-parser": "*" + } + } + } + }, + "jest-pnp-resolver": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.2.tgz", + "integrity": "sha512-olV41bKSMm8BdnuMsewT4jqlZ8+3TCARAXjZGT9jcoSnrfUnRCqnMoF9XEeoWjbzObpqF9dRhHQj0Xb9QdF6/w==", + "dev": true + }, + "jest-regex-util": { + "version": "27.0.6", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-27.0.6.tgz", + "integrity": "sha512-SUhPzBsGa1IKm8hx2F4NfTGGp+r7BXJ4CulsZ1k2kI+mGLG+lxGrs76veN2LF/aUdGosJBzKgXmNCw+BzFqBDQ==", + "dev": true + }, + "jest-resolve": { + "version": "27.0.6", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-27.0.6.tgz", + "integrity": "sha512-yKmIgw2LgTh7uAJtzv8UFHGF7Dm7XfvOe/LQ3Txv101fLM8cx2h1QVwtSJ51Q/SCxpIiKfVn6G2jYYMDNHZteA==", + "dev": true, + "requires": { + "@jest/types": "^27.0.6", + "chalk": "^4.0.0", + "escalade": "^3.1.1", + "graceful-fs": "^4.2.4", + "jest-pnp-resolver": "^1.2.2", + "jest-util": "^27.0.6", + "jest-validate": "^27.0.6", + "resolve": "^1.20.0", + "slash": "^3.0.0" + }, + "dependencies": { + "@jest/types": { + "version": "27.0.6", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.0.6.tgz", + "integrity": "sha512-aSquT1qa9Pik26JK5/3rvnYb4bGtm1VFNesHKmNTwmPIgOrixvhL2ghIvFRNEpzy3gU+rUgjIF/KodbkFAl++g==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + } + }, + "@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "dev": true, + "requires": { + "@types/yargs-parser": "*" + } + } + } + }, + "jest-resolve-dependencies": { + "version": "27.0.6", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-27.0.6.tgz", + "integrity": "sha512-mg9x9DS3BPAREWKCAoyg3QucCr0n6S8HEEsqRCKSPjPcu9HzRILzhdzY3imsLoZWeosEbJZz6TKasveczzpJZA==", + "dev": true, + "requires": { + "@jest/types": "^27.0.6", + "jest-regex-util": "^27.0.6", + "jest-snapshot": "^27.0.6" + }, + "dependencies": { + "@jest/types": { + "version": "27.0.6", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.0.6.tgz", + "integrity": "sha512-aSquT1qa9Pik26JK5/3rvnYb4bGtm1VFNesHKmNTwmPIgOrixvhL2ghIvFRNEpzy3gU+rUgjIF/KodbkFAl++g==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + } + }, + "@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "dev": true, + "requires": { + "@types/yargs-parser": "*" + } + } + } + }, + "jest-runner": { + "version": "27.0.6", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-27.0.6.tgz", + "integrity": "sha512-W3Bz5qAgaSChuivLn+nKOgjqNxM7O/9JOJoKDCqThPIg2sH/d4A/lzyiaFgnb9V1/w29Le11NpzTJSzga1vyYQ==", + "dev": true, + "requires": { + "@jest/console": "^27.0.6", + "@jest/environment": "^27.0.6", + "@jest/test-result": "^27.0.6", + "@jest/transform": "^27.0.6", + "@jest/types": "^27.0.6", + "@types/node": "*", + "chalk": "^4.0.0", + "emittery": "^0.8.1", + "exit": "^0.1.2", + "graceful-fs": "^4.2.4", + "jest-docblock": "^27.0.6", + "jest-environment-jsdom": "^27.0.6", + "jest-environment-node": "^27.0.6", + "jest-haste-map": "^27.0.6", + "jest-leak-detector": "^27.0.6", + "jest-message-util": "^27.0.6", + "jest-resolve": "^27.0.6", + "jest-runtime": "^27.0.6", + "jest-util": "^27.0.6", + "jest-worker": "^27.0.6", + "source-map-support": "^0.5.6", + "throat": "^6.0.1" + }, + "dependencies": { + "@jest/types": { + "version": "27.0.6", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.0.6.tgz", + "integrity": "sha512-aSquT1qa9Pik26JK5/3rvnYb4bGtm1VFNesHKmNTwmPIgOrixvhL2ghIvFRNEpzy3gU+rUgjIF/KodbkFAl++g==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + } + }, + "@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "dev": true, + "requires": { + "@types/yargs-parser": "*" + } + } + } + }, + "jest-runtime": { + "version": "27.0.6", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-27.0.6.tgz", + "integrity": "sha512-BhvHLRVfKibYyqqEFkybsznKwhrsu7AWx2F3y9G9L95VSIN3/ZZ9vBpm/XCS2bS+BWz3sSeNGLzI3TVQ0uL85Q==", + "dev": true, + "requires": { + "@jest/console": "^27.0.6", + "@jest/environment": "^27.0.6", + "@jest/fake-timers": "^27.0.6", + "@jest/globals": "^27.0.6", + "@jest/source-map": "^27.0.6", + "@jest/test-result": "^27.0.6", + "@jest/transform": "^27.0.6", + "@jest/types": "^27.0.6", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0", + "cjs-module-lexer": "^1.0.0", + "collect-v8-coverage": "^1.0.0", + "exit": "^0.1.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.4", + "jest-haste-map": "^27.0.6", + "jest-message-util": "^27.0.6", + "jest-mock": "^27.0.6", + "jest-regex-util": "^27.0.6", + "jest-resolve": "^27.0.6", + "jest-snapshot": "^27.0.6", + "jest-util": "^27.0.6", + "jest-validate": "^27.0.6", + "slash": "^3.0.0", + "strip-bom": "^4.0.0", + "yargs": "^16.0.3" + }, + "dependencies": { + "@jest/types": { + "version": "27.0.6", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.0.6.tgz", + "integrity": "sha512-aSquT1qa9Pik26JK5/3rvnYb4bGtm1VFNesHKmNTwmPIgOrixvhL2ghIvFRNEpzy3gU+rUgjIF/KodbkFAl++g==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + } + }, + "@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "dev": true, + "requires": { + "@types/yargs-parser": "*" + } + } + } + }, + "jest-serializer": { + "version": "27.0.6", + "resolved": "https://registry.npmjs.org/jest-serializer/-/jest-serializer-27.0.6.tgz", + "integrity": "sha512-PtGdVK9EGC7dsaziskfqaAPib6wTViY3G8E5wz9tLVPhHyiDNTZn/xjZ4khAw+09QkoOVpn7vF5nPSN6dtBexA==", + "dev": true, + "requires": { + "@types/node": "*", + "graceful-fs": "^4.2.4" + } + }, + "jest-snapshot": { + "version": "27.0.6", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-27.0.6.tgz", + "integrity": "sha512-NTHaz8He+ATUagUgE7C/UtFcRoHqR2Gc+KDfhQIyx+VFgwbeEMjeP+ILpUTLosZn/ZtbNdCF5LkVnN/l+V751A==", + "dev": true, + "requires": { + "@babel/core": "^7.7.2", + "@babel/generator": "^7.7.2", + "@babel/parser": "^7.7.2", + "@babel/plugin-syntax-typescript": "^7.7.2", + "@babel/traverse": "^7.7.2", + "@babel/types": "^7.0.0", + "@jest/transform": "^27.0.6", + "@jest/types": "^27.0.6", + "@types/babel__traverse": "^7.0.4", + "@types/prettier": "^2.1.5", + "babel-preset-current-node-syntax": "^1.0.0", + "chalk": "^4.0.0", + "expect": "^27.0.6", + "graceful-fs": "^4.2.4", + "jest-diff": "^27.0.6", + "jest-get-type": "^27.0.6", + "jest-haste-map": "^27.0.6", + "jest-matcher-utils": "^27.0.6", + "jest-message-util": "^27.0.6", + "jest-resolve": "^27.0.6", + "jest-util": "^27.0.6", + "natural-compare": "^1.4.0", + "pretty-format": "^27.0.6", + "semver": "^7.3.2" + }, + "dependencies": { + "@jest/types": { + "version": "27.0.6", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.0.6.tgz", + "integrity": "sha512-aSquT1qa9Pik26JK5/3rvnYb4bGtm1VFNesHKmNTwmPIgOrixvhL2ghIvFRNEpzy3gU+rUgjIF/KodbkFAl++g==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + } + }, + "@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "dev": true, + "requires": { + "@types/yargs-parser": "*" + } + }, + "ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true + }, + "diff-sequences": { + "version": "27.0.6", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-27.0.6.tgz", + "integrity": "sha512-ag6wfpBFyNXZ0p8pcuIDS//D8H062ZQJ3fzYxjpmeKjnz8W4pekL3AI8VohmyZmsWW2PWaHgjsmqR6L13101VQ==", + "dev": true + }, + "jest-diff": { + "version": "27.0.6", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-27.0.6.tgz", + "integrity": "sha512-Z1mqgkTCSYaFgwTlP/NUiRzdqgxmmhzHY1Tq17zL94morOHfHu3K4bgSgl+CR4GLhpV8VxkuOYuIWnQ9LnFqmg==", + "dev": true, + "requires": { + "chalk": "^4.0.0", + "diff-sequences": "^27.0.6", + "jest-get-type": "^27.0.6", + "pretty-format": "^27.0.6" + } + }, + "jest-get-type": { + "version": "27.0.6", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-27.0.6.tgz", + "integrity": "sha512-XTkK5exIeUbbveehcSR8w0bhH+c0yloW/Wpl+9vZrjzztCPWrxhHwkIFpZzCt71oRBsgxmuUfxEqOYoZI2macg==", + "dev": true + }, + "pretty-format": { + "version": "27.0.6", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.0.6.tgz", + "integrity": "sha512-8tGD7gBIENgzqA+UBzObyWqQ5B778VIFZA/S66cclyd5YkFLYs2Js7gxDKf0MXtTc9zcS7t1xhdfcElJ3YIvkQ==", + "dev": true, + "requires": { + "@jest/types": "^27.0.6", + "ansi-regex": "^5.0.0", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + } + } + } + }, + "jest-util": { + "version": "27.0.6", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-27.0.6.tgz", + "integrity": "sha512-1JjlaIh+C65H/F7D11GNkGDDZtDfMEM8EBXsvd+l/cxtgQ6QhxuloOaiayt89DxUvDarbVhqI98HhgrM1yliFQ==", + "dev": true, + "requires": { + "@jest/types": "^27.0.6", + "@types/node": "*", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.4", + "is-ci": "^3.0.0", + "picomatch": "^2.2.3" + }, + "dependencies": { + "@jest/types": { + "version": "27.0.6", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.0.6.tgz", + "integrity": "sha512-aSquT1qa9Pik26JK5/3rvnYb4bGtm1VFNesHKmNTwmPIgOrixvhL2ghIvFRNEpzy3gU+rUgjIF/KodbkFAl++g==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + } + }, + "@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "dev": true, + "requires": { + "@types/yargs-parser": "*" + } + } + } + }, + "jest-validate": { + "version": "27.0.6", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-27.0.6.tgz", + "integrity": "sha512-yhZZOaMH3Zg6DC83n60pLmdU1DQE46DW+KLozPiPbSbPhlXXaiUTDlhHQhHFpaqIFRrInko1FHXjTRpjWRuWfA==", + "dev": true, + "requires": { + "@jest/types": "^27.0.6", + "camelcase": "^6.2.0", + "chalk": "^4.0.0", + "jest-get-type": "^27.0.6", + "leven": "^3.1.0", + "pretty-format": "^27.0.6" + }, + "dependencies": { + "@jest/types": { + "version": "27.0.6", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.0.6.tgz", + "integrity": "sha512-aSquT1qa9Pik26JK5/3rvnYb4bGtm1VFNesHKmNTwmPIgOrixvhL2ghIvFRNEpzy3gU+rUgjIF/KodbkFAl++g==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + } + }, + "@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "dev": true, + "requires": { + "@types/yargs-parser": "*" + } + }, + "ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true + }, + "camelcase": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.2.0.tgz", + "integrity": "sha512-c7wVvbw3f37nuobQNtgsgG9POC9qMbNuMQmTCqZv23b6MIz0fcYpBiOlv9gEN/hdLdnZTDQhg6e9Dq5M1vKvfg==", + "dev": true + }, + "jest-get-type": { + "version": "27.0.6", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-27.0.6.tgz", + "integrity": "sha512-XTkK5exIeUbbveehcSR8w0bhH+c0yloW/Wpl+9vZrjzztCPWrxhHwkIFpZzCt71oRBsgxmuUfxEqOYoZI2macg==", + "dev": true + }, + "leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true + }, + "pretty-format": { + "version": "27.0.6", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.0.6.tgz", + "integrity": "sha512-8tGD7gBIENgzqA+UBzObyWqQ5B778VIFZA/S66cclyd5YkFLYs2Js7gxDKf0MXtTc9zcS7t1xhdfcElJ3YIvkQ==", + "dev": true, + "requires": { + "@jest/types": "^27.0.6", + "ansi-regex": "^5.0.0", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + } + } + } + }, + "jest-watcher": { + "version": "27.0.6", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-27.0.6.tgz", + "integrity": "sha512-/jIoKBhAP00/iMGnTwUBLgvxkn7vsOweDrOTSPzc7X9uOyUtJIDthQBTI1EXz90bdkrxorUZVhJwiB69gcHtYQ==", + "dev": true, + "requires": { + "@jest/test-result": "^27.0.6", + "@jest/types": "^27.0.6", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "jest-util": "^27.0.6", + "string-length": "^4.0.1" + }, + "dependencies": { + "@jest/types": { + "version": "27.0.6", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.0.6.tgz", + "integrity": "sha512-aSquT1qa9Pik26JK5/3rvnYb4bGtm1VFNesHKmNTwmPIgOrixvhL2ghIvFRNEpzy3gU+rUgjIF/KodbkFAl++g==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + } + }, + "@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "dev": true, + "requires": { + "@types/yargs-parser": "*" + } + } + } + }, + "jest-worker": { + "version": "27.0.6", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.0.6.tgz", + "integrity": "sha512-qupxcj/dRuA3xHPMUd40gr2EaAurFbkwzOh7wfPaeE9id7hyjURRQoqNfHifHK3XjJU6YJJUQKILGUnwGPEOCA==", + "dev": true, + "requires": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "dependencies": { + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, "jmespath": { "version": "0.15.0", "resolved": "https://registry.npmjs.org/jmespath/-/jmespath-0.15.0.tgz", @@ -1701,6 +4819,55 @@ "esprima": "^4.0.0" } }, + "jsdom": { + "version": "16.6.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-16.6.0.tgz", + "integrity": "sha512-Ty1vmF4NHJkolaEmdjtxTfSfkdb8Ywarwf63f+F8/mDD1uLSSWDxDuMiZxiPhwunLrn9LOSVItWj4bLYsLN3Dg==", + "dev": true, + "requires": { + "abab": "^2.0.5", + "acorn": "^8.2.4", + "acorn-globals": "^6.0.0", + "cssom": "^0.4.4", + "cssstyle": "^2.3.0", + "data-urls": "^2.0.0", + "decimal.js": "^10.2.1", + "domexception": "^2.0.1", + "escodegen": "^2.0.0", + "form-data": "^3.0.0", + "html-encoding-sniffer": "^2.0.1", + "http-proxy-agent": "^4.0.1", + "https-proxy-agent": "^5.0.0", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.0", + "parse5": "6.0.1", + "saxes": "^5.0.1", + "symbol-tree": "^3.2.4", + "tough-cookie": "^4.0.0", + "w3c-hr-time": "^1.0.2", + "w3c-xmlserializer": "^2.0.0", + "webidl-conversions": "^6.1.0", + "whatwg-encoding": "^1.0.5", + "whatwg-mimetype": "^2.3.0", + "whatwg-url": "^8.5.0", + "ws": "^7.4.5", + "xml-name-validator": "^3.0.0" + }, + "dependencies": { + "acorn": { + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.4.1.tgz", + "integrity": "sha512-asabaBSkEKosYKMITunzX177CXxQ4Q8BSSzMTKD+FefUhipQC70gfW5SiUDhYQ3vk8G+81HqQk7Fv9OXwwn9KA==", + "dev": true + } + } + }, + "jsesc": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", + "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", + "dev": true + }, "json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -1713,11 +4880,26 @@ "integrity": "sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=", "dev": true }, + "json5": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.0.tgz", + "integrity": "sha512-f+8cldu7X/y7RAJurMEJmdoKXGB/X550w2Nr3tTbezL6RwEE/iMcm+tZnXeoZtKuOq6ft8+CqzEkrIgx1fPoQA==", + "dev": true, + "requires": { + "minimist": "^1.2.5" + } + }, "jsrsasign": { "version": "8.0.24", "resolved": "https://registry.npmjs.org/jsrsasign/-/jsrsasign-8.0.24.tgz", "integrity": "sha512-u45jAyusqUpyGbFc2IbHoeE4rSkoBWQgLe/w99temHenX+GyCz4nflU5sjK7ajU1ffZTezl6le7u43Yjr/lkQg==" }, + "kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "dev": true + }, "leven": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-2.1.0.tgz", @@ -1734,6 +4916,15 @@ "type-check": "~0.4.0" } }, + "locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "requires": { + "p-locate": "^4.1.0" + } + }, "lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", @@ -1786,6 +4977,38 @@ "yallist": "^4.0.0" } }, + "make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "dev": true, + "requires": { + "semver": "^6.0.0" + }, + "dependencies": { + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true + } + } + }, + "make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true + }, + "makeerror": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.11.tgz", + "integrity": "sha1-4BpckQnyr3lmDk6LlYd5AYT1qWw=", + "dev": true, + "requires": { + "tmpl": "1.0.x" + } + }, "media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", @@ -1796,6 +5019,12 @@ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", "integrity": "sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E=" }, + "merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true + }, "merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -1835,6 +5064,12 @@ "mime-db": "1.48.0" } }, + "mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true + }, "minimalistic-assert": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", @@ -1854,6 +5089,18 @@ "brace-expansion": "^1.1.7" } }, + "minimist": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", + "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==", + "dev": true + }, + "mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true + }, "mri": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/mri/-/mri-1.1.4.tgz", @@ -1915,6 +5162,45 @@ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.2.tgz", "integrity": "sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw==" }, + "node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha1-h6kGXNs1XTGC2PlM4RGIuCXGijs=", + "dev": true + }, + "node-modules-regexp": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-modules-regexp/-/node-modules-regexp-1.0.0.tgz", + "integrity": "sha1-jZ2+KJZKSsVxLpExZCEHxx6Q7EA=", + "dev": true + }, + "node-releases": { + "version": "1.1.73", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-1.1.73.tgz", + "integrity": "sha512-uW7fodD6pyW2FZNZnp/Z3hvWKeEW1Y8R1+1CnErE8cXFXzl5blBOoVB41CvMer6P6Q0S5FXDwcHgFd1Wj0U9zg==", + "dev": true + }, + "normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true + }, + "npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "requires": { + "path-key": "^3.0.0" + } + }, + "nwsapi": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.0.tgz", + "integrity": "sha512-h2AatdwYH+JHiZpv7pt/gSX1XoRGb7L/qSIeuqA6GwYoF9w1vP1cw42TO0aI2pNyshRK5893hNSl+1//vHK7hQ==", + "dev": true + }, "object-inspect": { "version": "1.10.3", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.10.3.tgz", @@ -1937,6 +5223,15 @@ "wrappy": "1" } }, + "onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "requires": { + "mimic-fn": "^2.1.0" + } + }, "optionator": { "version": "0.9.1", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz", @@ -1951,11 +5246,41 @@ "word-wrap": "^1.2.3" } }, + "p-each-series": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-each-series/-/p-each-series-2.2.0.tgz", + "integrity": "sha512-ycIL2+1V32th+8scbpTvyHNaHe02z0sjgh91XXjAk+ZeXoPN4Z46DVUnzdso0aX4KckKw0FNNFHdjZ2UsZvxiA==", + "dev": true + }, + "p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "requires": { + "p-try": "^2.0.0" + } + }, + "p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "requires": { + "p-limit": "^2.2.0" + } + }, "p-map": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/p-map/-/p-map-2.1.0.tgz", "integrity": "sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==" }, + "p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true + }, "parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -1965,6 +5290,12 @@ "callsites": "^3.0.0" } }, + "parse5": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz", + "integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==", + "dev": true + }, "parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -1993,6 +5324,12 @@ "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", "integrity": "sha1-tVOaqPwiWj0a0XlHbd8ja0QPUuQ=" }, + "path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true + }, "path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -2005,6 +5342,12 @@ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", "dev": true }, + "path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true + }, "path-to-regexp": { "version": "0.1.7", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", @@ -2088,6 +5431,15 @@ "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-3.2.0.tgz", "integrity": "sha512-EqX4pwDPrt3MuOAAUBMU0Tk5kR/YcCM5fNPEzgCO2zJ5HfX0vbiH9HbJglnyeQsN96Kznae6MWD47pZB5avTrg==" }, + "pirates": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.1.tgz", + "integrity": "sha512-WuNqLTbMI3tmfef2TKxlQmAiLHKtFhlsCZnPIpuv2Ow0RDVO8lfy1Opf4NUzlMXLjPl+Men7AuVdX6TA+s+uGA==", + "dev": true, + "requires": { + "node-modules-regexp": "^1.0.0" + } + }, "pkcs11js": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/pkcs11js/-/pkcs11js-1.2.5.tgz", @@ -2097,6 +5449,15 @@ "nan": "^2.14.2" } }, + "pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "requires": { + "find-up": "^4.0.0" + } + }, "prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -2118,6 +5479,44 @@ "fast-diff": "^1.1.2" } }, + "pretty-format": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-26.6.2.tgz", + "integrity": "sha512-7AeGuCYNGmycyQbCqd/3PWH4eOoX/OiCa0uphp57NVTeAGdJGaAliecxwBDHYQCIvrW7aDBZCYeNTP/WX69mkg==", + "dev": true, + "requires": { + "@jest/types": "^26.6.2", + "ansi-regex": "^5.0.0", + "ansi-styles": "^4.0.0", + "react-is": "^17.0.1" + }, + "dependencies": { + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "requires": { + "color-convert": "^2.0.1" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + } + } + }, "progress": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", @@ -2129,6 +5528,16 @@ "resolved": "https://registry.npmjs.org/promise-settle/-/promise-settle-0.3.0.tgz", "integrity": "sha1-tO/VcqHrdM95T4KM00naQKCOTpY=" }, + "prompts": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.1.tgz", + "integrity": "sha512-EQyfIuO2hPDsX1L/blblV+H7I0knhgAd82cVneCwcdND9B8AuCDuRcBH6yIcG4dFzlOUqbazQqwGjx5xmsNLuQ==", + "dev": true, + "requires": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + } + }, "protobufjs": { "version": "6.11.2", "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-6.11.2.tgz", @@ -2210,6 +5619,12 @@ "unpipe": "1.0.0" } }, + "react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true + }, "readable-stream": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", @@ -2256,6 +5671,33 @@ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", "dev": true }, + "resolve": { + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.20.0.tgz", + "integrity": "sha512-wENBPt4ySzg4ybFQW2TT1zMQucPK95HSh/nq2CFTZVOGut2+pQvSsgtda4d26YrYcr067wjbmzOG8byDPBX63A==", + "dev": true, + "requires": { + "is-core-module": "^2.2.0", + "path-parse": "^1.0.6" + } + }, + "resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "requires": { + "resolve-from": "^5.0.0" + }, + "dependencies": { + "resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true + } + } + }, "resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -2302,6 +5744,15 @@ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, + "saxes": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-5.0.1.tgz", + "integrity": "sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==", + "dev": true, + "requires": { + "xmlchars": "^2.2.0" + } + }, "secure-keys": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/secure-keys/-/secure-keys-1.0.0.tgz", @@ -2384,6 +5835,18 @@ "object-inspect": "^1.9.0" } }, + "signal-exit": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.3.tgz", + "integrity": "sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA==", + "dev": true + }, + "sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true + }, "sjcl": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/sjcl/-/sjcl-1.0.8.tgz", @@ -2475,6 +5938,23 @@ "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", "integrity": "sha1-VHxws0fo0ytOEI6hoqFZ5f3eGcA=" }, + "stack-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-gL//fkxfWUsIlFL2Tl42Cl6+HFALEaB1FU76I/Fy+oZjRreP7OPMXFlGbxM7NQsI0ZpUfw76sHnv0WNYuTb7Iw==", + "dev": true, + "requires": { + "escape-string-regexp": "^2.0.0" + }, + "dependencies": { + "escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true + } + } + }, "standard-as-callback": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", @@ -2485,6 +5965,16 @@ "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", "integrity": "sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow=" }, + "string-length": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", + "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", + "dev": true, + "requires": { + "char-regex": "^1.0.2", + "strip-ansi": "^6.0.0" + } + }, "string-width": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.2.tgz", @@ -2520,12 +6010,85 @@ "ansi-regex": "^5.0.0" } }, + "strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true + }, + "strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true + }, "strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", "dev": true }, + "superagent": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-6.1.0.tgz", + "integrity": "sha512-OUDHEssirmplo3F+1HWKUrUjvnQuA+nZI6i/JJBdXb5eq9IyEQwPyPpqND+SSsxf6TygpBEkUjISVRN4/VOpeg==", + "dev": true, + "requires": { + "component-emitter": "^1.3.0", + "cookiejar": "^2.1.2", + "debug": "^4.1.1", + "fast-safe-stringify": "^2.0.7", + "form-data": "^3.0.0", + "formidable": "^1.2.2", + "methods": "^1.1.2", + "mime": "^2.4.6", + "qs": "^6.9.4", + "readable-stream": "^3.6.0", + "semver": "^7.3.2" + }, + "dependencies": { + "debug": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.2.tgz", + "integrity": "sha512-mOp8wKcvj7XxC78zLgw/ZA+6TSgkoE2C/ienthhRD298T7UNwAg9diBpLRxC0mOezLl4B0xV7M0cCO6P/O0Xhw==", + "dev": true, + "requires": { + "ms": "2.1.2" + } + }, + "mime": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.5.2.tgz", + "integrity": "sha512-tqkh47FzKeCPD2PUiPB6pkbMzsCasjxAfC62/Wap5qrUWcb+sFasXUC5I3gYM5iBM8v/Qpn4UK0x+j0iHyFPDg==", + "dev": true + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "qs": { + "version": "6.10.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.10.1.tgz", + "integrity": "sha512-M528Hph6wsSVOBiYUnGf+K/7w0hNshs/duGsNXPUCLH5XAqjEtiPGwNONLV0tBH8NoGb0mvD5JubnUTrujKDTg==", + "dev": true, + "requires": { + "side-channel": "^1.0.4" + } + } + } + }, + "supertest": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-6.1.4.tgz", + "integrity": "sha512-giC9Zm+Bf1CZP09ciPdUyl+XlMAu6rbch79KYiYKOGcbK2R1wH8h+APul1i/3wN6RF1XfWOIF+8X1ga+7SBrug==", + "dev": true, + "requires": { + "methods": "^1.1.2", + "superagent": "^6.1.0" + } + }, "supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", @@ -2535,6 +6098,39 @@ "has-flag": "^3.0.0" } }, + "supports-hyperlinks": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-2.2.0.tgz", + "integrity": "sha512-6sXEzV5+I5j8Bmq9/vUphGRM/RJNT9SCURJLjwfOg51heRtguGWDzcaBlgAzKhQa0EVNpPEKzQuBwZ8S8WaCeQ==", + "dev": true, + "requires": { + "has-flag": "^4.0.0", + "supports-color": "^7.0.0" + }, + "dependencies": { + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, + "symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true + }, "table": { "version": "6.7.1", "resolved": "https://registry.npmjs.org/table/-/table-6.7.1.tgz", @@ -2569,12 +6165,51 @@ } } }, + "terminal-link": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-2.1.1.tgz", + "integrity": "sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ==", + "dev": true, + "requires": { + "ansi-escapes": "^4.2.1", + "supports-hyperlinks": "^2.0.0" + } + }, + "test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "requires": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + } + }, "text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", "integrity": "sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=", "dev": true }, + "throat": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/throat/-/throat-6.0.1.tgz", + "integrity": "sha512-8hmiGIJMDlwjg7dlJ4yKGLK8EsYqKgPWbG3b4wjJddKNwc7N7Dpn08Df4szr/sZdMVeOstrdYSsqzX6BYbcB+w==", + "dev": true + }, + "tmpl": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.4.tgz", + "integrity": "sha1-I2QN17QtAEM5ERQIIOXPRA5SHdE=", + "dev": true + }, + "to-fast-properties": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", + "integrity": "sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4=", + "dev": true + }, "to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -2606,6 +6241,67 @@ } } }, + "tr46": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-2.1.0.tgz", + "integrity": "sha512-15Ih7phfcdP5YxqiB+iDtLoaTz4Nd35+IiAv0kQ5FNKHzXgdWqPoTIqEDDJmXceQt4JZk6lVPT8lnDlPpGDppw==", + "dev": true, + "requires": { + "punycode": "^2.1.1" + }, + "dependencies": { + "punycode": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", + "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", + "dev": true + } + } + }, + "ts-jest": { + "version": "27.0.4", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-27.0.4.tgz", + "integrity": "sha512-c4E1ECy9Xz2WGfTMyHbSaArlIva7Wi2p43QOMmCqjSSjHP06KXv+aT+eSY+yZMuqsMi3k7pyGsGj2q5oSl5WfQ==", + "dev": true, + "requires": { + "bs-logger": "0.x", + "buffer-from": "1.x", + "fast-json-stable-stringify": "2.x", + "jest-util": "^27.0.0", + "json5": "2.x", + "lodash": "4.x", + "make-error": "1.x", + "mkdirp": "1.x", + "semver": "7.x", + "yargs-parser": "20.x" + } + }, + "ts-node": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.1.0.tgz", + "integrity": "sha512-6szn3+J9WyG2hE+5W8e0ruZrzyk1uFLYye6IGMBadnOzDh8aP7t8CbFpsfCiEx2+wMixAhjFt7lOZC4+l+WbEA==", + "dev": true, + "requires": { + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "source-map-support": "^0.5.17", + "yn": "3.1.1" + }, + "dependencies": { + "yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true + } + } + }, "tslib": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", @@ -2630,6 +6326,12 @@ "prelude-ls": "^1.2.1" } }, + "type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true + }, "type-fest": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", @@ -2645,10 +6347,19 @@ "mime-types": "~2.1.24" } }, + "typedarray-to-buffer": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", + "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==", + "dev": true, + "requires": { + "is-typedarray": "^1.0.0" + } + }, "typescript": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.3.4.tgz", - "integrity": "sha512-uauPG7XZn9F/mo+7MrsRjyvbxFpzemRjKEZXS4AK83oP2KKOJPvb+9cO/gmnv8arWZvhnjVOXz7B49m1l0e9Ew==", + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.3.5.tgz", + "integrity": "sha512-DqQgihaQ9cUrskJo9kIyW/+g0Vxsk8cDtZ52a3NGh0YNTfpUSArXSohyUGnvbPazEPLu398C0UxmKSOrPumUzA==", "dev": true }, "universalify": { @@ -2695,6 +6406,25 @@ "integrity": "sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA==", "dev": true }, + "v8-to-istanbul": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-8.0.0.tgz", + "integrity": "sha512-LkmXi8UUNxnCC+JlH7/fsfsKr5AU110l+SYGJimWNkWhxbN5EyeOtm1MJ0hhvqMMOhGwBj1Fp70Yv9i+hX0QAg==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^1.6.0", + "source-map": "^0.7.3" + }, + "dependencies": { + "source-map": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.3.tgz", + "integrity": "sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==", + "dev": true + } + } + }, "validator": { "version": "13.6.0", "resolved": "https://registry.npmjs.org/validator/-/validator-13.6.0.tgz", @@ -2705,6 +6435,65 @@ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", "integrity": "sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=" }, + "w3c-hr-time": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz", + "integrity": "sha512-z8P5DvDNjKDoFIHK7q8r8lackT6l+jo/Ye3HOle7l9nICP9lf1Ci25fy9vHd0JOWewkIFzXIEig3TdKT7JQ5fQ==", + "dev": true, + "requires": { + "browser-process-hrtime": "^1.0.0" + } + }, + "w3c-xmlserializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-2.0.0.tgz", + "integrity": "sha512-4tzD0mF8iSiMiNs30BiLO3EpfGLZUT2MSX/G+o7ZywDzliWQ3OPtTZ0PTC3B3ca1UAf4cJMHB+2Bf56EriJuRA==", + "dev": true, + "requires": { + "xml-name-validator": "^3.0.0" + } + }, + "walker": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.7.tgz", + "integrity": "sha1-L3+bj9ENZ3JisYqITijRlhjgKPs=", + "dev": true, + "requires": { + "makeerror": "1.0.x" + } + }, + "webidl-conversions": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-6.1.0.tgz", + "integrity": "sha512-qBIvFLGiBpLjfwmYAaHPXsn+ho5xZnGvyGvsarywGNc8VyQJUMHJ8OBKGGrPER0okBeMDaan4mNBlgBROxuI8w==", + "dev": true + }, + "whatwg-encoding": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-1.0.5.tgz", + "integrity": "sha512-b5lim54JOPN9HtzvK9HFXvBma/rnfFeqsic0hSpjtDbVxR3dJKLc+KB4V6GgiGOvl7CY/KNh8rxSo9DKQrnUEw==", + "dev": true, + "requires": { + "iconv-lite": "0.4.24" + } + }, + "whatwg-mimetype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-2.3.0.tgz", + "integrity": "sha512-M4yMwr6mAnQz76TbJm914+gPpB/nCwvZbJU28cUD6dR004SAxDLOOSUaB1JDRqLtaOV/vi0IC5lEAGFgrjGv/g==", + "dev": true + }, + "whatwg-url": { + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-8.7.0.tgz", + "integrity": "sha512-gAojqb/m9Q8a5IV96E3fHJM70AzCkgt4uXYX2O7EmuyOnLrViCQlsEBmF9UQIu3/aeAIp2U17rtbpZWNntQqdg==", + "dev": true, + "requires": { + "lodash": "^4.7.0", + "tr46": "^2.1.0", + "webidl-conversions": "^6.1.0" + } + }, "which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -2779,6 +6568,36 @@ "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", "dev": true }, + "write-file-atomic": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-3.0.3.tgz", + "integrity": "sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==", + "dev": true, + "requires": { + "imurmurhash": "^0.1.4", + "is-typedarray": "^1.0.0", + "signal-exit": "^3.0.2", + "typedarray-to-buffer": "^3.1.5" + } + }, + "ws": { + "version": "7.5.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.3.tgz", + "integrity": "sha512-kQ/dHIzuLrS6Je9+uv81ueZomEwH0qVYstcAQ4/Z93K8zeko9gtAbttJWzoC5ukqXY1PpoouV3+VSOqEAFt5wg==", + "dev": true + }, + "xml-name-validator": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-3.0.0.tgz", + "integrity": "sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw==", + "dev": true + }, + "xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true + }, "y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/asset-transfer-basic/rest-api-typescript/package.json b/asset-transfer-basic/rest-api-typescript/package.json index 1b1aa0e6..328e5f45 100644 --- a/asset-transfer-basic/rest-api-typescript/package.json +++ b/asset-transfer-basic/rest-api-typescript/package.json @@ -21,19 +21,25 @@ "devDependencies": { "@types/express": "^4.17.12", "@types/ioredis": "^4.26.4", + "@types/jest": "^26.0.24", "@types/node": "^15.12.4", "@types/passport": "^1.0.7", "@types/pino": "^6.3.8", "@types/pino-http": "^5.4.1", + "@types/supertest": "^2.0.11", "@typescript-eslint/eslint-plugin": "^4.28.0", "@typescript-eslint/parser": "^4.28.0", "eslint": "^7.29.0", "eslint-config-prettier": "^8.3.0", "eslint-plugin-prettier": "^3.4.0", + "jest": "^27.0.6", "pino-pretty": "^5.0.2", "prettier": "^2.3.1", "rimraf": "^3.0.2", - "typescript": "^4.3.4" + "supertest": "^6.1.4", + "ts-jest": "^27.0.4", + "ts-node": "^10.1.0", + "typescript": "^4.3.5" }, "scripts": { "prebuild": "npm run lint", @@ -45,7 +51,7 @@ "start": "node --require source-map-support/register ./dist", "start:dev": "node --require source-map-support/register --require dotenv/config ./dist | pino-pretty", "start:redis": "docker run -p 6379:6379 --name fabric-sample-redis -d redis", - "test": "echo \"Error: no test specified\" && exit 1" + "test": "jest" }, "author": "Hyperledger", "license": "Apache-2.0", diff --git a/asset-transfer-basic/rest-api-typescript/src/__mocks__/config.ts b/asset-transfer-basic/rest-api-typescript/src/__mocks__/config.ts new file mode 100644 index 00000000..b3a2bc44 --- /dev/null +++ b/asset-transfer-basic/rest-api-typescript/src/__mocks__/config.ts @@ -0,0 +1,55 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + */ + +export const logLevel = 'info'; + +export const port = '3000'; + +export const retryDelay = '3000'; + +export const asLocalHost = true; + +export const identityNameOrg1 = 'Org1'; + +export const identityNameOrg2 = 'Org2'; + +export const mspIdOrg1 = 'Org1MSP'; + +export const mspIdOrg2 = 'Org2MSP'; + +export const channelName = 'mychannel'; + +export const chaincodeName = 'basic'; + +export const commitTimeout = '3000'; + +export const endorseTimeout = '30'; + +export const connectionProfileOrg1 = '{"name":"mock-profile-org1"}'; + +export const certificateOrg1 = + '"-----BEGIN CERTIFICATE-----\\n...\\n-----END CERTIFICATE-----\\n"'; + +export const privateKeyOrg1 = + '"-----BEGIN PRIVATE KEY-----\\n...\\n-----END PRIVATE KEY-----\\n"'; + +export const connectionProfileOrg2 = '{"name":"mock-profile-org2"}'; + +export const certificateOrg2 = + '"-----BEGIN CERTIFICATE-----\\n...\\n-----END CERTIFICATE-----\\n"'; + +export const privateKeyOrg2 = + '"-----BEGIN PRIVATE KEY-----\\n...\\n-----END PRIVATE KEY-----\\n"'; + +export const redisHost = 'localhost'; + +export const redisPort = '6379'; + +export const redisUsername = 'conga'; + +export const redisPassword = ''; + +export const org1ApiKey = '123'; + +export const org2ApiKey = '456'; diff --git a/asset-transfer-basic/rest-api-typescript/src/__mocks__/fabric-network.ts b/asset-transfer-basic/rest-api-typescript/src/__mocks__/fabric-network.ts new file mode 100644 index 00000000..216baff7 --- /dev/null +++ b/asset-transfer-basic/rest-api-typescript/src/__mocks__/fabric-network.ts @@ -0,0 +1,41 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + */ + +import { mocked } from 'ts-jest/utils'; + +type FabricNetworkModule = jest.Mocked; + +const { + DefaultEventHandlerStrategies, + DefaultQueryHandlerStrategies, + Gateway, + Wallet, + Wallets, +}: FabricNetworkModule = jest.createMockFromModule('fabric-network'); + +mocked(Wallets.newInMemoryWallet).mockResolvedValue( + new Wallet({ + get: jest.fn(), + list: jest.fn(), + put: jest.fn(), + remove: jest.fn(), + }) +); + +mocked(Gateway.prototype.getNetwork).mockResolvedValue({ + getGateway: jest.fn(), + getContract: jest.fn(), + getChannel: jest.fn(), + addCommitListener: jest.fn(), + removeCommitListener: jest.fn(), + addBlockListener: jest.fn(), + removeBlockListener: jest.fn(), +}); + +export { + DefaultEventHandlerStrategies, + DefaultQueryHandlerStrategies, + Gateway, + Wallets, +}; diff --git a/asset-transfer-basic/rest-api-typescript/src/__tests__/api.test.ts b/asset-transfer-basic/rest-api-typescript/src/__tests__/api.test.ts new file mode 100644 index 00000000..5e0366b2 --- /dev/null +++ b/asset-transfer-basic/rest-api-typescript/src/__tests__/api.test.ts @@ -0,0 +1,31 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createServer } from '../server'; +import { Application } from 'express'; +import request from 'supertest'; + +jest.mock('../config'); +jest.mock('fabric-network'); +jest.mock('ioredis'); + +describe('Asset Transfer Besic REST API', () => { + let app: Application; + + beforeEach(async () => { + app = await createServer(); + }); + + describe('GET /ready', () => { + it('should respond with success json', async () => { + const response = await request(app).get('/ready'); + expect(response.statusCode).toEqual(200); + expect(response.header).toHaveProperty( + 'content-type', + 'application/json; charset=utf-8' + ); + expect(response.body.status).toEqual('OK'); + }); + }); +}); diff --git a/asset-transfer-basic/rest-api-typescript/src/config.ts b/asset-transfer-basic/rest-api-typescript/src/config.ts index 94c4d998..cc1b4216 100644 --- a/asset-transfer-basic/rest-api-typescript/src/config.ts +++ b/asset-transfer-basic/rest-api-typescript/src/config.ts @@ -28,15 +28,16 @@ export const asLocalHost = env .asBoolStrict(); export const identityNameOrg1 = 'Org1'; + export const identityNameOrg2 = 'Org2'; -const mspIdOrg1 = env +export const mspIdOrg1 = env .get('HLF_MSP_ID_ORG1') .default('Org1MSP') .example('Org1MSP') .asString(); -const mspIdOrg2 = env +export const mspIdOrg2 = env .get('HLF_MSP_ID_ORG2') .default('Org2MSP') .example('Org2MSP') @@ -66,7 +67,7 @@ export const endorseTimeout = env .example('30') .asIntPositive(); -const connectionProfileOrg1 = env +export const connectionProfileOrg1 = env .get('HLF_CONNECTION_PROFILE_ORG1') .required() .example( @@ -74,19 +75,19 @@ const connectionProfileOrg1 = env ) .asJsonObject(); -const certificateOrg1 = env +export const certificateOrg1 = env .get('HLF_CERTIFICATE_ORG1') .required() .example('"-----BEGIN CERTIFICATE-----\\n...\\n-----END CERTIFICATE-----\\n"') .asString(); -const privateKeyOrg1 = env +export const privateKeyOrg1 = env .get('HLF_PRIVATE_KEY_ORG1') .required() .example('"-----BEGIN PRIVATE KEY-----\\n...\\n-----END PRIVATE KEY-----\\n"') .asString(); -const connectionProfileOrg2 = env +export const connectionProfileOrg2 = env .get('HLF_CONNECTION_PROFILE_ORG2') .required() .example( @@ -94,13 +95,13 @@ const connectionProfileOrg2 = env ) .asJsonObject(); -const certificateOrg2 = env +export const certificateOrg2 = env .get('HLF_CERTIFICATE_ORG2') .required() .example('"-----BEGIN CERTIFICATE-----\\n...\\n-----END CERTIFICATE-----\\n"') .asString(); -const privateKeyOrg2 = env +export const privateKeyOrg2 = env .get('HLF_PRIVATE_KEY_ORG2') .required() .example('"-----BEGIN PRIVATE KEY-----\\n...\\n-----END PRIVATE KEY-----\\n"') @@ -136,19 +137,3 @@ export const org2ApiKey = env .required() .example('456') .asString(); - -export const ORG1_CONFIG = { - identityName: identityNameOrg1, - mspId: mspIdOrg1, - connectionProfile: connectionProfileOrg1, - certificate: certificateOrg1, - privateKey: privateKeyOrg1, -}; - -export const ORG2_CONFIG = { - identityName: identityNameOrg2, - mspId: mspIdOrg2, - connectionProfile: connectionProfileOrg2, - certificate: certificateOrg2, - privateKey: privateKeyOrg2, -}; diff --git a/asset-transfer-basic/rest-api-typescript/src/fabric.ts b/asset-transfer-basic/rest-api-typescript/src/fabric.ts index 1db73941..4e642700 100644 --- a/asset-transfer-basic/rest-api-typescript/src/fabric.ts +++ b/asset-transfer-basic/rest-api-typescript/src/fabric.ts @@ -40,9 +40,25 @@ interface FabricConfigType { privateKey: string; } +const ORG1_CONFIG = { + identityName: config.identityNameOrg1, + mspId: config.mspIdOrg1, + connectionProfile: config.connectionProfileOrg1, + certificate: config.certificateOrg1, + privateKey: config.privateKeyOrg1, +}; + +const ORG2_CONFIG = { + identityName: config.identityNameOrg2, + mspId: config.mspIdOrg2, + connectionProfile: config.connectionProfileOrg2, + certificate: config.certificateOrg2, + privateKey: config.privateKeyOrg2, +}; + const FabricDataMapper: { [key: string]: FabricConfigType } = { - [config.identityNameOrg1]: config.ORG1_CONFIG, - [config.identityNameOrg2]: config.ORG2_CONFIG, + [config.identityNameOrg1]: ORG1_CONFIG, + [config.identityNameOrg2]: ORG2_CONFIG, }; export const getGateway = async (org: string): Promise => { diff --git a/asset-transfer-basic/rest-api-typescript/src/server.ts b/asset-transfer-basic/rest-api-typescript/src/server.ts index 31dc77c7..5caf5799 100644 --- a/asset-transfer-basic/rest-api-typescript/src/server.ts +++ b/asset-transfer-basic/rest-api-typescript/src/server.ts @@ -67,6 +67,10 @@ export const createServer = async (): Promise => { // TBC } + if (process.env.NODE_ENV === 'test') { + // TBC + } + if (process.env.NODE_ENV === 'production') { app.use(helmet()); } From 4277f4a68f08198a2bf84b9afdec3ab7d7b101a0 Mon Sep 17 00:00:00 2001 From: James Taylor Date: Mon, 2 Aug 2021 16:04:23 +0100 Subject: [PATCH 28/59] Fix dist output The jest.config.ts file was causing unintended changes to the dist folder Signed-off-by: James Taylor --- .../rest-api-typescript/.eslintrc.json | 13 ++++++++++--- .../rest-api-typescript/tsconfig.json | 5 ++++- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/asset-transfer-basic/rest-api-typescript/.eslintrc.json b/asset-transfer-basic/rest-api-typescript/.eslintrc.json index 0eaa9dde..7fcdea66 100644 --- a/asset-transfer-basic/rest-api-typescript/.eslintrc.json +++ b/asset-transfer-basic/rest-api-typescript/.eslintrc.json @@ -10,8 +10,7 @@ "parser": "@typescript-eslint/parser", "parserOptions": { "ecmaVersion": 12, - "sourceType": "module", - "project": "./tsconfig.json" + "sourceType": "module" }, "plugins": [ "@typescript-eslint" @@ -23,5 +22,13 @@ "argsIgnorePattern": "^_" } ] - } + }, + "overrides": [ + { + "files": ["src/**/*.ts"], + "parserOptions": { + "project": ["./tsconfig.json"] + } + } + ] } diff --git a/asset-transfer-basic/rest-api-typescript/tsconfig.json b/asset-transfer-basic/rest-api-typescript/tsconfig.json index c0e23f1c..cb4a4a82 100644 --- a/asset-transfer-basic/rest-api-typescript/tsconfig.json +++ b/asset-transfer-basic/rest-api-typescript/tsconfig.json @@ -68,5 +68,8 @@ /* Advanced Options */ "skipLibCheck": true, /* Skip type checking of declaration files. */ "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ - } + }, + "include": [ + "src/" + ] } From 73049e01536c7a89a20a3917f991d1c293216d87 Mon Sep 17 00:00:00 2001 From: sapthasurendran Date: Tue, 3 Aug 2021 12:38:27 +0530 Subject: [PATCH 29/59] docker file for app readme update for docker added dockerignore added dumb-init env var added to compose readme update Signed-off-by: sapthasurendran --- README.md | 19 ++++++++++++++ .../rest-api-typescript/.dockerignore | 4 +++ .../rest-api-typescript/Dockerfile | 20 ++++++++++++++ .../rest-api-typescript/docker-compose.yaml | 26 +++++++++++++++++++ 4 files changed, 69 insertions(+) create mode 100644 asset-transfer-basic/rest-api-typescript/.dockerignore create mode 100644 asset-transfer-basic/rest-api-typescript/Dockerfile create mode 100644 asset-transfer-basic/rest-api-typescript/docker-compose.yaml diff --git a/README.md b/README.md index f8ba15c9..b5f40edf 100644 --- a/README.md +++ b/README.md @@ -105,3 +105,22 @@ curl --include --header "Content-Type: application/json" --header "X-Api-Key: ${ ```shell curl --include --header "X-Api-Key: ${SAMPLE_APIKEY}" --request DELETE http://localhost:3000/api/assets/asset7 ``` +## Steps to run the application using docker: + +Move to directory fabric-rest-sample/asset-transfer-basic/rest-api-typescript + +### Build docker image + docker build -t fabricapp . + +### Generate .env file + TEST_NETWORK_HOME=$HOME/fabric-samples/test-network ./scripts/generateEnv.sh + + Note: Connection profile need to use the peer container’s hostname instead of localhost. + +### Run docker containers + docker-compose up -d + + + + + diff --git a/asset-transfer-basic/rest-api-typescript/.dockerignore b/asset-transfer-basic/rest-api-typescript/.dockerignore new file mode 100644 index 00000000..4f3089df --- /dev/null +++ b/asset-transfer-basic/rest-api-typescript/.dockerignore @@ -0,0 +1,4 @@ +node_modules +npm-debug.log +Dockerfile +.gitignore diff --git a/asset-transfer-basic/rest-api-typescript/Dockerfile b/asset-transfer-basic/rest-api-typescript/Dockerfile new file mode 100644 index 00000000..5ebc77bd --- /dev/null +++ b/asset-transfer-basic/rest-api-typescript/Dockerfile @@ -0,0 +1,20 @@ +FROM node:14-alpine3.12 +RUN apk add dumb-init +WORKDIR /fabric_app/ + +COPY --chown=node:node . /fabric_app/ + +RUN npm ci + +RUN npm run build + +EXPOSE 3000 + +USER node +CMD dumb-init npm run start:dev + + + + + + diff --git a/asset-transfer-basic/rest-api-typescript/docker-compose.yaml b/asset-transfer-basic/rest-api-typescript/docker-compose.yaml new file mode 100644 index 00000000..1a2c7b93 --- /dev/null +++ b/asset-transfer-basic/rest-api-typescript/docker-compose.yaml @@ -0,0 +1,26 @@ +version: '3' +# Replace network name with the fabric test-network name +services: + redis: + image: 'redis' + ports: + - 6379:6379 + networks: + - net_test + + nodeapp: + image: 'fabricapp' + ports: + - 3000:3000 + env_file: + - ./.env + environment: + - REDIS_HOST=redis + - AS_LOCAL_HOST=false + networks: + - net_test + + +networks: + net_test: + external: true From b8509490ad26ff52f5dab6061b3e8bcc74395798 Mon Sep 17 00:00:00 2001 From: sapthasurendran Date: Fri, 30 Jul 2021 14:19:10 +0530 Subject: [PATCH 30/59] retry count check build err fix retry condition testcase retryCount to maxRetryCount Signed-off-by: sapthasurendran --- .../scripts/generateEnv.sh | 2 + .../src/__mocks__/config.ts | 3 +- .../src/__mocks__/fabric-network.ts | 12 +++ .../src/__tests__/fabric.test.ts | 97 +++++++++++++++++++ .../rest-api-typescript/src/config.ts | 6 ++ .../rest-api-typescript/src/fabric.ts | 16 ++- .../rest-api-typescript/src/redis.ts | 17 ++++ 7 files changed, 148 insertions(+), 5 deletions(-) create mode 100644 asset-transfer-basic/rest-api-typescript/src/__tests__/fabric.test.ts diff --git a/asset-transfer-basic/rest-api-typescript/scripts/generateEnv.sh b/asset-transfer-basic/rest-api-typescript/scripts/generateEnv.sh index c0af2374..902397f8 100755 --- a/asset-transfer-basic/rest-api-typescript/scripts/generateEnv.sh +++ b/asset-transfer-basic/rest-api-typescript/scripts/generateEnv.sh @@ -21,6 +21,8 @@ PORT=3000 RETRY_DELAY=3000 +MAX_RETRY_COUNT=5 + HLF_CONNECTION_PROFILE_ORG1=$(cat ${CONNECTION_PROFILE_FILE_ORG1} | jq -c .) HLF_CERTIFICATE_ORG1="$(cat ${CERTIFICATE_FILE_ORG1} | sed -e 's/$/\\n/' | tr -d '\r\n')" diff --git a/asset-transfer-basic/rest-api-typescript/src/__mocks__/config.ts b/asset-transfer-basic/rest-api-typescript/src/__mocks__/config.ts index b3a2bc44..42dddbab 100644 --- a/asset-transfer-basic/rest-api-typescript/src/__mocks__/config.ts +++ b/asset-transfer-basic/rest-api-typescript/src/__mocks__/config.ts @@ -7,6 +7,7 @@ export const logLevel = 'info'; export const port = '3000'; export const retryDelay = '3000'; +export const maxRetryCount = 5; export const asLocalHost = true; @@ -46,7 +47,7 @@ export const redisHost = 'localhost'; export const redisPort = '6379'; -export const redisUsername = 'conga'; +export const redisUsername = ''; export const redisPassword = ''; diff --git a/asset-transfer-basic/rest-api-typescript/src/__mocks__/fabric-network.ts b/asset-transfer-basic/rest-api-typescript/src/__mocks__/fabric-network.ts index 216baff7..9ae7e06e 100644 --- a/asset-transfer-basic/rest-api-typescript/src/__mocks__/fabric-network.ts +++ b/asset-transfer-basic/rest-api-typescript/src/__mocks__/fabric-network.ts @@ -32,10 +32,22 @@ mocked(Gateway.prototype.getNetwork).mockResolvedValue({ addBlockListener: jest.fn(), removeBlockListener: jest.fn(), }); +const getMockedNetwork = (getContract = jest.fn()) => { + return mocked(Gateway.prototype.getNetwork).mockResolvedValue({ + getGateway: jest.fn(), + getContract, + getChannel: jest.fn(), + addCommitListener: jest.fn(), + removeCommitListener: jest.fn(), + addBlockListener: jest.fn(), + removeBlockListener: jest.fn(), + }); +}; export { DefaultEventHandlerStrategies, DefaultQueryHandlerStrategies, Gateway, Wallets, + getMockedNetwork, }; diff --git a/asset-transfer-basic/rest-api-typescript/src/__tests__/fabric.test.ts b/asset-transfer-basic/rest-api-typescript/src/__tests__/fabric.test.ts new file mode 100644 index 00000000..16ec024d --- /dev/null +++ b/asset-transfer-basic/rest-api-typescript/src/__tests__/fabric.test.ts @@ -0,0 +1,97 @@ +import { retryTransaction } from '../fabric'; + +import { getMockedNetwork } from '../__mocks__/fabric-network'; +import { Redis } from 'ioredis'; +import * as redis from '../redis'; +// import { Gateway, Gateway, Gateway } from 'fabric-network'; + +/** + * retryTransaction + */ +jest.mock('../config'); + + +describe('Testing retryTransaction', () => { + let contract: any = null; + const transaction = { + submit: jest.fn().mockRejectedValue({}), + }; + const mockedContact = { + deserializeTransaction: jest.fn().mockReturnValue(transaction), + }; + beforeAll(async () => { + const rejectableGetContract = jest.fn().mockImplementation( + () => + mockedContact + ); + + const network = getMockedNetwork(rejectableGetContract)(''); + contract = (await network).getContract(''); + + }); + + describe('Check retry condition ', () => { + const transactionId = + '0ae62c01e4c4b112c3f3954a2f11243da76778e46df9ad2783bcbafc79652b95'; + const key = `txn:${transactionId}`; + const state = `{"name":"CreateAsset","nonce":"damqinq8nrI4n4qY8lFVsZw7RwG2ufrv","transactionId":${transactionId}`; + const args = '["test111","red",400,"Jean",101]'; + const timestamp = 1628078044362; + const savedTransaction = { + timestamp: timestamp.toString(), + state: state, + retries: '', + args: args, + }; + + let data: Record = {}; + beforeEach(() => { + data = {}; + const clearTransactionDetails = jest.spyOn( + redis, + 'clearTransactionDetails' + ); + clearTransactionDetails.mockImplementation( + async (redis: Redis, transactionId: string) => { + const key = `txn:${transactionId}`; + delete data[key]; + } + ); + + const incrementRetryCount = jest.spyOn(redis, 'incrementRetryCount'); + incrementRetryCount.mockImplementation( + async (redis: Redis, transactionId: string) => { + const key = `txn:${transactionId}`; + data[key].retries = (parseInt(data[key].retries) + 1).toString(); + } + ); + }); + it('Transaction should exist if retry count is less then max rety count', async () => { + savedTransaction.retries = '3'; + data = { [key]: savedTransaction }; + await retryTransaction( + contract, + redis.redis, + transactionId, + savedTransaction + ); + expect(data[key]).toMatchObject({ + timestamp: timestamp.toString(), + state: state, + retries: '4', + args: args, + }); + }); + it('Clear transaction once retry reaches max retry count ', async () => { + savedTransaction.retries = '5'; + data = { [key]: savedTransaction }; + await retryTransaction( + contract, + redis.redis, + transactionId, + savedTransaction + ); + expect(data[key]).toBe(undefined); + }); + }); +}); diff --git a/asset-transfer-basic/rest-api-typescript/src/config.ts b/asset-transfer-basic/rest-api-typescript/src/config.ts index cc1b4216..375fc953 100644 --- a/asset-transfer-basic/rest-api-typescript/src/config.ts +++ b/asset-transfer-basic/rest-api-typescript/src/config.ts @@ -21,6 +21,12 @@ export const retryDelay = env .example('3000') .asIntPositive(); + export const maxRetryCount = env + .get('MAX_RETRY_COUNT') + .default('5') + .example('5') + .asIntPositive(); + export const asLocalHost = env .get('AS_LOCAL_HOST') .default('true') diff --git a/asset-transfer-basic/rest-api-typescript/src/fabric.ts b/asset-transfer-basic/rest-api-typescript/src/fabric.ts index 4e642700..b28c6444 100644 --- a/asset-transfer-basic/rest-api-typescript/src/fabric.ts +++ b/asset-transfer-basic/rest-api-typescript/src/fabric.ts @@ -18,7 +18,11 @@ import { Request } from 'express'; import { Redis } from 'ioredis'; import * as config from './config'; import { logger } from './logger'; -import { storeTransactionDetails, clearTransactionDetails } from './redis'; +import { + storeTransactionDetails, + clearTransactionDetails, + incrementRetryCount, +} from './redis'; import { AssetExistsError, AssetNotFoundError, @@ -251,12 +255,12 @@ const handleError = (transactionId: string, err: Error): Error => { return new TransactionError('Transaction error', transactionId); }; -const retryTransaction = async ( +export const retryTransaction = async ( contract: Contract, redis: Redis, transactionId: string, savedTransaction: Record -) => { +): Promise => { logger.debug('Retrying transaction %s', transactionId); try { @@ -279,7 +283,11 @@ const retryTransaction = async ( savedTransaction.retries, transactionId ); - await (redis as Redis).hincrby(`txn:${transactionId}`, 'retries', 1); + if (parseInt(savedTransaction.retries) < config.maxRetryCount) { + await incrementRetryCount(redis, transactionId); + } else { + await clearTransactionDetails(redis, transactionId); + } } } }; diff --git a/asset-transfer-basic/rest-api-typescript/src/redis.ts b/asset-transfer-basic/rest-api-typescript/src/redis.ts index d799d800..2077a68b 100644 --- a/asset-transfer-basic/rest-api-typescript/src/redis.ts +++ b/asset-transfer-basic/rest-api-typescript/src/redis.ts @@ -70,3 +70,20 @@ export const clearTransactionDetails = async ( }; // TODO add getTransaction etc. helpers? + +export const incrementRetryCount = async ( + redis: Redis, + transactionId: string +): Promise => { + const key = `txn:${transactionId}`; + logger.debug('Incrementing retries fortransaction Key: %s', key); + try { + await (redis as Redis).hincrby(`txn:${transactionId}`, 'retries', 1); + } catch (err) { + logger.error( + err, + 'Error incrementing retries for transaction ID %s', + transactionId + ); + } +}; From 5bea58e501a3c908bdc7c5a5a43018aeb25b1577 Mon Sep 17 00:00:00 2001 From: sapthasurendran Date: Thu, 5 Aug 2021 17:12:14 +0530 Subject: [PATCH 31/59] retry condition moved to startretryloop Signed-off-by: sapthasurendran --- .../src/__tests__/fabric.test.ts | 24 ++++--------------- .../rest-api-typescript/src/config.ts | 2 +- .../rest-api-typescript/src/fabric.ts | 23 +++++++++--------- 3 files changed, 17 insertions(+), 32 deletions(-) diff --git a/asset-transfer-basic/rest-api-typescript/src/__tests__/fabric.test.ts b/asset-transfer-basic/rest-api-typescript/src/__tests__/fabric.test.ts index 16ec024d..ec2948b9 100644 --- a/asset-transfer-basic/rest-api-typescript/src/__tests__/fabric.test.ts +++ b/asset-transfer-basic/rest-api-typescript/src/__tests__/fabric.test.ts @@ -10,7 +10,6 @@ import * as redis from '../redis'; */ jest.mock('../config'); - describe('Testing retryTransaction', () => { let contract: any = null; const transaction = { @@ -20,17 +19,15 @@ describe('Testing retryTransaction', () => { deserializeTransaction: jest.fn().mockReturnValue(transaction), }; beforeAll(async () => { - const rejectableGetContract = jest.fn().mockImplementation( - () => - mockedContact - ); + const rejectableGetContract = jest + .fn() + .mockImplementation(() => mockedContact); const network = getMockedNetwork(rejectableGetContract)(''); contract = (await network).getContract(''); - }); - describe('Check retry condition ', () => { + describe('Check retry increment ', () => { const transactionId = '0ae62c01e4c4b112c3f3954a2f11243da76778e46df9ad2783bcbafc79652b95'; const key = `txn:${transactionId}`; @@ -66,7 +63,7 @@ describe('Testing retryTransaction', () => { } ); }); - it('Transaction should exist if retry count is less then max rety count', async () => { + it('retry count should incremnt to 4', async () => { savedTransaction.retries = '3'; data = { [key]: savedTransaction }; await retryTransaction( @@ -82,16 +79,5 @@ describe('Testing retryTransaction', () => { args: args, }); }); - it('Clear transaction once retry reaches max retry count ', async () => { - savedTransaction.retries = '5'; - data = { [key]: savedTransaction }; - await retryTransaction( - contract, - redis.redis, - transactionId, - savedTransaction - ); - expect(data[key]).toBe(undefined); - }); }); }); diff --git a/asset-transfer-basic/rest-api-typescript/src/config.ts b/asset-transfer-basic/rest-api-typescript/src/config.ts index 375fc953..019ab4dd 100644 --- a/asset-transfer-basic/rest-api-typescript/src/config.ts +++ b/asset-transfer-basic/rest-api-typescript/src/config.ts @@ -21,7 +21,7 @@ export const retryDelay = env .example('3000') .asIntPositive(); - export const maxRetryCount = env +export const maxRetryCount = env .get('MAX_RETRY_COUNT') .default('5') .example('5') diff --git a/asset-transfer-basic/rest-api-typescript/src/fabric.ts b/asset-transfer-basic/rest-api-typescript/src/fabric.ts index b28c6444..a9543190 100644 --- a/asset-transfer-basic/rest-api-typescript/src/fabric.ts +++ b/asset-transfer-basic/rest-api-typescript/src/fabric.ts @@ -136,13 +136,16 @@ export const startRetryLoop = (contract: Contract, redis: Redis): void => { const savedTransaction = await (redis as Redis).hgetall( `txn:${transactionId}` ); - - await retryTransaction( - contract, - redis, - transactionId, - savedTransaction - ); + if (parseInt(savedTransaction.retries) >= config.maxRetryCount) { + await clearTransactionDetails(redis, transactionId); + } else { + await retryTransaction( + contract, + redis, + transactionId, + savedTransaction + ); + } } } catch (err) { // TODO just log? @@ -283,11 +286,7 @@ export const retryTransaction = async ( savedTransaction.retries, transactionId ); - if (parseInt(savedTransaction.retries) < config.maxRetryCount) { - await incrementRetryCount(redis, transactionId); - } else { - await clearTransactionDetails(redis, transactionId); - } + await incrementRetryCount(redis, transactionId); } } }; From e81a7a8b4637e1b1d9f2a42c854fbf253a213f08 Mon Sep 17 00:00:00 2001 From: James Taylor Date: Thu, 5 Aug 2021 12:39:24 +0100 Subject: [PATCH 32/59] Update port number config Discovered that env-var will validate port numbers, which is nice Signed-off-by: James Taylor --- asset-transfer-basic/rest-api-typescript/src/config.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/asset-transfer-basic/rest-api-typescript/src/config.ts b/asset-transfer-basic/rest-api-typescript/src/config.ts index 019ab4dd..31b5f96f 100644 --- a/asset-transfer-basic/rest-api-typescript/src/config.ts +++ b/asset-transfer-basic/rest-api-typescript/src/config.ts @@ -13,7 +13,7 @@ export const port = env .get('PORT') .default('3000') .example('3000') - .asIntPositive(); + .asPortNumber(); export const retryDelay = env .get('RETRY_DELAY') @@ -123,7 +123,7 @@ export const redisPort = env .get('REDIS_PORT') .default('6379') .example('6379') - .asIntPositive(); + .asPortNumber(); export const redisUsername = env .get('REDIS_USERNAME') From f904adbf6ff15ffaa45a735d01a7489abf266f8c Mon Sep 17 00:00:00 2001 From: James Taylor Date: Mon, 9 Aug 2021 16:13:51 +0100 Subject: [PATCH 33/59] Add config spec tests Signed-off-by: James Taylor --- .../rest-api-typescript/jest.config.ts | 16 + .../rest-api-typescript/package-lock.json | 6 +- .../rest-api-typescript/package.json | 2 +- .../src/__mocks__/config.ts | 56 --- .../src/__tests__/api.test.ts | 1 - .../rest-api-typescript/src/config.spec.ts | 465 ++++++++++++++++++ .../rest-api-typescript/src/config.ts | 86 +++- .../rest-api-typescript/src/fabric.ts | 4 +- 8 files changed, 572 insertions(+), 64 deletions(-) delete mode 100644 asset-transfer-basic/rest-api-typescript/src/__mocks__/config.ts create mode 100644 asset-transfer-basic/rest-api-typescript/src/config.spec.ts diff --git a/asset-transfer-basic/rest-api-typescript/jest.config.ts b/asset-transfer-basic/rest-api-typescript/jest.config.ts index ba3bbbc4..d11325d5 100644 --- a/asset-transfer-basic/rest-api-typescript/jest.config.ts +++ b/asset-transfer-basic/rest-api-typescript/jest.config.ts @@ -190,3 +190,19 @@ export default { // Whether to use watchman for file crawling // watchman: true, }; + +// Required environment variable values for the config.ts file +process.env = Object.assign(process.env, { + HLF_CONNECTION_PROFILE_ORG1: '{"name":"mock-profile-org1"}', + HLF_CERTIFICATE_ORG1: + '"-----BEGIN CERTIFICATE-----\\nMOCK\\n-----END CERTIFICATE-----\\n"', + HLF_PRIVATE_KEY_ORG1: + '"-----BEGIN PRIVATE KEY-----\\nMOCK\\n-----END PRIVATE KEY-----\\n"', + HLF_CONNECTION_PROFILE_ORG2: '{"name":"mock-profile-org2"}', + HLF_CERTIFICATE_ORG2: + '"-----BEGIN CERTIFICATE-----\\nMOCK\\n-----END CERTIFICATE-----\\n"', + HLF_PRIVATE_KEY_ORG2: + '"-----BEGIN PRIVATE KEY-----\\nMOCK\\n-----END PRIVATE KEY-----\\n"', + ORG1_APIKEY: 'ORG1MOCKAPIKEY', + ORG2_APIKEY: 'ORG2MOCKAPIKEY', +}); diff --git a/asset-transfer-basic/rest-api-typescript/package-lock.json b/asset-transfer-basic/rest-api-typescript/package-lock.json index 4270a380..3c44608f 100644 --- a/asset-transfer-basic/rest-api-typescript/package-lock.json +++ b/asset-transfer-basic/rest-api-typescript/package-lock.json @@ -1275,9 +1275,9 @@ "dev": true }, "@types/node": { - "version": "15.12.4", - "resolved": "https://registry.npmjs.org/@types/node/-/node-15.12.4.tgz", - "integrity": "sha512-zrNj1+yqYF4WskCMOHwN+w9iuD12+dGm0rQ35HLl9/Ouuq52cEtd0CH9qMgrdNmi5ejC1/V7vKEXYubB+65DkA==" + "version": "15.14.7", + "resolved": "https://registry.npmjs.org/@types/node/-/node-15.14.7.tgz", + "integrity": "sha512-FA45p37/mLhpebgbPWWCKfOisTjxGK9lwcHlJ6XVLfu3NgfcazOJHdYUZCWPMK8QX4LhNZdmfo6iMz9FqpUbaw==" }, "@types/passport": { "version": "1.0.7", diff --git a/asset-transfer-basic/rest-api-typescript/package.json b/asset-transfer-basic/rest-api-typescript/package.json index 328e5f45..2f1e86fc 100644 --- a/asset-transfer-basic/rest-api-typescript/package.json +++ b/asset-transfer-basic/rest-api-typescript/package.json @@ -22,7 +22,7 @@ "@types/express": "^4.17.12", "@types/ioredis": "^4.26.4", "@types/jest": "^26.0.24", - "@types/node": "^15.12.4", + "@types/node": "^15.14.7", "@types/passport": "^1.0.7", "@types/pino": "^6.3.8", "@types/pino-http": "^5.4.1", diff --git a/asset-transfer-basic/rest-api-typescript/src/__mocks__/config.ts b/asset-transfer-basic/rest-api-typescript/src/__mocks__/config.ts deleted file mode 100644 index 42dddbab..00000000 --- a/asset-transfer-basic/rest-api-typescript/src/__mocks__/config.ts +++ /dev/null @@ -1,56 +0,0 @@ -/* - * SPDX-License-Identifier: Apache-2.0 - */ - -export const logLevel = 'info'; - -export const port = '3000'; - -export const retryDelay = '3000'; -export const maxRetryCount = 5; - -export const asLocalHost = true; - -export const identityNameOrg1 = 'Org1'; - -export const identityNameOrg2 = 'Org2'; - -export const mspIdOrg1 = 'Org1MSP'; - -export const mspIdOrg2 = 'Org2MSP'; - -export const channelName = 'mychannel'; - -export const chaincodeName = 'basic'; - -export const commitTimeout = '3000'; - -export const endorseTimeout = '30'; - -export const connectionProfileOrg1 = '{"name":"mock-profile-org1"}'; - -export const certificateOrg1 = - '"-----BEGIN CERTIFICATE-----\\n...\\n-----END CERTIFICATE-----\\n"'; - -export const privateKeyOrg1 = - '"-----BEGIN PRIVATE KEY-----\\n...\\n-----END PRIVATE KEY-----\\n"'; - -export const connectionProfileOrg2 = '{"name":"mock-profile-org2"}'; - -export const certificateOrg2 = - '"-----BEGIN CERTIFICATE-----\\n...\\n-----END CERTIFICATE-----\\n"'; - -export const privateKeyOrg2 = - '"-----BEGIN PRIVATE KEY-----\\n...\\n-----END PRIVATE KEY-----\\n"'; - -export const redisHost = 'localhost'; - -export const redisPort = '6379'; - -export const redisUsername = ''; - -export const redisPassword = ''; - -export const org1ApiKey = '123'; - -export const org2ApiKey = '456'; diff --git a/asset-transfer-basic/rest-api-typescript/src/__tests__/api.test.ts b/asset-transfer-basic/rest-api-typescript/src/__tests__/api.test.ts index 5e0366b2..0da71ca3 100644 --- a/asset-transfer-basic/rest-api-typescript/src/__tests__/api.test.ts +++ b/asset-transfer-basic/rest-api-typescript/src/__tests__/api.test.ts @@ -6,7 +6,6 @@ import { createServer } from '../server'; import { Application } from 'express'; import request from 'supertest'; -jest.mock('../config'); jest.mock('fabric-network'); jest.mock('ioredis'); diff --git a/asset-transfer-basic/rest-api-typescript/src/config.spec.ts b/asset-transfer-basic/rest-api-typescript/src/config.spec.ts new file mode 100644 index 00000000..cdfc6ecb --- /dev/null +++ b/asset-transfer-basic/rest-api-typescript/src/config.spec.ts @@ -0,0 +1,465 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + */ + +/* eslint-disable @typescript-eslint/no-var-requires */ + +describe('Config values', () => { + const ORIGINAL_ENV = process.env; + + beforeEach(async () => { + jest.resetModules(); + process.env = { ...ORIGINAL_ENV }; + }); + + afterAll(() => { + process.env = { ...ORIGINAL_ENV }; + }); + + describe('logLevel', () => { + it('defaults to "info"', () => { + const config = require('./config'); + expect(config.logLevel).toBe('info'); + }); + + it('can be configured using the "LOG_LEVEL" environment variable', () => { + process.env.LOG_LEVEL = 'debug'; + const config = require('./config'); + expect(config.logLevel).toBe('debug'); + }); + + it('throws an error when the "LOG_LEVEL" environment variable has an invalid log level', () => { + process.env.LOG_LEVEL = 'ludicrous'; + expect(() => { + require('./config'); + }).toThrow( + 'env-var: "LOG_LEVEL" should be one of [fatal, error, warn, info, debug, trace, silent]' + ); + }); + }); + + describe('port', () => { + it('defaults to "3000"', () => { + const config = require('./config'); + expect(config.port).toBe(3000); + }); + + it('can be configured using the "PORT" environment variable', () => { + process.env.PORT = '8000'; + const config = require('./config'); + expect(config.port).toBe(8000); + }); + + it('throws an error when the "PORT" environment variable has an invalid port number', () => { + process.env.PORT = '65536'; + expect(() => { + require('./config'); + }).toThrow( + 'env-var: "PORT" cannot assign a port number greater than 65535. An example of a valid value would be: 3000' + ); + }); + }); + + describe('retryDelay', () => { + it('defaults to "3000"', () => { + const config = require('./config'); + expect(config.retryDelay).toBe(3000); + }); + + it('can be configured using the "RETRY_DELAY" environment variable', () => { + process.env.RETRY_DELAY = '9999'; + const config = require('./config'); + expect(config.retryDelay).toBe(9999); + }); + + it('throws an error when the "RETRY_DELAY" environment variable has an invalid number', () => { + process.env.RETRY_DELAY = 'short'; + expect(() => { + require('./config'); + }).toThrow( + 'env-var: "RETRY_DELAY" should be a valid integer. An example of a valid value would be: 3000' + ); + }); + }); + + describe('maxRetryCount', () => { + it('defaults to "5"', () => { + const config = require('./config'); + expect(config.maxRetryCount).toBe(5); + }); + + it('can be configured using the "MAX_RETRY_COUNT" environment variable', () => { + process.env.MAX_RETRY_COUNT = '9999'; + const config = require('./config'); + expect(config.maxRetryCount).toBe(9999); + }); + + it('throws an error when the "MAX_RETRY_COUNT" environment variable has an invalid number', () => { + process.env.MAX_RETRY_COUNT = 'lots'; + expect(() => { + require('./config'); + }).toThrow( + 'env-var: "MAX_RETRY_COUNT" should be a valid integer. An example of a valid value would be: 5' + ); + }); + }); + + describe('asLocalhost', () => { + it('defaults to "true"', () => { + const config = require('./config'); + expect(config.asLocalhost).toBe(true); + }); + + it('can be configured using the "AS_LOCAL_HOST" environment variable', () => { + process.env.AS_LOCAL_HOST = 'false'; + const config = require('./config'); + expect(config.asLocalhost).toBe(false); + }); + + it('throws an error when the "AS_LOCAL_HOST" environment variable has an invalid boolean value', () => { + process.env.AS_LOCAL_HOST = '11'; + expect(() => { + require('./config'); + }).toThrow( + 'env-var: "AS_LOCAL_HOST" should be either "true", "false", "TRUE", or "FALSE". An example of a valid value would be: true' + ); + }); + }); + + describe('mspIdOrg1', () => { + it('defaults to "Org1MSP"', () => { + const config = require('./config'); + expect(config.mspIdOrg1).toBe('Org1MSP'); + }); + + it('can be configured using the "HLF_MSP_ID_ORG1" environment variable', () => { + process.env.HLF_MSP_ID_ORG1 = 'Test1MSP'; + const config = require('./config'); + expect(config.mspIdOrg1).toBe('Test1MSP'); + }); + }); + + describe('mspIdOrg2', () => { + it('defaults to "Org2MSP"', () => { + const config = require('./config'); + expect(config.mspIdOrg2).toBe('Org2MSP'); + }); + + it('can be configured using the "HLF_MSP_ID_ORG2" environment variable', () => { + process.env.HLF_MSP_ID_ORG2 = 'Test2MSP'; + const config = require('./config'); + expect(config.mspIdOrg2).toBe('Test2MSP'); + }); + }); + + describe('channelName', () => { + it('defaults to "mychannel"', () => { + const config = require('./config'); + expect(config.channelName).toBe('mychannel'); + }); + + it('can be configured using the "HLF_CHANNEL_NAME" environment variable', () => { + process.env.HLF_CHANNEL_NAME = 'testchannel'; + const config = require('./config'); + expect(config.channelName).toBe('testchannel'); + }); + }); + + describe('chaincodeName', () => { + it('defaults to "basic"', () => { + const config = require('./config'); + expect(config.chaincodeName).toBe('basic'); + }); + + it('can be configured using the "HLF_CHAINCODE_NAME" environment variable', () => { + process.env.HLF_CHAINCODE_NAME = 'testcc'; + const config = require('./config'); + expect(config.chaincodeName).toBe('testcc'); + }); + }); + + describe('commitTimeout', () => { + it('defaults to "3000"', () => { + const config = require('./config'); + expect(config.commitTimeout).toBe(3000); + }); + + it('can be configured using the "HLF_COMMIT_TIMEOUT" environment variable', () => { + process.env.HLF_COMMIT_TIMEOUT = '9999'; + const config = require('./config'); + expect(config.commitTimeout).toBe(9999); + }); + + it('throws an error when the "HLF_COMMIT_TIMEOUT" environment variable has an invalid number', () => { + process.env.HLF_COMMIT_TIMEOUT = 'short'; + expect(() => { + require('./config'); + }).toThrow( + 'env-var: "HLF_COMMIT_TIMEOUT" should be a valid integer. An example of a valid value would be: 3000' + ); + }); + }); + + describe('endorseTimeout', () => { + it('defaults to "30"', () => { + const config = require('./config'); + expect(config.endorseTimeout).toBe(30); + }); + + it('can be configured using the "HLF_ENDORSE_TIMEOUT" environment variable', () => { + process.env.HLF_ENDORSE_TIMEOUT = '9999'; + const config = require('./config'); + expect(config.endorseTimeout).toBe(9999); + }); + + it('throws an error when the "HLF_ENDORSE_TIMEOUT" environment variable has an invalid number', () => { + process.env.HLF_ENDORSE_TIMEOUT = 'short'; + expect(() => { + require('./config'); + }).toThrow( + 'env-var: "HLF_ENDORSE_TIMEOUT" should be a valid integer. An example of a valid value would be: 30' + ); + }); + }); + + describe('queryTimeout', () => { + it('defaults to "3"', () => { + const config = require('./config'); + expect(config.queryTimeout).toBe(3); + }); + + it('can be configured using the "HLF_QUERY_TIMEOUT" environment variable', () => { + process.env.HLF_QUERY_TIMEOUT = '9999'; + const config = require('./config'); + expect(config.queryTimeout).toBe(9999); + }); + + it('throws an error when the "HLF_QUERY_TIMEOUT" environment variable has an invalid number', () => { + process.env.HLF_QUERY_TIMEOUT = 'long'; + expect(() => { + require('./config'); + }).toThrow( + 'env-var: "HLF_QUERY_TIMEOUT" should be a valid integer. An example of a valid value would be: 3' + ); + }); + }); + + describe('connectionProfileOrg1', () => { + it('throws an error when the "HLF_CONNECTION_PROFILE_ORG1" environment variable is not set', () => { + delete process.env.HLF_CONNECTION_PROFILE_ORG1; + expect(() => { + require('./config'); + }).toThrow( + 'env-var: "HLF_CONNECTION_PROFILE_ORG1" is a required variable, but it was not set. An example of a valid value would be: {"name":"test-network-org1","version":"1.0.0","client":{"organization":"Org1" ... }' + ); + }); + + it('can be configured using the "HLF_CONNECTION_PROFILE_ORG1" environment variable', () => { + process.env.HLF_CONNECTION_PROFILE_ORG1 = '{"name":"test-network-org1"}'; + const config = require('./config'); + expect(config.connectionProfileOrg1).toStrictEqual({ + name: 'test-network-org1', + }); + }); + + it('throws an error when the "HLF_CONNECTION_PROFILE_ORG1" environment variable is set to invalid json', () => { + process.env.HLF_CONNECTION_PROFILE_ORG1 = 'testing'; + expect(() => { + require('./config'); + }).toThrow( + 'env-var: "HLF_CONNECTION_PROFILE_ORG1" should be valid (parseable) JSON. An example of a valid value would be: {"name":"test-network-org1","version":"1.0.0","client":{"organization":"Org1" ... }' + ); + }); + }); + + describe('certificateOrg1', () => { + it('throws an error when the "HLF_CERTIFICATE_ORG1" environment variable is not set', () => { + delete process.env.HLF_CERTIFICATE_ORG1; + expect(() => { + require('./config'); + }).toThrow( + 'env-var: "HLF_CERTIFICATE_ORG1" is a required variable, but it was not set. An example of a valid value would be: "-----BEGIN CERTIFICATE-----\\n...\\n-----END CERTIFICATE-----\\n"' + ); + }); + + it('can be configured using the "HLF_CERTIFICATE_ORG1" environment variable', () => { + process.env.HLF_CERTIFICATE_ORG1 = 'ORG1CERT'; + const config = require('./config'); + expect(config.certificateOrg1).toBe('ORG1CERT'); + }); + }); + + describe('privateKeyOrg1', () => { + it('throws an error when the "HLF_PRIVATE_KEY_ORG1" environment variable is not set', () => { + delete process.env.HLF_PRIVATE_KEY_ORG1; + expect(() => { + require('./config'); + }).toThrow( + 'env-var: "HLF_PRIVATE_KEY_ORG1" is a required variable, but it was not set. An example of a valid value would be: "-----BEGIN PRIVATE KEY-----\\n...\\n-----END PRIVATE KEY-----\\n"' + ); + }); + + it('can be configured using the "HLF_PRIVATE_KEY_ORG1" environment variable', () => { + process.env.HLF_PRIVATE_KEY_ORG1 = 'ORG1PK'; + const config = require('./config'); + expect(config.privateKeyOrg1).toBe('ORG1PK'); + }); + }); + + describe('connectionProfileOrg2', () => { + it('throws an error when the "HLF_CONNECTION_PROFILE_ORG2" environment variable is not set', () => { + delete process.env.HLF_CONNECTION_PROFILE_ORG2; + expect(() => { + require('./config'); + }).toThrow( + 'env-var: "HLF_CONNECTION_PROFILE_ORG2" is a required variable, but it was not set. An example of a valid value would be: {"name":"test-network-org2","version":"1.0.0","client":{"organization":"Org2" ... }' + ); + }); + + it('can be configured using the "HLF_CONNECTION_PROFILE_ORG2" environment variable', () => { + process.env.HLF_CONNECTION_PROFILE_ORG2 = '{"name":"test-network-org2"}'; + const config = require('./config'); + expect(config.connectionProfileOrg2).toStrictEqual({ + name: 'test-network-org2', + }); + }); + + it('throws an error when the "HLF_CONNECTION_PROFILE_ORG2" environment variable is set to invalid json', () => { + process.env.HLF_CONNECTION_PROFILE_ORG2 = 'testing'; + expect(() => { + require('./config'); + }).toThrow( + 'env-var: "HLF_CONNECTION_PROFILE_ORG2" should be valid (parseable) JSON. An example of a valid value would be: {"name":"test-network-org2","version":"1.0.0","client":{"organization":"Org2" ... }' + ); + }); + }); + + describe('certificateOrg2', () => { + it('throws an error when the "HLF_CERTIFICATE_ORG2" environment variable is not set', () => { + delete process.env.HLF_CERTIFICATE_ORG2; + expect(() => { + require('./config'); + }).toThrow( + 'env-var: "HLF_CERTIFICATE_ORG2" is a required variable, but it was not set. An example of a valid value would be: "-----BEGIN CERTIFICATE-----\\n...\\n-----END CERTIFICATE-----\\n"' + ); + }); + + it('can be configured using the "HLF_CERTIFICATE_ORG2" environment variable', () => { + process.env.HLF_CERTIFICATE_ORG2 = 'ORG2CERT'; + const config = require('./config'); + expect(config.certificateOrg2).toBe('ORG2CERT'); + }); + }); + + describe('privateKeyOrg2', () => { + it('throws an error when the "HLF_PRIVATE_KEY_ORG2" environment variable is not set', () => { + delete process.env.HLF_PRIVATE_KEY_ORG2; + expect(() => { + require('./config'); + }).toThrow( + 'env-var: "HLF_PRIVATE_KEY_ORG2" is a required variable, but it was not set. An example of a valid value would be: "-----BEGIN PRIVATE KEY-----\\n...\\n-----END PRIVATE KEY-----\\n"' + ); + }); + + it('can be configured using the "HLF_PRIVATE_KEY_ORG2" environment variable', () => { + process.env.HLF_PRIVATE_KEY_ORG2 = 'ORG2PK'; + const config = require('./config'); + expect(config.privateKeyOrg2).toBe('ORG2PK'); + }); + }); + + describe('redisHost', () => { + it('defaults to "localhost"', () => { + const config = require('./config'); + expect(config.redisHost).toBe('localhost'); + }); + + it('can be configured using the "REDIS_HOST" environment variable', () => { + process.env.REDIS_HOST = 'redis.example.org'; + const config = require('./config'); + expect(config.redisHost).toBe('redis.example.org'); + }); + }); + + describe('redisPort', () => { + it('defaults to "6379"', () => { + const config = require('./config'); + expect(config.redisPort).toBe(6379); + }); + + it('can be configured with a valid port number using the "REDIS_PORT" environment variable', () => { + process.env.REDIS_PORT = '9736'; + const config = require('./config'); + expect(config.redisPort).toBe(9736); + }); + + it('throws an error when the "REDIS_PORT" environment variable has an invalid port number', () => { + process.env.REDIS_PORT = '65536'; + expect(() => { + require('./config'); + }).toThrow( + 'env-var: "REDIS_PORT" cannot assign a port number greater than 65535. An example of a valid value would be: 6379' + ); + }); + }); + + describe('redisUsername', () => { + it('has no default value', () => { + const config = require('./config'); + expect(config.redisUsername).toBeUndefined(); + }); + + it('can be configured using the "REDIS_USERNAME" environment variable', () => { + process.env.REDIS_USERNAME = 'test'; + const config = require('./config'); + expect(config.redisUsername).toBe('test'); + }); + }); + + describe('redisPassword', () => { + it('has no default value', () => { + const config = require('./config'); + expect(config.redisPassword).toBeUndefined(); + }); + + it('can be configured using the "REDIS_PASSWORD" environment variable', () => { + process.env.REDIS_PASSWORD = 'testpw'; + const config = require('./config'); + expect(config.redisPassword).toBe('testpw'); + }); + }); + + describe('org1ApiKey', () => { + it('throws an error when the "ORG1_APIKEY" environment variable is not set', () => { + delete process.env.ORG1_APIKEY; + expect(() => { + require('./config'); + }).toThrow( + 'env-var: "ORG1_APIKEY" is a required variable, but it was not set. An example of a valid value would be: 123' + ); + }); + + it('can be configured using the "ORG1_APIKEY" environment variable', () => { + process.env.ORG1_APIKEY = 'org1ApiKey'; + const config = require('./config'); + expect(config.org1ApiKey).toBe('org1ApiKey'); + }); + }); + + describe('org2ApiKey', () => { + it('throws an error when the "ORG1_APIKEY" environment variable is not set', () => { + delete process.env.ORG2_APIKEY; + expect(() => { + require('./config'); + }).toThrow( + 'env-var: "ORG2_APIKEY" is a required variable, but it was not set. An example of a valid value would be: 456' + ); + }); + + it('can be configured using the "ORG1_APIKEY" environment variable', () => { + process.env.ORG2_APIKEY = 'org2ApiKey'; + const config = require('./config'); + expect(config.org2ApiKey).toBe('org2ApiKey'); + }); + }); +}); diff --git a/asset-transfer-basic/rest-api-typescript/src/config.ts b/asset-transfer-basic/rest-api-typescript/src/config.ts index 31b5f96f..3a00c813 100644 --- a/asset-transfer-basic/rest-api-typescript/src/config.ts +++ b/asset-transfer-basic/rest-api-typescript/src/config.ts @@ -4,75 +4,124 @@ import * as env from 'env-var'; +/* + * Log level for the REST server + */ export const logLevel = env .get('LOG_LEVEL') .default('info') .asEnum(['fatal', 'error', 'warn', 'info', 'debug', 'trace', 'silent']); +/* + * The port to start the REST server on + */ export const port = env .get('PORT') .default('3000') .example('3000') .asPortNumber(); +/* + * The delay between each retry attempt in milliseconds + */ export const retryDelay = env .get('RETRY_DELAY') .default('3000') .example('3000') .asIntPositive(); +/* + * The maximum number of times to retry a failing transaction + */ export const maxRetryCount = env .get('MAX_RETRY_COUNT') .default('5') .example('5') .asIntPositive(); -export const asLocalHost = env +/* + * Whether to convert discovered host addresses to be 'localhost' + * This should be set to 'true' when running a docker composed fabric network on the + * local system, e.g. using the test network; otherwise should it should be 'false' + */ +export const asLocalhost = env .get('AS_LOCAL_HOST') .default('true') .example('true') .asBoolStrict(); +// TODO delete this and use mspIdOrg1 export const identityNameOrg1 = 'Org1'; +// TODO delete this and use mspIdOrg2 export const identityNameOrg2 = 'Org2'; +/* + * The Org1 MSP ID + */ export const mspIdOrg1 = env .get('HLF_MSP_ID_ORG1') .default('Org1MSP') .example('Org1MSP') .asString(); +/* + * The Org2 MSP ID + */ export const mspIdOrg2 = env .get('HLF_MSP_ID_ORG2') .default('Org2MSP') .example('Org2MSP') .asString(); +/* + * Name of the channel which the basic asset sample chaincode has been installed on + */ export const channelName = env .get('HLF_CHANNEL_NAME') .default('mychannel') .example('mychannel') .asString(); +/* + * Name used to install the basic asset sample + */ export const chaincodeName = env .get('HLF_CHAINCODE_NAME') .default('basic') .example('basic') .asString(); +/* + * The transaction submit timeout in seconds for commit notification to complete + */ export const commitTimeout = env .get('HLF_COMMIT_TIMEOUT') .default('3000') .example('3000') .asIntPositive(); +/* + * The transaction submit timeout in seconds for the endorsement to complete + */ export const endorseTimeout = env .get('HLF_ENDORSE_TIMEOUT') .default('30') .example('30') .asIntPositive(); +/* + * The transaction query timeout in seconds + */ +export const queryTimeout = env + .get('HLF_QUERY_TIMEOUT') + .default('3') + .example('3') + .asIntPositive(); + +/* + * The Org1 connection profile JSON + */ export const connectionProfileOrg1 = env .get('HLF_CONNECTION_PROFILE_ORG1') .required() @@ -81,18 +130,27 @@ export const connectionProfileOrg1 = env ) .asJsonObject(); +/* + * Certificate for the Org1 identity + */ export const certificateOrg1 = env .get('HLF_CERTIFICATE_ORG1') .required() .example('"-----BEGIN CERTIFICATE-----\\n...\\n-----END CERTIFICATE-----\\n"') .asString(); +/* + * Private key for the Org1 identity + */ export const privateKeyOrg1 = env .get('HLF_PRIVATE_KEY_ORG1') .required() .example('"-----BEGIN PRIVATE KEY-----\\n...\\n-----END PRIVATE KEY-----\\n"') .asString(); +/* + * The Org2 connection profile JSON + */ export const connectionProfileOrg2 = env .get('HLF_CONNECTION_PROFILE_ORG2') .required() @@ -101,43 +159,69 @@ export const connectionProfileOrg2 = env ) .asJsonObject(); +/* + * Certificate for the Org2 identity + */ export const certificateOrg2 = env .get('HLF_CERTIFICATE_ORG2') .required() .example('"-----BEGIN CERTIFICATE-----\\n...\\n-----END CERTIFICATE-----\\n"') .asString(); +/* + * Private key for the Org2 identity + */ export const privateKeyOrg2 = env .get('HLF_PRIVATE_KEY_ORG2') .required() .example('"-----BEGIN PRIVATE KEY-----\\n...\\n-----END PRIVATE KEY-----\\n"') .asString(); +/* + * The host the Redis server is running on + */ export const redisHost = env .get('REDIS_HOST') .default('localhost') .example('localhost') .asString(); +/* + * The port the Redis server is running on + */ export const redisPort = env .get('REDIS_PORT') .default('6379') .example('6379') .asPortNumber(); +/* + * Username for the Redis server + */ export const redisUsername = env .get('REDIS_USERNAME') .example('conga') .asString(); +/* + * Password for the Redis server + */ export const redisPassword = env.get('REDIS_PASSWORD').asString(); +/* + * API key for Org1 + * Specify this API key with the X-Api-Key header to use the Org1 connection profile and credentials + */ export const org1ApiKey = env .get('ORG1_APIKEY') .required() .example('123') .asString(); +/* + * API key for Org2 + * Specify this API key with the X-Api-Key header to use the Org2 connection profile and credentials + */ export const org2ApiKey = env .get('ORG2_APIKEY') .required() diff --git a/asset-transfer-basic/rest-api-typescript/src/fabric.ts b/asset-transfer-basic/rest-api-typescript/src/fabric.ts index a9543190..b7bee9a3 100644 --- a/asset-transfer-basic/rest-api-typescript/src/fabric.ts +++ b/asset-transfer-basic/rest-api-typescript/src/fabric.ts @@ -85,14 +85,14 @@ export const getGateway = async (org: string): Promise => { const connectOptions: GatewayOptions = { wallet, identity: fabricConfig.identityName, - discovery: { enabled: true, asLocalhost: config.asLocalHost }, + discovery: { enabled: true, asLocalhost: config.asLocalhost }, eventHandlerOptions: { commitTimeout: config.commitTimeout, endorseTimeout: config.endorseTimeout, strategy: DefaultEventHandlerStrategies.PREFER_MSPID_SCOPE_ANYFORTX, }, queryHandlerOptions: { - timeout: 3, + timeout: config.queryTimeout, strategy: DefaultQueryHandlerStrategies.PREFER_MSPID_SCOPE_ROUND_ROBIN, }, }; From 6477333743a8fc199918e05a453533fa846e441d Mon Sep 17 00:00:00 2001 From: sapthasurendran Date: Mon, 9 Aug 2021 11:38:21 +0530 Subject: [PATCH 34/59] redis mock class code cleanup created fabric.spec redis testcases and additional checks Signed-off-by: sapthasurendran --- .../rest-api-typescript/package-lock.json | 6 +- .../src/__mocks__/IORedis.ts | 21 +++++ .../src/__tests__/fabric.test.ts | 83 ---------------- .../rest-api-typescript/src/fabric.spec.ts | 94 +++++++++++++++++++ .../rest-api-typescript/src/fabric.ts | 5 +- .../rest-api-typescript/src/redis.spec.ts | 75 +++++++++++++++ .../rest-api-typescript/src/redis.ts | 54 +++++++---- 7 files changed, 231 insertions(+), 107 deletions(-) create mode 100644 asset-transfer-basic/rest-api-typescript/src/__mocks__/IORedis.ts delete mode 100644 asset-transfer-basic/rest-api-typescript/src/__tests__/fabric.test.ts create mode 100644 asset-transfer-basic/rest-api-typescript/src/fabric.spec.ts create mode 100644 asset-transfer-basic/rest-api-typescript/src/redis.spec.ts diff --git a/asset-transfer-basic/rest-api-typescript/package-lock.json b/asset-transfer-basic/rest-api-typescript/package-lock.json index 3c44608f..99399c39 100644 --- a/asset-transfer-basic/rest-api-typescript/package-lock.json +++ b/asset-transfer-basic/rest-api-typescript/package-lock.json @@ -1215,9 +1215,9 @@ } }, "@types/ioredis": { - "version": "4.26.4", - "resolved": "https://registry.npmjs.org/@types/ioredis/-/ioredis-4.26.4.tgz", - "integrity": "sha512-QFbjNq7EnOGw6d1gZZt2h26OFXjx7z+eqEnbCHSrDI1OOLEgOHMKdtIajJbuCr9uO+X9kQQRe7Lz6uxqxl5XKg==", + "version": "4.26.6", + "resolved": "https://registry.npmjs.org/@types/ioredis/-/ioredis-4.26.6.tgz", + "integrity": "sha512-Q9ydXL/5Mot751i7WLCm9OGTj5jlW3XBdkdEW21SkXZ8Y03srbkluFGbM3q8c+vzPW30JOLJ+NsZWHoly0+13A==", "dev": true, "requires": { "@types/node": "*" diff --git a/asset-transfer-basic/rest-api-typescript/src/__mocks__/IORedis.ts b/asset-transfer-basic/rest-api-typescript/src/__mocks__/IORedis.ts new file mode 100644 index 00000000..bc31167e --- /dev/null +++ b/asset-transfer-basic/rest-api-typescript/src/__mocks__/IORedis.ts @@ -0,0 +1,21 @@ +import { RedisOptions } from 'ioredis'; + +class IORedis { + redisOptions: RedisOptions; + constructor(options: RedisOptions) { + this.redisOptions = options; + } + + hincrby = jest.fn().mockReturnThis(); + multi = jest.fn().mockReturnThis(); + del = jest.fn().mockReturnThis(); + + zrem = jest.fn().mockReturnThis(); + + exec = jest.fn().mockReturnThis(); + + hset = jest.fn().mockReturnThis(); + zadd = jest.fn().mockReturnThis(); +} + +export default IORedis; diff --git a/asset-transfer-basic/rest-api-typescript/src/__tests__/fabric.test.ts b/asset-transfer-basic/rest-api-typescript/src/__tests__/fabric.test.ts deleted file mode 100644 index ec2948b9..00000000 --- a/asset-transfer-basic/rest-api-typescript/src/__tests__/fabric.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { retryTransaction } from '../fabric'; - -import { getMockedNetwork } from '../__mocks__/fabric-network'; -import { Redis } from 'ioredis'; -import * as redis from '../redis'; -// import { Gateway, Gateway, Gateway } from 'fabric-network'; - -/** - * retryTransaction - */ -jest.mock('../config'); - -describe('Testing retryTransaction', () => { - let contract: any = null; - const transaction = { - submit: jest.fn().mockRejectedValue({}), - }; - const mockedContact = { - deserializeTransaction: jest.fn().mockReturnValue(transaction), - }; - beforeAll(async () => { - const rejectableGetContract = jest - .fn() - .mockImplementation(() => mockedContact); - - const network = getMockedNetwork(rejectableGetContract)(''); - contract = (await network).getContract(''); - }); - - describe('Check retry increment ', () => { - const transactionId = - '0ae62c01e4c4b112c3f3954a2f11243da76778e46df9ad2783bcbafc79652b95'; - const key = `txn:${transactionId}`; - const state = `{"name":"CreateAsset","nonce":"damqinq8nrI4n4qY8lFVsZw7RwG2ufrv","transactionId":${transactionId}`; - const args = '["test111","red",400,"Jean",101]'; - const timestamp = 1628078044362; - const savedTransaction = { - timestamp: timestamp.toString(), - state: state, - retries: '', - args: args, - }; - - let data: Record = {}; - beforeEach(() => { - data = {}; - const clearTransactionDetails = jest.spyOn( - redis, - 'clearTransactionDetails' - ); - clearTransactionDetails.mockImplementation( - async (redis: Redis, transactionId: string) => { - const key = `txn:${transactionId}`; - delete data[key]; - } - ); - - const incrementRetryCount = jest.spyOn(redis, 'incrementRetryCount'); - incrementRetryCount.mockImplementation( - async (redis: Redis, transactionId: string) => { - const key = `txn:${transactionId}`; - data[key].retries = (parseInt(data[key].retries) + 1).toString(); - } - ); - }); - it('retry count should incremnt to 4', async () => { - savedTransaction.retries = '3'; - data = { [key]: savedTransaction }; - await retryTransaction( - contract, - redis.redis, - transactionId, - savedTransaction - ); - expect(data[key]).toMatchObject({ - timestamp: timestamp.toString(), - state: state, - retries: '4', - args: args, - }); - }); - }); -}); diff --git a/asset-transfer-basic/rest-api-typescript/src/fabric.spec.ts b/asset-transfer-basic/rest-api-typescript/src/fabric.spec.ts new file mode 100644 index 00000000..3cc7b1d8 --- /dev/null +++ b/asset-transfer-basic/rest-api-typescript/src/fabric.spec.ts @@ -0,0 +1,94 @@ +import { retryTransaction, getGateway } from './fabric'; +import { getMockedNetwork } from './__mocks__/fabric-network'; +import * as config from './config'; + +import IORedis from './__mocks__/IORedis'; +import { Redis } from 'ioredis'; +import { Contract } from 'fabric-network'; + +jest.mock('./config'); +jest.mock('ioredis'); +const redisOptions = { + port: config.redisPort, + host: config.redisHost, + username: config.redisUsername, + password: config.redisPassword, +}; + +const redis = new IORedis(redisOptions) as unknown as Redis; + +describe('Testing retryTransaction', () => { + const transactionId = + '0ae62c01e4c4b112c3f3954a2f11243da76778e46df9ad2783bcbafc79652b95'; + const state = `{"name":"CreateAsset","nonce":"damqinq8nrI4n4qY8lFVsZw7RwG2ufrv","transactionId":${transactionId}`; + const args = '["test111","red",400,"Jean",101]'; + const timestamp = 1628078044362; + const savedTransaction = { + timestamp: timestamp.toString(), + state: state, + retries: '', + args: args, + }; + + describe('Check retry increment ', () => { + const transactionId = + '0ae62c01e4c4b112c3f3954a2f11243da76778e46df9ad2783bcbafc79652b95'; + const state = `{"name":"CreateAsset","nonce":"damqinq8nrI4n4qY8lFVsZw7RwG2ufrv","transactionId":${transactionId}`; + const args = '["test111","red",400,"Jean",101]'; + const timestamp = 1628078044362; + const savedTransaction = { + timestamp: timestamp.toString(), + state: state, + retries: '', + args: args, + }; + + it('Transaction failure, check redis increment func call', async () => { + jest.doMock('fabric-network'); + const transaction = { + submit: jest.fn().mockRejectedValue({}), + }; + const mockedContact = { + deserializeTransaction: jest.fn().mockReturnValue(transaction), + }; + const rejectableGetContract = jest + .fn() + .mockImplementation(() => mockedContact); + + const network = getMockedNetwork(rejectableGetContract)(''); + const contract: Contract = (await network).getContract(''); + savedTransaction.retries = '3'; + await retryTransaction(contract, redis, transactionId, savedTransaction); + expect(redis.hincrby).toHaveBeenCalledTimes(1); + }); + }); + + describe('Transaction successful, check redis delete key func call ', () => { + it('call redis increment', async () => { + jest.doMock('fabric-network'); + const transaction = { + submit: jest.fn().mockResolvedValue({}), + }; + const mockedContact = { + deserializeTransaction: jest.fn().mockReturnValue(transaction), + }; + const resolvableGetContract = jest + .fn() + .mockImplementation(() => mockedContact); + + const network = getMockedNetwork(resolvableGetContract)(''); + const contract: Contract = (await network).getContract(''); + savedTransaction.retries = '3'; + await retryTransaction(contract, redis, transactionId, savedTransaction); + expect(redis.del).toHaveBeenCalledTimes(1); + }); + }); +}); + +describe('Test getGateway', () => { + it('should throw error for invalid org name', async () => { + expect(async () => await getGateway('')).rejects.toThrow( + 'Invalid org name for gateway' + ); + }); +}); diff --git a/asset-transfer-basic/rest-api-typescript/src/fabric.ts b/asset-transfer-basic/rest-api-typescript/src/fabric.ts index b7bee9a3..8af4fefe 100644 --- a/asset-transfer-basic/rest-api-typescript/src/fabric.ts +++ b/asset-transfer-basic/rest-api-typescript/src/fabric.ts @@ -67,6 +67,9 @@ const FabricDataMapper: { [key: string]: FabricConfigType } = { export const getGateway = async (org: string): Promise => { const fabricConfig = FabricDataMapper[org]; + if (fabricConfig == undefined) { + throw new Error('Invalid org name for gateway'); + } logger.debug('Configuring fabric gateway for %s', org); const wallet = await Wallets.newInMemoryWallet(); @@ -78,8 +81,8 @@ export const getGateway = async (org: string): Promise => { mspId: fabricConfig.mspId, type: 'X.509', }; - await wallet.put(fabricConfig.identityName, x509Identity); + await wallet.put(fabricConfig.identityName, x509Identity); const gateway = new Gateway(); const connectOptions: GatewayOptions = { diff --git a/asset-transfer-basic/rest-api-typescript/src/redis.spec.ts b/asset-transfer-basic/rest-api-typescript/src/redis.spec.ts new file mode 100644 index 00000000..7d81cb99 --- /dev/null +++ b/asset-transfer-basic/rest-api-typescript/src/redis.spec.ts @@ -0,0 +1,75 @@ +import IORedis from './__mocks__/IORedis'; +import * as config from './config'; +import { Redis } from 'ioredis'; +import { + clearTransactionDetails, + incrementRetryCount, + storeTransactionDetails, +} from './redis'; + +jest.mock('ioredis'); +jest.mock('./config'); + +const redisOptions = { + port: config.redisPort, + host: config.redisHost, + username: config.redisUsername, + password: config.redisPassword, +}; +const redis = new IORedis(redisOptions) as unknown as Redis; +describe('Testing increment retries ', () => { + const transactionId = + '0ae62c01e4c4b112c3f3954a2f11243da76778e46df9ad2783bcbafc79652b95'; + it('Should increment retries for valid transction id', async () => { + await incrementRetryCount(redis, transactionId); + expect(redis.hincrby).toHaveBeenCalledTimes(1); + }); + + it('Should not increment retries for empty transaction id ', async () => { + await incrementRetryCount(redis, ''); + expect(redis.hincrby).toHaveBeenCalledTimes(0); + }); +}); + +describe('Testing storeTransactionDetails ', () => { + const args = '["test111","red",400,"Jean",101]'; + const timestamp = 1628078044362; + it('Should store details for valid transction Id', async () => { + const transactionId = + '0ae62c01e4c4b112c3f3954a2f11243da76778e46df9ad2783bcbafc79652b95'; + const state = `{"name":"CreateAsset","nonce":"damqinq8nrI4n4qY8lFVsZw7RwG2ufrv","transactionId":${transactionId}`; + await storeTransactionDetails( + redis, + transactionId, + Buffer.from(state), + args, + timestamp + ); + expect(redis.hset).toHaveBeenCalledTimes(1); + expect(redis.zadd).toHaveBeenCalledTimes(1); + }); + + it('Should not store details for empty transction Id', async () => { + const transactionId = ''; + const state = `{"name":"CreateAsset","nonce":"damqinq8nrI4n4qY8lFVsZw7RwG2ufrv","transactionId":${transactionId}`; + await storeTransactionDetails( + redis, + transactionId, + Buffer.from(state), + args, + timestamp + ); + expect(redis.hset).toHaveBeenCalledTimes(0); + expect(redis.zadd).toHaveBeenCalledTimes(0); + }); +}); + +describe('Testing clearTransactionDetails ', () => { + it('Should clear details ', async () => { + const transactionId = + '0ae62c01e4c4b112c3f3954a2f11243da76778e46df9ad2783bcbafc79652b95'; + await clearTransactionDetails(redis, transactionId); + expect(redis.del).toHaveBeenCalledTimes(1); + expect(redis.zrem).toHaveBeenCalledTimes(1); + }); +}); diff --git a/asset-transfer-basic/rest-api-typescript/src/redis.ts b/asset-transfer-basic/rest-api-typescript/src/redis.ts index 2077a68b..c4e8b7c3 100644 --- a/asset-transfer-basic/rest-api-typescript/src/redis.ts +++ b/asset-transfer-basic/rest-api-typescript/src/redis.ts @@ -23,29 +23,40 @@ export const storeTransactionDetails = async ( transactionArgs: string, timestamp: number ): Promise => { - const key = `txn:${transactionId}`; - logger.debug( - 'Storing transaction details. Key: %s State: %s Args: %s Timestamp: %d', - key, - transactionState, - transactionArgs, - timestamp - ); - await redis - .multi() - .hset( + try { + if (transactionId.length === 0) { + throw new Error('Empty transaction Id found'); + } + const key = `txn:${transactionId}`; + logger.debug( + 'Storing transaction details. Key: %s State: %s Args: %s Timestamp: %d', key, - 'state', transactionState, - 'args', transactionArgs, - 'timestamp', - timestamp, - 'retries', - '0' - ) - .zadd('index:txn:timestamp', timestamp, transactionId) - .exec(); + timestamp + ); + await redis + .multi() + .hset( + key, + 'state', + transactionState, + 'args', + transactionArgs, + 'timestamp', + timestamp, + 'retries', + '0' + ) + .zadd('index:txn:timestamp', timestamp, transactionId) + .exec(); + } catch (err) { + logger.error( + err, + 'Error storing transaction details. ID %s', + transactionId + ); + } }; export const clearTransactionDetails = async ( @@ -78,6 +89,9 @@ export const incrementRetryCount = async ( const key = `txn:${transactionId}`; logger.debug('Incrementing retries fortransaction Key: %s', key); try { + if (transactionId.length === 0) { + throw new Error('Empty transaction Id found'); + } await (redis as Redis).hincrby(`txn:${transactionId}`, 'retries', 1); } catch (err) { logger.error( From 862080773e8a87ffbd686f0136298645e88c6d15 Mon Sep 17 00:00:00 2001 From: James Taylor Date: Mon, 2 Aug 2021 14:09:11 +0100 Subject: [PATCH 35/59] Initial API tests Signed-off-by: James Taylor --- .../rest-api-typescript/package-lock.json | 73 +++ .../rest-api-typescript/package.json | 2 + .../src/__mocks__/fabric-network.ts | 171 ++++- .../src/__tests__/api.test.ts | 592 +++++++++++++++++- .../rest-api-typescript/src/assets.router.ts | 5 +- .../rest-api-typescript/src/fabric.ts | 8 +- 6 files changed, 826 insertions(+), 25 deletions(-) diff --git a/asset-transfer-basic/rest-api-typescript/package-lock.json b/asset-transfer-basic/rest-api-typescript/package-lock.json index 99399c39..e7e35369 100644 --- a/asset-transfer-basic/rest-api-typescript/package-lock.json +++ b/asset-transfer-basic/rest-api-typescript/package-lock.json @@ -2934,6 +2934,31 @@ "bser": "2.1.1" } }, + "fengari": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/fengari/-/fengari-0.1.4.tgz", + "integrity": "sha512-6ujqUuiIYmcgkGz8MGAdERU57EIluGGPSUgGPTsco657EHa+srq0S3/YUl/r9kx1+D+d4rGfYObd+m8K22gB1g==", + "dev": true, + "requires": { + "readline-sync": "^1.4.9", + "sprintf-js": "^1.1.1", + "tmp": "^0.0.33" + }, + "dependencies": { + "sprintf-js": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.2.tgz", + "integrity": "sha512-VE0SOVEHCk7Qc8ulkWw3ntAzXuqf7S2lvwQaDLRnUeIEaKNQJzV6BwmLKhOqT61aGhfUMrXeaBk+oDGCzvhcug==", + "dev": true + } + } + }, + "fengari-interop": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/fengari-interop/-/fengari-interop-0.1.2.tgz", + "integrity": "sha512-8iTvaByZVoi+lQJhHH9vC+c/Yaok9CwOqNQZN6JrVpjmWwW4dDkeblBXhnHC+BoI6eF4Cy5NKW3z6ICEjvgywQ==", + "dev": true + }, "file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -3366,6 +3391,18 @@ } } }, + "ioredis-mock": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/ioredis-mock/-/ioredis-mock-5.6.0.tgz", + "integrity": "sha512-Ow+tyKdijg/gA2gSEv7lq8dLp6bO7FnwDXbJ9as37NF23XNRGMLzBc7ITaqMydfrbTodWnLcE2lKEaBs7SBpyA==", + "dev": true, + "requires": { + "fengari": "^0.1.4", + "fengari-interop": "^0.1.2", + "lodash": "^4.17.21", + "standard-as-callback": "^2.1.0" + } + }, "ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -4299,6 +4336,15 @@ } } }, + "jest-mock-extended": { + "version": "2.0.2-beta2", + "resolved": "https://registry.npmjs.org/jest-mock-extended/-/jest-mock-extended-2.0.2-beta2.tgz", + "integrity": "sha512-56zcpgRPs3YxQP0ejcaaNFxUinPyRxQCbuk7GGORZqEbAFuQVXWAAtru2tI1N4qcLBoDWEJ/hwUxwbEGY5hdyw==", + "dev": true, + "requires": { + "ts-essentials": "^7.0.3" + } + }, "jest-pnp-resolver": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.2.tgz", @@ -5246,6 +5292,12 @@ "word-wrap": "^1.2.3" } }, + "os-tmpdir": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", + "integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=", + "dev": true + }, "p-each-series": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-each-series/-/p-each-series-2.2.0.tgz", @@ -5636,6 +5688,12 @@ "util-deprecate": "^1.0.1" } }, + "readline-sync": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/readline-sync/-/readline-sync-1.4.10.tgz", + "integrity": "sha512-gNva8/6UAe8QYepIQH/jQ2qn91Qj0B9sYjMBBs3QOB8F2CXcKgLxQaJRP76sWVRQt+QU+8fAkCbCvjjMFu7Ycw==", + "dev": true + }, "redis-commands": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/redis-commands/-/redis-commands-1.7.0.tgz", @@ -6198,6 +6256,15 @@ "integrity": "sha512-8hmiGIJMDlwjg7dlJ4yKGLK8EsYqKgPWbG3b4wjJddKNwc7N7Dpn08Df4szr/sZdMVeOstrdYSsqzX6BYbcB+w==", "dev": true }, + "tmp": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", + "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", + "dev": true, + "requires": { + "os-tmpdir": "~1.0.2" + } + }, "tmpl": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.4.tgz", @@ -6258,6 +6325,12 @@ } } }, + "ts-essentials": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/ts-essentials/-/ts-essentials-7.0.3.tgz", + "integrity": "sha512-8+gr5+lqO3G84KdiTSMRLtuyJ+nTBVRKuCrK4lidMPdVeEp0uqC875uE5NMcaA7YYMN7XsNiFQuMvasF8HT/xQ==", + "dev": true + }, "ts-jest": { "version": "27.0.4", "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-27.0.4.tgz", diff --git a/asset-transfer-basic/rest-api-typescript/package.json b/asset-transfer-basic/rest-api-typescript/package.json index 2f1e86fc..18919584 100644 --- a/asset-transfer-basic/rest-api-typescript/package.json +++ b/asset-transfer-basic/rest-api-typescript/package.json @@ -32,7 +32,9 @@ "eslint": "^7.29.0", "eslint-config-prettier": "^8.3.0", "eslint-plugin-prettier": "^3.4.0", + "ioredis-mock": "^5.6.0", "jest": "^27.0.6", + "jest-mock-extended": "^2.0.2-beta2", "pino-pretty": "^5.0.2", "prettier": "^2.3.1", "rimraf": "^3.0.2", diff --git a/asset-transfer-basic/rest-api-typescript/src/__mocks__/fabric-network.ts b/asset-transfer-basic/rest-api-typescript/src/__mocks__/fabric-network.ts index 9ae7e06e..d13ff057 100644 --- a/asset-transfer-basic/rest-api-typescript/src/__mocks__/fabric-network.ts +++ b/asset-transfer-basic/rest-api-typescript/src/__mocks__/fabric-network.ts @@ -2,7 +2,47 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { mock } from 'jest-mock-extended'; +import { Contract, Network, Transaction, WalletStore } from 'fabric-network'; import { mocked } from 'ts-jest/utils'; +import * as fabricProtos from 'fabric-protos'; + +const mockAsset1 = { + ID: 'asset1', + Color: 'blue', + Size: 5, + Owner: 'Tomoko', + AppraisedValue: 300, +}; +const mockAsset1Buffer = Buffer.from(JSON.stringify(mockAsset1)); + +const mockAsset2 = { + ID: 'asset2', + Color: 'red', + Size: 5, + Owner: 'Brad', + AppraisedValue: 400, +}; + +const mockAllAssetsBuffer = Buffer.from( + JSON.stringify([mockAsset1, mockAsset2]) +); + +const mockBlockchainInfoProto = fabricProtos.common.BlockchainInfo.create(); +mockBlockchainInfoProto.height = 42; +const mockBlockchainInfoBuffer = Buffer.from( + fabricProtos.common.BlockchainInfo.encode(mockBlockchainInfoProto).finish() +); + +const processedTransactionProto = + fabricProtos.protos.ProcessedTransaction.create(); +processedTransactionProto.validationCode = + fabricProtos.protos.TxValidationCode.VALID; +const processedTransactionBuffer = Buffer.from( + fabricProtos.protos.ProcessedTransaction.encode( + processedTransactionProto + ).finish() +); type FabricNetworkModule = jest.Mocked; @@ -14,24 +54,124 @@ const { Wallets, }: FabricNetworkModule = jest.createMockFromModule('fabric-network'); +const mockWalletStore = mock(); mocked(Wallets.newInMemoryWallet).mockResolvedValue( - new Wallet({ - get: jest.fn(), - list: jest.fn(), - put: jest.fn(), - remove: jest.fn(), - }) + new Wallet(mockWalletStore) ); -mocked(Gateway.prototype.getNetwork).mockResolvedValue({ - getGateway: jest.fn(), - getContract: jest.fn(), - getChannel: jest.fn(), - addCommitListener: jest.fn(), - removeCommitListener: jest.fn(), - addBlockListener: jest.fn(), - removeBlockListener: jest.fn(), -}); +const mockAssetExistsTransaction = mock(); +mockAssetExistsTransaction.evaluate + .calledWith('asset1') + .mockResolvedValue(Buffer.from('true')); +mockAssetExistsTransaction.evaluate + .calledWith('asset3') + .mockResolvedValue(Buffer.from('false')); + +const mockReadAssetTransaction = mock(); +mockReadAssetTransaction.evaluate + .calledWith('asset1') + .mockResolvedValue(mockAsset1Buffer); +mockReadAssetTransaction.evaluate + .calledWith('asset3') + .mockRejectedValue(new Error('the asset asset3 does not exist')); + +const mockCreateAssetTransaction = mock(); +mockCreateAssetTransaction.getTransactionId.mockReturnValue('txn1'); +mockCreateAssetTransaction.submit + .calledWith('asset1') + .mockRejectedValue( + new Error( + 'No valid responses from any peers. Errors:\n peer=peer0.org1.example.com:7051, status=500, message=the asset asset1 already exists\n peer=peer0.org2.example.com:9051, status=500, message=the asset asset3 already exists' + ) + ); + +// NOTE: only the second mocked GetAllAssets with return no assets +// TODO find a better alternative so that test order does not matter +const mockGetAllAssetsTransaction = mock(); +mockGetAllAssetsTransaction.evaluate + .mockResolvedValueOnce(Buffer.from('')) + .mockResolvedValueOnce(mockAllAssetsBuffer); + +const mockUpdateAssetTransaction = mock(); +mockUpdateAssetTransaction.getTransactionId.mockReturnValue('txn1'); +mockUpdateAssetTransaction.submit + .calledWith('asset3') + .mockRejectedValue( + new Error( + 'No valid responses from any peers. Errors:\n peer=peer0.org1.example.com:7051, status=500, message=the asset asset3 does not exist\n peer=peer0.org2.example.com:9051, status=500, message=the asset asset3 does not exist' + ) + ); + +const mockTransferAssetTransaction = mock(); +mockTransferAssetTransaction.getTransactionId.mockReturnValue('txn1'); +mockTransferAssetTransaction.submit + .calledWith('asset3') + .mockRejectedValue( + new Error( + 'No valid responses from any peers. Errors:\n peer=peer0.org1.example.com:7051, status=500, message=the asset asset3 does not exist\n peer=peer0.org2.example.com:9051, status=500, message=the asset asset3 does not exist' + ) + ); + +const mockDeleteAssetTransaction = mock(); +mockDeleteAssetTransaction.getTransactionId.mockReturnValue('txn1'); +mockDeleteAssetTransaction.submit + .calledWith('asset3') + .mockRejectedValue( + new Error( + 'No valid responses from any peers. Errors:\n peer=peer0.org1.example.com:7051, status=500, message=the asset asset3 does not exist\n peer=peer0.org2.example.com:9051, status=500, message=the asset asset3 does not exist' + ) + ); + +const mockBasicContract = mock(); +mockBasicContract.createTransaction + .calledWith('AssetExists') + .mockReturnValue(mockAssetExistsTransaction); +mockBasicContract.createTransaction + .calledWith('ReadAsset') + .mockReturnValue(mockReadAssetTransaction); +mockBasicContract.createTransaction + .calledWith('CreateAsset') + .mockReturnValue(mockCreateAssetTransaction); +mockBasicContract.createTransaction + .calledWith('GetAllAssets') + .mockReturnValue(mockGetAllAssetsTransaction); +mockBasicContract.createTransaction + .calledWith('UpdateAsset') + .mockReturnValue(mockUpdateAssetTransaction); +mockBasicContract.createTransaction + .calledWith('TransferAsset') + .mockReturnValue(mockTransferAssetTransaction); +mockBasicContract.createTransaction + .calledWith('DeleteAsset') + .mockReturnValue(mockDeleteAssetTransaction); + +const mockGetTransactionByIDTransaction = mock(); +mockGetTransactionByIDTransaction.evaluate + .calledWith('mychannel', 'txn1') + .mockResolvedValue(processedTransactionBuffer); +mockGetTransactionByIDTransaction.evaluate + .calledWith('mychannel', 'txn3') + .mockRejectedValue( + new Error( + 'Failed to get transaction with id txn3, error Entry not found in index' + ) + ); + +const mockSystemContract = mock(); +mockSystemContract.evaluateTransaction + .calledWith('GetChainInfo') + .mockResolvedValue(mockBlockchainInfoBuffer); +mockSystemContract.createTransaction + .calledWith('GetTransactionByID') + .mockReturnValue(mockGetTransactionByIDTransaction); + +const mockNetwork = mock(); +mockNetwork.getContract.calledWith('basic').mockReturnValue(mockBasicContract); +mockNetwork.getContract.calledWith('qscc').mockReturnValue(mockSystemContract); + +mocked(Gateway.prototype.getNetwork).mockResolvedValue(mockNetwork); + +// TODO remove this and use simpler mocks in fabric spec tests const getMockedNetwork = (getContract = jest.fn()) => { return mocked(Gateway.prototype.getNetwork).mockResolvedValue({ getGateway: jest.fn(), @@ -47,6 +187,7 @@ const getMockedNetwork = (getContract = jest.fn()) => { export { DefaultEventHandlerStrategies, DefaultQueryHandlerStrategies, + Contract, Gateway, Wallets, getMockedNetwork, diff --git a/asset-transfer-basic/rest-api-typescript/src/__tests__/api.test.ts b/asset-transfer-basic/rest-api-typescript/src/__tests__/api.test.ts index 0da71ca3..9b686f63 100644 --- a/asset-transfer-basic/rest-api-typescript/src/__tests__/api.test.ts +++ b/asset-transfer-basic/rest-api-typescript/src/__tests__/api.test.ts @@ -2,13 +2,15 @@ * SPDX-License-Identifier: Apache-2.0 */ +jest.mock('fabric-network'); +jest.mock('ioredis', () => require('ioredis-mock/jest')); + import { createServer } from '../server'; import { Application } from 'express'; import request from 'supertest'; -jest.mock('fabric-network'); -jest.mock('ioredis'); - +// TODO add tests for server errors +// TODO implement 405 Method Not Allowed where appropriate and add tests describe('Asset Transfer Besic REST API', () => { let app: Application; @@ -16,15 +18,593 @@ describe('Asset Transfer Besic REST API', () => { app = await createServer(); }); - describe('GET /ready', () => { - it('should respond with success json', async () => { + describe('/ready', () => { + it('GET should respond with 200 OK json', async () => { const response = await request(app).get('/ready'); expect(response.statusCode).toEqual(200); expect(response.header).toHaveProperty( 'content-type', 'application/json; charset=utf-8' ); - expect(response.body.status).toEqual('OK'); + expect(response.body).toEqual({ + status: 'OK', + timestamp: expect.any(String), + }); + }); + }); + + describe('/live', () => { + it('GET should respond with 200 OK json', async () => { + const response = await request(app).get('/live'); + expect(response.statusCode).toEqual(200); + expect(response.header).toHaveProperty( + 'content-type', + 'application/json; charset=utf-8' + ); + expect(response.body).toEqual({ + status: 'OK', + timestamp: expect.any(String), + }); + }); + }); + + describe('/api/assets', () => { + it('GET should respond with 401 unauthorized json when an invalid API key is specified', async () => { + const response = await request(app) + .get('/api/assets') + .set('X-Api-Key', 'NOTTHERIGHTAPIKEY'); + expect(response.statusCode).toEqual(401); + expect(response.header).toHaveProperty( + 'content-type', + 'application/json; charset=utf-8' + ); + expect(response.body).toEqual({ + reason: 'NO_VALID_APIKEY', + status: 'Unauthorized', + timestamp: expect.any(String), + }); + }); + + it('GET should respond with an empty json array when there are no assets', async () => { + // NOTE: only the first mocked GetAllAssets with return no assets + // TODO find a better alternative so that test order does not matter + const response = await request(app) + .get('/api/assets') + .set('X-Api-Key', 'ORG1MOCKAPIKEY'); + expect(response.statusCode).toEqual(200); + expect(response.header).toHaveProperty( + 'content-type', + 'application/json; charset=utf-8' + ); + expect(response.body).toEqual([]); + }); + + it('GET should respond with json array of assets', async () => { + // NOTE: only the second mocked GetAllAssets with return no assets + // TODO find a better alternative so that test order does not matter + const response = await request(app) + .get('/api/assets') + .set('X-Api-Key', 'ORG1MOCKAPIKEY'); + expect(response.statusCode).toEqual(200); + expect(response.header).toHaveProperty( + 'content-type', + 'application/json; charset=utf-8' + ); + expect(response.body).toEqual([ + { + ID: 'asset1', + Color: 'blue', + Size: 5, + Owner: 'Tomoko', + AppraisedValue: 300, + }, + { + ID: 'asset2', + Color: 'red', + Size: 5, + Owner: 'Brad', + AppraisedValue: 400, + }, + ]); + }); + + it('POST should respond with 401 unauthorized json when an invalid API key is specified', async () => { + const response = await request(app) + .post('/api/assets') + .send({ + ID: 'asset6', + Color: 'white', + Size: 15, + Owner: 'Michel', + AppraisedValue: 800, + }) + .set('X-Api-Key', 'NOTTHERIGHTAPIKEY'); + expect(response.statusCode).toEqual(401); + expect(response.header).toHaveProperty( + 'content-type', + 'application/json; charset=utf-8' + ); + expect(response.body).toEqual({ + reason: 'NO_VALID_APIKEY', + status: 'Unauthorized', + timestamp: expect.any(String), + }); + }); + + it('POST should respond with 400 bad request json for invalid asset json', async () => { + const response = await request(app) + .post('/api/assets') + .send({ + identifier: 'asset3', + color: 'red', + size: 5, + owner: 'Brad', + appraisedValue: 400, + }) + .set('X-Api-Key', 'ORG1MOCKAPIKEY'); + expect(response.statusCode).toEqual(400); + expect(response.header).toHaveProperty( + 'content-type', + 'application/json; charset=utf-8' + ); + expect(response.body).toEqual({ + status: 'Bad Request', + reason: 'VALIDATION_ERROR', + errors: [ + { + location: 'body', + msg: 'must be a string', + param: 'id', + }, + ], + message: 'Invalid request body', + timestamp: expect.any(String), + }); + }); + + it('POST should respond with 202 accepted json', async () => { + const response = await request(app) + .post('/api/assets') + .send({ + id: 'asset3', + color: 'red', + size: 5, + owner: 'Brad', + appraisedValue: 400, + }) + .set('X-Api-Key', 'ORG1MOCKAPIKEY'); + expect(response.statusCode).toEqual(202); + expect(response.header).toHaveProperty( + 'content-type', + 'application/json; charset=utf-8' + ); + expect(response.body).toEqual({ + status: 'Accepted', + transactionId: 'txn1', + timestamp: expect.any(String), + }); + }); + + it('POST should respond with 409 conflict json when asset already exists', async () => { + const response = await request(app) + .post('/api/assets') + .send({ + id: 'asset1', + color: 'blue', + size: 5, + owner: 'Tomoko', + appraisedValue: 300, + }) + .set('X-Api-Key', 'ORG1MOCKAPIKEY'); + expect(response.statusCode).toEqual(409); + expect(response.header).toHaveProperty( + 'content-type', + 'application/json; charset=utf-8' + ); + expect(response.body).toEqual({ + status: 'Conflict', + reason: 'ASSET_EXISTS', + message: 'the asset asset1 already exists', + timestamp: expect.any(String), + }); + }); + }); + + describe('/api/assets/:id', () => { + it('OPTIONS should respond with 401 unauthorized json when an invalid API key is specified', async () => { + const response = await request(app) + .options('/api/assets/asset1') + .set('X-Api-Key', 'NOTTHERIGHTAPIKEY'); + expect(response.statusCode).toEqual(401); + expect(response.header).toHaveProperty( + 'content-type', + 'application/json; charset=utf-8' + ); + expect(response.body).toEqual({ + reason: 'NO_VALID_APIKEY', + status: 'Unauthorized', + timestamp: expect.any(String), + }); + }); + + it('OPTIONS should respond with 404 not found json without the allow header when there is no asset with the specified ID', async () => { + const response = await request(app) + .options('/api/assets/asset3') + .set('X-Api-Key', 'ORG1MOCKAPIKEY'); + expect(response.statusCode).toEqual(404); + expect(response.header).toHaveProperty( + 'content-type', + 'application/json; charset=utf-8' + ); + expect(response.header).not.toHaveProperty('allow'); + expect(response.body).toEqual({ + status: 'Not Found', + timestamp: expect.any(String), + }); + }); + + it('OPTIONS should respond with 200 OK json with the allow header', async () => { + const response = await request(app) + .options('/api/assets/asset1') + .set('X-Api-Key', 'ORG1MOCKAPIKEY'); + expect(response.statusCode).toEqual(200); + expect(response.header).toHaveProperty( + 'content-type', + 'application/json; charset=utf-8' + ); + expect(response.header).toHaveProperty( + 'allow', + 'DELETE,GET,OPTIONS,PATCH,PUT' + ); + expect(response.body).toEqual({ + status: 'OK', + timestamp: expect.any(String), + }); + }); + + it('GET should respond with 401 unauthorized json when an invalid API key is specified', async () => { + const response = await request(app) + .get('/api/assets/asset1') + .set('X-Api-Key', 'NOTTHERIGHTAPIKEY'); + expect(response.statusCode).toEqual(401); + expect(response.header).toHaveProperty( + 'content-type', + 'application/json; charset=utf-8' + ); + expect(response.body).toEqual({ + reason: 'NO_VALID_APIKEY', + status: 'Unauthorized', + timestamp: expect.any(String), + }); + }); + + it('GET should respond with 404 not found json when there is no asset with the specified ID', async () => { + const response = await request(app) + .get('/api/assets/asset3') + .set('X-Api-Key', 'ORG1MOCKAPIKEY'); + expect(response.statusCode).toEqual(404); + expect(response.header).toHaveProperty( + 'content-type', + 'application/json; charset=utf-8' + ); + expect(response.body).toEqual({ + status: 'Not Found', + timestamp: expect.any(String), + }); + }); + + it('GET should respond with the asset json when the asset exists', async () => { + const response = await request(app) + .get('/api/assets/asset1') + .set('X-Api-Key', 'ORG1MOCKAPIKEY'); + expect(response.statusCode).toEqual(200); + expect(response.header).toHaveProperty( + 'content-type', + 'application/json; charset=utf-8' + ); + expect(response.body).toEqual({ + ID: 'asset1', + Color: 'blue', + Size: 5, + Owner: 'Tomoko', + AppraisedValue: 300, + }); + }); + + it('PUT should respond with 401 unauthorized json when an invalid API key is specified', async () => { + const response = await request(app) + .put('/api/assets/asset1') + .send({ + id: 'asset3', + color: 'red', + size: 5, + owner: 'Brad', + appraisedValue: 400, + }) + .set('X-Api-Key', 'NOTTHERIGHTAPIKEY'); + expect(response.statusCode).toEqual(401); + expect(response.header).toHaveProperty( + 'content-type', + 'application/json; charset=utf-8' + ); + expect(response.body).toEqual({ + reason: 'NO_VALID_APIKEY', + status: 'Unauthorized', + timestamp: expect.any(String), + }); + }); + + it('PUT should respond with 404 not found json when there is no asset with the specified ID', async () => { + const response = await request(app) + .put('/api/assets/asset3') + .send({ + id: 'asset3', + color: 'red', + size: 5, + owner: 'Brad', + appraisedValue: 400, + }) + .set('X-Api-Key', 'ORG1MOCKAPIKEY'); + expect(response.statusCode).toEqual(404); + expect(response.header).toHaveProperty( + 'content-type', + 'application/json; charset=utf-8' + ); + expect(response.body).toEqual({ + status: 'Not Found', + timestamp: expect.any(String), + }); + }); + + it('PUT should respond with 400 bad request json when IDs do not match', async () => { + const response = await request(app) + .put('/api/assets/asset1') + .send({ + id: 'asset2', + color: 'red', + size: 5, + owner: 'Brad', + appraisedValue: 400, + }) + .set('X-Api-Key', 'ORG1MOCKAPIKEY'); + expect(response.statusCode).toEqual(400); + expect(response.header).toHaveProperty( + 'content-type', + 'application/json; charset=utf-8' + ); + expect(response.body).toEqual({ + status: 'Bad Request', + reason: 'ASSET_ID_MISMATCH', + message: 'Asset IDs must match', + timestamp: expect.any(String), + }); + }); + + it('PUT should respond with 400 bad request json for invalid asset json', async () => { + const response = await request(app) + .put('/api/assets/asset1') + .send({ + identifier: 'asset1', + color: 'red', + size: 5, + owner: 'Brad', + appraisedValue: 400, + }) + .set('X-Api-Key', 'ORG1MOCKAPIKEY'); + expect(response.statusCode).toEqual(400); + expect(response.header).toHaveProperty( + 'content-type', + 'application/json; charset=utf-8' + ); + expect(response.body).toEqual({ + status: 'Bad Request', + reason: 'VALIDATION_ERROR', + errors: [ + { + location: 'body', + msg: 'must be a string', + param: 'id', + }, + ], + message: 'Invalid request body', + timestamp: expect.any(String), + }); + }); + + it('PUT should respond with 202 accepted json', async () => { + const response = await request(app) + .put('/api/assets/asset1') + .send({ + id: 'asset1', + color: 'red', + size: 5, + owner: 'Brad', + appraisedValue: 400, + }) + .set('X-Api-Key', 'ORG1MOCKAPIKEY'); + expect(response.statusCode).toEqual(202); + expect(response.header).toHaveProperty( + 'content-type', + 'application/json; charset=utf-8' + ); + expect(response.body).toEqual({ + status: 'Accepted', + transactionId: 'txn1', + timestamp: expect.any(String), + }); + }); + + it('PATCH should respond with 401 unauthorized json when an invalid API key is specified', async () => { + const response = await request(app) + .patch('/api/assets/asset1') + .send([{ op: 'replace', path: '/owner', value: 'Ashleigh' }]) + .set('X-Api-Key', 'NOTTHERIGHTAPIKEY'); + expect(response.statusCode).toEqual(401); + expect(response.header).toHaveProperty( + 'content-type', + 'application/json; charset=utf-8' + ); + expect(response.body).toEqual({ + reason: 'NO_VALID_APIKEY', + status: 'Unauthorized', + timestamp: expect.any(String), + }); + }); + + it('PATCH should respond with 404 not found json when there is no asset with the specified ID', async () => { + const response = await request(app) + .patch('/api/assets/asset3') + .send([{ op: 'replace', path: '/owner', value: 'Ashleigh' }]) + .set('X-Api-Key', 'ORG1MOCKAPIKEY'); + expect(response.statusCode).toEqual(404); + expect(response.header).toHaveProperty( + 'content-type', + 'application/json; charset=utf-8' + ); + expect(response.body).toEqual({ + status: 'Not Found', + timestamp: expect.any(String), + }); + }); + + it('PATCH should respond with 400 bad request json for invalid patch op/path', async () => { + const response = await request(app) + .patch('/api/assets/asset1') + .send([{ op: 'replace', path: '/color', value: 'orange' }]) + .set('X-Api-Key', 'ORG1MOCKAPIKEY'); + expect(response.statusCode).toEqual(400); + expect(response.header).toHaveProperty( + 'content-type', + 'application/json; charset=utf-8' + ); + expect(response.body).toEqual({ + status: 'Bad Request', + reason: 'VALIDATION_ERROR', + errors: [ + { + location: 'body', + msg: "path must be '/owner'", + param: '[0].path', + value: '/color', + }, + ], + message: 'Invalid request body', + timestamp: expect.any(String), + }); + }); + + it('PATCH should respond with 202 accepted json', async () => { + const response = await request(app) + .patch('/api/assets/asset1') + .send([{ op: 'replace', path: '/owner', value: 'Ashleigh' }]) + .set('X-Api-Key', 'ORG1MOCKAPIKEY'); + expect(response.statusCode).toEqual(202); + expect(response.header).toHaveProperty( + 'content-type', + 'application/json; charset=utf-8' + ); + expect(response.body).toEqual({ + status: 'Accepted', + transactionId: 'txn1', + timestamp: expect.any(String), + }); + }); + + it('DELETE should respond with 401 unauthorized json when an invalid API key is specified', async () => { + const response = await request(app) + .delete('/api/assets/asset1') + .set('X-Api-Key', 'NOTTHERIGHTAPIKEY'); + expect(response.statusCode).toEqual(401); + expect(response.header).toHaveProperty( + 'content-type', + 'application/json; charset=utf-8' + ); + expect(response.body).toEqual({ + reason: 'NO_VALID_APIKEY', + status: 'Unauthorized', + timestamp: expect.any(String), + }); + }); + + it('DELETE should respond with 404 not found json when there is no asset with the specified ID', async () => { + const response = await request(app) + .delete('/api/assets/asset3') + .set('X-Api-Key', 'ORG1MOCKAPIKEY'); + expect(response.statusCode).toEqual(404); + expect(response.header).toHaveProperty( + 'content-type', + 'application/json; charset=utf-8' + ); + expect(response.body).toEqual({ + status: 'Not Found', + timestamp: expect.any(String), + }); + }); + + it('DELETE should respond with 202 accepted json', async () => { + const response = await request(app) + .delete('/api/assets/asset1') + .set('X-Api-Key', 'ORG1MOCKAPIKEY'); + expect(response.statusCode).toEqual(202); + expect(response.header).toHaveProperty( + 'content-type', + 'application/json; charset=utf-8' + ); + expect(response.body).toEqual({ + status: 'Accepted', + transactionId: 'txn1', + timestamp: expect.any(String), + }); + }); + }); + + describe('/api/transactions/:id', () => { + it('GET should respond with 401 unauthorized json when an invalid API key is specified', async () => { + const response = await request(app) + .get('/api/transactions/txn1') + .set('X-Api-Key', 'NOTTHERIGHTAPIKEY'); + expect(response.statusCode).toEqual(401); + expect(response.header).toHaveProperty( + 'content-type', + 'application/json; charset=utf-8' + ); + expect(response.body).toEqual({ + reason: 'NO_VALID_APIKEY', + status: 'Unauthorized', + timestamp: expect.any(String), + }); + }); + + it('GET should respond with 404 not found json when there is no transaction with the specified ID', async () => { + const response = await request(app) + .get('/api/transactions/txn3') + .set('X-Api-Key', 'ORG1MOCKAPIKEY'); + expect(response.statusCode).toEqual(404); + expect(response.header).toHaveProperty( + 'content-type', + 'application/json; charset=utf-8' + ); + expect(response.body).toEqual({ + status: 'Not Found', + timestamp: expect.any(String), + }); + }); + + it('GET should respond with json details for the specified transaction ID', async () => { + const response = await request(app) + .get('/api/transactions/txn1') + .set('X-Api-Key', 'ORG1MOCKAPIKEY'); + expect(response.statusCode).toEqual(200); + expect(response.header).toHaveProperty( + 'content-type', + 'application/json; charset=utf-8' + ); + expect(response.body).toEqual({ + status: 'OK', + progress: 'DONE', + validationCode: 'VALID', + timestamp: expect.any(String), + }); }); }); }); diff --git a/asset-transfer-basic/rest-api-typescript/src/assets.router.ts b/asset-transfer-basic/rest-api-typescript/src/assets.router.ts index b1d89a71..eb3adb58 100644 --- a/asset-transfer-basic/rest-api-typescript/src/assets.router.ts +++ b/asset-transfer-basic/rest-api-typescript/src/assets.router.ts @@ -40,7 +40,10 @@ assetsRouter.get('/', async (req: Request, res: Response) => { try { const contract: Contract = getContractForOrg(req).contract; const data = await evatuateTransaction(contract, 'GetAllAssets'); - const assets = JSON.parse(data.toString()); + let assets = []; + if (data.length > 0) { + assets = JSON.parse(data.toString()); + } return res.status(OK).json(assets); } catch (err) { diff --git a/asset-transfer-basic/rest-api-typescript/src/fabric.ts b/asset-transfer-basic/rest-api-typescript/src/fabric.ts index 8af4fefe..8249c55d 100644 --- a/asset-transfer-basic/rest-api-typescript/src/fabric.ts +++ b/asset-transfer-basic/rest-api-typescript/src/fabric.ts @@ -29,7 +29,7 @@ import { TransactionError, TransactionNotFoundError, } from './errors'; -import fabproto6 from 'fabric-protos'; +import protos from 'fabric-protos'; export const getNetwork = async (gateway: Gateway): Promise => { const network = await gateway.getNetwork(config.channelName); @@ -169,7 +169,9 @@ export const evatuateTransaction = async ( const txnId = txn.getTransactionId(); try { - return await txn.evaluate(...transactionArgs); + const payload = await txn.evaluate(...transactionArgs); + logger.debug({ payload }, 'Evaluate transaction response received'); + return payload; } catch (err) { throw handleError(txnId, err); } @@ -338,7 +340,7 @@ export const getChainInfo = async (qscc: Contract): Promise => { 'GetChainInfo', config.channelName ); - const info = fabproto6.common.BlockchainInfo.decode(data); + const info = protos.common.BlockchainInfo.decode(data); const blockHeight = info.height.toString(); logger.info('Current block height: %s', blockHeight); return true; From 45683b2a1a3e5ac1ddd742a17018ee1efb3c35e3 Mon Sep 17 00:00:00 2001 From: James Taylor Date: Mon, 16 Aug 2021 17:36:37 +0100 Subject: [PATCH 36/59] Simplify fabric code Attempt to make fabric code easier to document Signed-off-by: James Taylor --- .../src/__mocks__/fabric-network.ts | 28 +-- .../rest-api-typescript/src/assets.router.ts | 36 ++-- .../rest-api-typescript/src/auth.ts | 12 +- .../rest-api-typescript/src/config.ts | 10 +- .../rest-api-typescript/src/fabric.spec.ts | 186 ++++++++++++------ .../rest-api-typescript/src/fabric.ts | 141 +++++++------ .../rest-api-typescript/src/index.ts | 9 +- .../rest-api-typescript/src/server.ts | 79 ++++---- .../src/transactions.router.ts | 7 +- 9 files changed, 274 insertions(+), 234 deletions(-) diff --git a/asset-transfer-basic/rest-api-typescript/src/__mocks__/fabric-network.ts b/asset-transfer-basic/rest-api-typescript/src/__mocks__/fabric-network.ts index d13ff057..626f34e6 100644 --- a/asset-transfer-basic/rest-api-typescript/src/__mocks__/fabric-network.ts +++ b/asset-transfer-basic/rest-api-typescript/src/__mocks__/fabric-network.ts @@ -3,10 +3,14 @@ */ import { mock } from 'jest-mock-extended'; -import { Contract, Network, Transaction, WalletStore } from 'fabric-network'; +import { Contract, Network, Transaction } from 'fabric-network'; import { mocked } from 'ts-jest/utils'; import * as fabricProtos from 'fabric-protos'; +const actualFabricNetwork = jest.requireActual('fabric-network'); +const Wallet = actualFabricNetwork.Wallet; +const Wallets = actualFabricNetwork.Wallets; + const mockAsset1 = { ID: 'asset1', Color: 'blue', @@ -50,15 +54,8 @@ const { DefaultEventHandlerStrategies, DefaultQueryHandlerStrategies, Gateway, - Wallet, - Wallets, }: FabricNetworkModule = jest.createMockFromModule('fabric-network'); -const mockWalletStore = mock(); -mocked(Wallets.newInMemoryWallet).mockResolvedValue( - new Wallet(mockWalletStore) -); - const mockAssetExistsTransaction = mock(); mockAssetExistsTransaction.evaluate .calledWith('asset1') @@ -171,24 +168,11 @@ mockNetwork.getContract.calledWith('qscc').mockReturnValue(mockSystemContract); mocked(Gateway.prototype.getNetwork).mockResolvedValue(mockNetwork); -// TODO remove this and use simpler mocks in fabric spec tests -const getMockedNetwork = (getContract = jest.fn()) => { - return mocked(Gateway.prototype.getNetwork).mockResolvedValue({ - getGateway: jest.fn(), - getContract, - getChannel: jest.fn(), - addCommitListener: jest.fn(), - removeCommitListener: jest.fn(), - addBlockListener: jest.fn(), - removeBlockListener: jest.fn(), - }); -}; - export { DefaultEventHandlerStrategies, DefaultQueryHandlerStrategies, Contract, Gateway, + Wallet, Wallets, - getMockedNetwork, }; diff --git a/asset-transfer-basic/rest-api-typescript/src/assets.router.ts b/asset-transfer-basic/rest-api-typescript/src/assets.router.ts index eb3adb58..5d75c65a 100644 --- a/asset-transfer-basic/rest-api-typescript/src/assets.router.ts +++ b/asset-transfer-basic/rest-api-typescript/src/assets.router.ts @@ -16,11 +16,7 @@ import { Contract } from 'fabric-network'; import { getReasonPhrase, StatusCodes } from 'http-status-codes'; import { Redis } from 'ioredis'; import { AssetExistsError, AssetNotFoundError } from './errors'; -import { - evatuateTransaction, - submitTransaction, - getContractForOrg, -} from './fabric'; +import { evatuateTransaction, submitTransaction } from './fabric'; import { logger } from './logger'; const { @@ -38,7 +34,9 @@ assetsRouter.get('/', async (req: Request, res: Response) => { logger.debug('Get all assets request received'); try { - const contract: Contract = getContractForOrg(req).contract; + const mspId = req.user as string; + const contract = req.app.get(mspId).assetContract as Contract; + const data = await evatuateTransaction(contract, 'GetAllAssets'); let assets = []; if (data.length > 0) { @@ -77,8 +75,9 @@ assetsRouter.post( }); } - const contract: Contract = getContractForOrg(req).contract; - const redis: Redis = req.app.get('redis'); + const mspId = req.user as string; + const contract = req.app.get(mspId).assetContract as Contract; + const redis = req.app.get('redis') as Redis; const assetId = req.body.id; try { @@ -128,7 +127,8 @@ assetsRouter.options('/:assetId', async (req: Request, res: Response) => { logger.debug('Asset options request received for asset ID %s', assetId); try { - const contract: Contract = getContractForOrg(req).contract; + const mspId = req.user as string; + const contract = req.app.get(mspId).assetContract as Contract; const data = await evatuateTransaction(contract, 'AssetExists', assetId); const exists = data.toString() === 'true'; @@ -167,7 +167,8 @@ assetsRouter.get('/:assetId', async (req: Request, res: Response) => { logger.debug('Read asset request received for asset ID %s', assetId); try { - const contract: Contract = getContractForOrg(req).contract; + const mspId = req.user as string; + const contract = req.app.get(mspId).assetContract as Contract; const data = await evatuateTransaction(contract, 'ReadAsset', assetId); const asset = JSON.parse(data.toString()); @@ -225,8 +226,9 @@ assetsRouter.put( }); } - const contract: Contract = getContractForOrg(req).contract; - const redis: Redis = req.app.get('redis'); + const mspId = req.user as string; + const contract = req.app.get(mspId).assetContract as Contract; + const redis = req.app.get('redis') as Redis; const assetId = req.params.assetId; try { @@ -294,8 +296,9 @@ assetsRouter.patch( }); } - const contract: Contract = getContractForOrg(req).contract; - const redis: Redis = req.app.get('redis'); + const mspId = req.user as string; + const contract = req.app.get(mspId).assetContract as Contract; + const redis = req.app.get('redis') as Redis; const assetId = req.params.assetId; const newOwner = req.body[0].value; @@ -339,8 +342,9 @@ assetsRouter.patch( assetsRouter.delete('/:assetId', async (req: Request, res: Response) => { logger.debug(req.body, 'Delete asset request received'); - const contract: Contract = getContractForOrg(req).contract; - const redis: Redis = req.app.get('redis'); + const mspId = req.user as string; + const contract = req.app.get(mspId).assetContract as Contract; + const redis = req.app.get('redis') as Redis; const assetId = req.params.assetId; try { diff --git a/asset-transfer-basic/rest-api-typescript/src/auth.ts b/asset-transfer-basic/rest-api-typescript/src/auth.ts index fd40ed43..03ea8b14 100644 --- a/asset-transfer-basic/rest-api-typescript/src/auth.ts +++ b/asset-transfer-basic/rest-api-typescript/src/auth.ts @@ -17,16 +17,13 @@ export const fabricAPIKeyStrategy: HeaderAPIKeyStrategy = false, function (apikey, done) { logger.debug({ apikey }, 'Checking X-API-Key'); - const user: { org: string } = { - org: '', - }; if (apikey === config.org1ApiKey) { - user.org = config.identityNameOrg1; - logger.debug('Organisation set to Org1'); + const user = config.mspIdOrg1; + logger.debug('User set to %s', user); done(null, user); } else if (apikey === config.org2ApiKey) { - user.org = config.identityNameOrg2; - logger.info('Organisation set to Org2'); + const user = config.mspIdOrg2; + logger.debug('User set to %s', user); done(null, user); } else { logger.debug({ apikey }, 'No valid X-API-Key'); @@ -44,7 +41,6 @@ export const authenticateApiKey = ( 'headerapikey', { session: false }, (err, user, _info) => { - logger.debug({ user }, 'USERUSERUSER'); if (err) return next(err); if (!user) return res.status(UNAUTHORIZED).json({ diff --git a/asset-transfer-basic/rest-api-typescript/src/config.ts b/asset-transfer-basic/rest-api-typescript/src/config.ts index 3a00c813..afc15aac 100644 --- a/asset-transfer-basic/rest-api-typescript/src/config.ts +++ b/asset-transfer-basic/rest-api-typescript/src/config.ts @@ -50,12 +50,6 @@ export const asLocalhost = env .example('true') .asBoolStrict(); -// TODO delete this and use mspIdOrg1 -export const identityNameOrg1 = 'Org1'; - -// TODO delete this and use mspIdOrg2 -export const identityNameOrg2 = 'Org2'; - /* * The Org1 MSP ID */ @@ -128,7 +122,7 @@ export const connectionProfileOrg1 = env .example( '{"name":"test-network-org1","version":"1.0.0","client":{"organization":"Org1" ... }' ) - .asJsonObject(); + .asJsonObject() as Record; /* * Certificate for the Org1 identity @@ -157,7 +151,7 @@ export const connectionProfileOrg2 = env .example( '{"name":"test-network-org2","version":"1.0.0","client":{"organization":"Org2" ... }' ) - .asJsonObject(); + .asJsonObject() as Record; /* * Certificate for the Org2 identity diff --git a/asset-transfer-basic/rest-api-typescript/src/fabric.spec.ts b/asset-transfer-basic/rest-api-typescript/src/fabric.spec.ts index 3cc7b1d8..7c37bf00 100644 --- a/asset-transfer-basic/rest-api-typescript/src/fabric.spec.ts +++ b/asset-transfer-basic/rest-api-typescript/src/fabric.spec.ts @@ -1,13 +1,32 @@ -import { retryTransaction, getGateway } from './fabric'; -import { getMockedNetwork } from './__mocks__/fabric-network'; +/* + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + createGateway, + createWallet, + getContracts, + getNetwork, + retryTransaction, +} from './fabric'; import * as config from './config'; import IORedis from './__mocks__/IORedis'; import { Redis } from 'ioredis'; -import { Contract } from 'fabric-network'; +import { + Contract, + Gateway, + GatewayOptions, + Network, + Transaction, + Wallet, +} from 'fabric-network'; + +import { mock } from 'jest-mock-extended'; jest.mock('./config'); jest.mock('ioredis'); + const redisOptions = { port: config.redisPort, host: config.redisHost, @@ -17,20 +36,72 @@ const redisOptions = { const redis = new IORedis(redisOptions) as unknown as Redis; -describe('Testing retryTransaction', () => { - const transactionId = - '0ae62c01e4c4b112c3f3954a2f11243da76778e46df9ad2783bcbafc79652b95'; - const state = `{"name":"CreateAsset","nonce":"damqinq8nrI4n4qY8lFVsZw7RwG2ufrv","transactionId":${transactionId}`; - const args = '["test111","red",400,"Jean",101]'; - const timestamp = 1628078044362; - const savedTransaction = { - timestamp: timestamp.toString(), - state: state, - retries: '', - args: args, - }; +describe('Fabric', () => { + describe('createWallet', () => { + it('creates a wallet containing identities for both orgs', async () => { + const wallet = await createWallet(); - describe('Check retry increment ', () => { + expect(await wallet.list()).toStrictEqual(['Org1MSP', 'Org2MSP']); + }); + }); + + describe('createGateway', () => { + it('creates a Gateway and connects using the provided arguments', async () => { + const connectionProfile = config.connectionProfileOrg1; + const identity = config.mspIdOrg1; + const mockWallet = mock(); + + const gateway = await createGateway( + connectionProfile, + identity, + mockWallet + ); + + expect(gateway.connect).toBeCalledWith( + connectionProfile, + expect.objectContaining({ + wallet: mockWallet, + identity, + discovery: expect.any(Object), + eventHandlerOptions: expect.any(Object), + queryHandlerOptions: expect.any(Object), + }) + ); + }); + }); + + describe('getNetwork', () => { + it('gets a Network instance for the required channel from the Gateway', async () => { + const mockGateway = mock(); + + await getNetwork(mockGateway); + + expect(mockGateway.getNetwork).toHaveBeenCalledWith(config.channelName); + }); + }); + + describe('getContracts', () => { + it('gets the asset and qscc contracts from the network', async () => { + const mockBasicContract = mock(); + const mockSystemContract = mock(); + const mockNetwork = mock(); + mockNetwork.getContract + .calledWith(config.chaincodeName) + .mockReturnValue(mockBasicContract); + mockNetwork.getContract + .calledWith('qscc') + .mockReturnValue(mockSystemContract); + + const contracts = await getContracts(mockNetwork); + + expect(contracts).toStrictEqual({ + assetContract: mockBasicContract, + qsccContract: mockSystemContract, + }); + }); + }); + + describe('Testing retryTransaction', () => { const transactionId = '0ae62c01e4c4b112c3f3954a2f11243da76778e46df9ad2783bcbafc79652b95'; const state = `{"name":"CreateAsset","nonce":"damqinq8nrI4n4qY8lFVsZw7RwG2ufrv","transactionId":${transactionId}`; @@ -43,52 +114,53 @@ describe('Testing retryTransaction', () => { args: args, }; - it('Transaction failure, check redis increment func call', async () => { - jest.doMock('fabric-network'); - const transaction = { - submit: jest.fn().mockRejectedValue({}), + describe('Check retry increment ', () => { + const transactionId = + '0ae62c01e4c4b112c3f3954a2f11243da76778e46df9ad2783bcbafc79652b95'; + const state = `{"name":"CreateAsset","nonce":"damqinq8nrI4n4qY8lFVsZw7RwG2ufrv","transactionId":${transactionId}`; + const args = '["test111","red",400,"Jean",101]'; + const timestamp = 1628078044362; + const savedTransaction = { + timestamp: timestamp.toString(), + state: state, + retries: '', + args: args, }; - const mockedContact = { - deserializeTransaction: jest.fn().mockReturnValue(transaction), - }; - const rejectableGetContract = jest - .fn() - .mockImplementation(() => mockedContact); - const network = getMockedNetwork(rejectableGetContract)(''); - const contract: Contract = (await network).getContract(''); - savedTransaction.retries = '3'; - await retryTransaction(contract, redis, transactionId, savedTransaction); - expect(redis.hincrby).toHaveBeenCalledTimes(1); + it('Transaction failure, check redis increment func call', async () => { + const mockTransaction = mock(); + mockTransaction.submit.mockRejectedValue('MOCKERROR'); + const mockContract = mock(); + mockContract.deserializeTransaction.mockReturnValue(mockTransaction); + + savedTransaction.retries = '3'; + await retryTransaction( + mockContract, + redis, + transactionId, + savedTransaction + ); + expect(redis.hincrby).toHaveBeenCalledTimes(1); + }); }); - }); - describe('Transaction successful, check redis delete key func call ', () => { - it('call redis increment', async () => { - jest.doMock('fabric-network'); - const transaction = { - submit: jest.fn().mockResolvedValue({}), - }; - const mockedContact = { - deserializeTransaction: jest.fn().mockReturnValue(transaction), - }; - const resolvableGetContract = jest - .fn() - .mockImplementation(() => mockedContact); + describe('Transaction successful, check redis delete key func call ', () => { + it('call redis increment', async () => { + const mockTransaction = mock(); + mockTransaction.submit.mockResolvedValue(Buffer.from('{}')); + const mockContract = mock(); + mockContract.deserializeTransaction.mockReturnValue(mockTransaction); - const network = getMockedNetwork(resolvableGetContract)(''); - const contract: Contract = (await network).getContract(''); - savedTransaction.retries = '3'; - await retryTransaction(contract, redis, transactionId, savedTransaction); - expect(redis.del).toHaveBeenCalledTimes(1); + savedTransaction.retries = '3'; + await retryTransaction( + mockContract, + redis, + transactionId, + savedTransaction + ); + + expect(redis.del).toHaveBeenCalledTimes(1); + }); }); }); }); - -describe('Test getGateway', () => { - it('should throw error for invalid org name', async () => { - expect(async () => await getGateway('')).rejects.toThrow( - 'Invalid org name for gateway' - ); - }); -}); diff --git a/asset-transfer-basic/rest-api-typescript/src/fabric.ts b/asset-transfer-basic/rest-api-typescript/src/fabric.ts index 8249c55d..44014ce4 100644 --- a/asset-transfer-basic/rest-api-typescript/src/fabric.ts +++ b/asset-transfer-basic/rest-api-typescript/src/fabric.ts @@ -13,8 +13,8 @@ import { BlockListener, BlockEvent, TransactionEvent, + Wallet, } from 'fabric-network'; -import { Request } from 'express'; import { Redis } from 'ioredis'; import * as config from './config'; import { logger } from './logger'; @@ -31,63 +31,60 @@ import { } from './errors'; import protos from 'fabric-protos'; -export const getNetwork = async (gateway: Gateway): Promise => { - const network = await gateway.getNetwork(config.channelName); - return network; -}; - -interface FabricConfigType { - identityName: string; - mspId: string; - connectionProfile: { [key: string]: any }; - certificate: string; - privateKey: string; -} - -const ORG1_CONFIG = { - identityName: config.identityNameOrg1, - mspId: config.mspIdOrg1, - connectionProfile: config.connectionProfileOrg1, - certificate: config.certificateOrg1, - privateKey: config.privateKeyOrg1, -}; - -const ORG2_CONFIG = { - identityName: config.identityNameOrg2, - mspId: config.mspIdOrg2, - connectionProfile: config.connectionProfileOrg2, - certificate: config.certificateOrg2, - privateKey: config.privateKeyOrg2, -}; - -const FabricDataMapper: { [key: string]: FabricConfigType } = { - [config.identityNameOrg1]: ORG1_CONFIG, - [config.identityNameOrg2]: ORG2_CONFIG, -}; - -export const getGateway = async (org: string): Promise => { - const fabricConfig = FabricDataMapper[org]; - if (fabricConfig == undefined) { - throw new Error('Invalid org name for gateway'); - } - logger.debug('Configuring fabric gateway for %s', org); +/* + * Creates an in memory wallet to hold credentials for an Org1 and Org2 user + * + * In this sample there is a single user for each MSP ID to demonstrate how + * a client app might submit transactions for different users + * + * Alternatively a REST server could use its own identity for all transactions, + * or it could use credentials supplied in the REST requests + */ +export const createWallet = async (): Promise => { const wallet = await Wallets.newInMemoryWallet(); - const x509Identity = { + const org1Identity = { credentials: { - certificate: fabricConfig.certificate, - privateKey: fabricConfig.privateKey, + certificate: config.certificateOrg1, + privateKey: config.privateKeyOrg1, }, - mspId: fabricConfig.mspId, + mspId: config.mspIdOrg1, type: 'X.509', }; - await wallet.put(fabricConfig.identityName, x509Identity); + await wallet.put(config.mspIdOrg1, org1Identity); + + const org2Identity = { + credentials: { + certificate: config.certificateOrg2, + privateKey: config.privateKeyOrg2, + }, + mspId: config.mspIdOrg2, + type: 'X.509', + }; + + await wallet.put(config.mspIdOrg2, org2Identity); + + return wallet; +}; + +/* + * Create a Gateway connection + * + * Gateway instances can and should be reused rather than connecting to submit every transaction + */ +export const createGateway = async ( + connectionProfile: Record, + identity: string, + wallet: Wallet +): Promise => { + logger.debug({ connectionProfile, identity }, 'Configuring gateway'); + const gateway = new Gateway(); - const connectOptions: GatewayOptions = { + const options: GatewayOptions = { wallet, - identity: fabricConfig.identityName, + identity, discovery: { enabled: true, asLocalhost: config.asLocalhost }, eventHandlerOptions: { commitTimeout: config.commitTimeout, @@ -100,16 +97,22 @@ export const getGateway = async (org: string): Promise => { }, }; - await gateway.connect(fabricConfig.connectionProfile, connectOptions); + await gateway.connect(connectionProfile, options); + return gateway; }; +export const getNetwork = async (gateway: Gateway): Promise => { + const network = await gateway.getNetwork(config.channelName); + return network; +}; + export const getContracts = async ( network: Network -): Promise<{ contract: Contract; qscc: Contract }> => { - const contract = network.getContract(config.chaincodeName); - const qscc = network.getContract('qscc'); - return { contract, qscc }; +): Promise<{ assetContract: Contract; qsccContract: Contract }> => { + const assetContract = network.getContract(config.chaincodeName); + const qsccContract = network.getContract('qscc'); + return { assetContract, qsccContract }; }; export const startRetryLoop = (contract: Contract, redis: Redis): void => { @@ -334,25 +337,15 @@ export const blockEventHandler = (redis: Redis): BlockListener => { return blockListner; }; -export const getChainInfo = async (qscc: Contract): Promise => { - try { - const data = await qscc.evaluateTransaction( - 'GetChainInfo', - config.channelName - ); - const info = protos.common.BlockchainInfo.decode(data); - const blockHeight = info.height.toString(); - logger.info('Current block height: %s', blockHeight); - return true; - } catch (e) { - logger.error(e, 'Unable to get blockchain info'); - return false; - } -}; - -export const getContractForOrg = ( - req: Request -): { contract: Contract; qscc: Contract } => { - const user: { org: string } = req.user as { org: string }; - return req.app.get('fabric')[user.org as string].contracts; +export const getBlockHeight = async ( + qscc: Contract +): Promise => { + const data = await qscc.evaluateTransaction( + 'GetChainInfo', + config.channelName + ); + const info = protos.common.BlockchainInfo.decode(data); + const blockHeight = info.height; + logger.debug('Current block height: %d', blockHeight); + return blockHeight; }; diff --git a/asset-transfer-basic/rest-api-typescript/src/index.ts b/asset-transfer-basic/rest-api-typescript/src/index.ts index 94814eb6..722079c5 100644 --- a/asset-transfer-basic/rest-api-typescript/src/index.ts +++ b/asset-transfer-basic/rest-api-typescript/src/index.ts @@ -12,10 +12,11 @@ import { createServer } from './server'; async function main() { const app = await createServer(); - const contract: Contract = - app.get('fabric')[config.identityNameOrg1].contracts.contract; - const redis: Redis = app.get('redis'); - const network: Network = app.get('fabric')[config.identityNameOrg1].network; + // TODO block listener and retry logic currently only handles a single org!!! + // TODO should these be initialised here? + const contract = app.get(config.mspIdOrg1).assetContract as Contract; + const redis = app.get('redis') as Redis; + const network = app.get('networkOrg1') as Network; await network.addBlockListener(blockEventHandler(redis)); startRetryLoop(contract, redis); diff --git a/asset-transfer-basic/rest-api-typescript/src/server.ts b/asset-transfer-basic/rest-api-typescript/src/server.ts index 5caf5799..4aeed8dd 100644 --- a/asset-transfer-basic/rest-api-typescript/src/server.ts +++ b/asset-transfer-basic/rest-api-typescript/src/server.ts @@ -12,10 +12,10 @@ import { assetsRouter } from './assets.router'; import { transactionsRouter } from './transactions.router'; import { getContracts, - getGateway, getNetwork, - getChainInfo, - getContractForOrg, + getBlockHeight, + createGateway, + createWallet, } from './fabric'; import { redis } from './redis'; import { Contract } from 'fabric-network'; @@ -74,29 +74,29 @@ export const createServer = async (): Promise => { if (process.env.NODE_ENV === 'production') { app.use(helmet()); } - // - const gatewayOrg1 = await getGateway(config.identityNameOrg1); - const gatewayOrg2 = await getGateway(config.identityNameOrg2); + + const wallet = await createWallet(); + + const gatewayOrg1 = await createGateway( + config.connectionProfileOrg1, + config.mspIdOrg1, + wallet + ); const networkOrg1 = await getNetwork(gatewayOrg1); - const networkOrg2 = await getNetwork(gatewayOrg2); - const contractsOrg1 = await getContracts(networkOrg1); + app.set(config.mspIdOrg1, contractsOrg1); + + // TODO used for block listener, which needs fixing! + app.set('networkOrg1', networkOrg1); + + const gatewayOrg2 = await createGateway( + config.connectionProfileOrg2, + config.mspIdOrg2, + wallet + ); + const networkOrg2 = await getNetwork(gatewayOrg2); const contractsOrg2 = await getContracts(networkOrg2); - - const fabric = { - [config.identityNameOrg1]: { - gateway: gatewayOrg1, - contracts: contractsOrg1, - network: networkOrg1, - }, - [config.identityNameOrg2]: { - gateway: gatewayOrg2, - contracts: contractsOrg2, - network: networkOrg2, - }, - }; - - app.set('fabric', fabric); + app.set(config.mspIdOrg2, contractsOrg2); app.set('redis', redis); @@ -107,32 +107,27 @@ export const createServer = async (): Promise => { timestamp: new Date().toISOString(), }) ); - app.get('/live', async (_req, res) => { - _req.user = { org: config.identityNameOrg1 }; - const qsccOrg1: Contract = getContractForOrg(_req).qscc; - const Org1Liveness = await getChainInfo(qsccOrg1); - logger.debug('Org1 liveness %s', Org1Liveness); - _req.user = { org: config.identityNameOrg2 }; - const qsccOrg2: Contract = getContractForOrg(_req).qscc; - const Org2Liveness = await getChainInfo(qsccOrg2); - logger.debug('Org2 liveness %s', Org2Liveness); + app.get('/live', async (req, res) => { + logger.debug(req.body, 'Liveness request received'); + + const qsccOrg1 = req.app.get(config.mspIdOrg1).qsccContract as Contract; + const qsccOrg2 = req.app.get(config.mspIdOrg2).qsccContract as Contract; + + try { + await Promise.all([getBlockHeight(qsccOrg1), getBlockHeight(qsccOrg2)]); + } catch (err) { + logger.error(err, 'Error processing liveness request'); - if (Org1Liveness && Org2Liveness) { - res.status(OK).json({ - status: getReasonPhrase(OK), - timestamp: new Date().toISOString(), - }); - } else { res.status(SERVICE_UNAVAILABLE).json({ status: getReasonPhrase(SERVICE_UNAVAILABLE), timestamp: new Date().toISOString(), }); } - }); - // TODO delete me - app.get('/error', (_req, _res) => { - throw new Error('Example error'); + res.status(OK).json({ + status: getReasonPhrase(OK), + timestamp: new Date().toISOString(), + }); }); app.use('/api/assets', authenticateApiKey, assetsRouter); diff --git a/asset-transfer-basic/rest-api-typescript/src/transactions.router.ts b/asset-transfer-basic/rest-api-typescript/src/transactions.router.ts index c3dd49bf..27f6438b 100644 --- a/asset-transfer-basic/rest-api-typescript/src/transactions.router.ts +++ b/asset-transfer-basic/rest-api-typescript/src/transactions.router.ts @@ -7,7 +7,7 @@ import { Contract } from 'fabric-network'; import { protos } from 'fabric-protos'; import { getReasonPhrase, StatusCodes } from 'http-status-codes'; import { Redis } from 'ioredis'; -import { evatuateTransaction, getContractForOrg } from './fabric'; +import { evatuateTransaction } from './fabric'; import { logger } from './logger'; import * as config from './config'; import { TransactionNotFoundError } from './errors'; @@ -28,8 +28,9 @@ transactionsRouter.get( let progress: Progress = 'DONE'; let validationCode = ''; - const qscc: Contract = getContractForOrg(req).qscc; - const redis: Redis = req.app.get('redis'); + const mspId = req.user as string; + const qscc = req.app.get(mspId).qsccContract as Contract; + const redis = req.app.get('redis') as Redis; try { const savedTransaction = await (redis as Redis).hgetall( From 5d1adddd03df1bd11329987167ac13f4a6639519 Mon Sep 17 00:00:00 2001 From: James Taylor Date: Wed, 18 Aug 2021 09:42:06 +0100 Subject: [PATCH 37/59] Refactor health routes in to separate file Signed-off-by: James Taylor --- .../rest-api-typescript/src/health.router.ts | 48 +++++++++++++++++++ .../rest-api-typescript/src/server.ts | 43 ++--------------- 2 files changed, 51 insertions(+), 40 deletions(-) create mode 100644 asset-transfer-basic/rest-api-typescript/src/health.router.ts diff --git a/asset-transfer-basic/rest-api-typescript/src/health.router.ts b/asset-transfer-basic/rest-api-typescript/src/health.router.ts new file mode 100644 index 00000000..27b40c3d --- /dev/null +++ b/asset-transfer-basic/rest-api-typescript/src/health.router.ts @@ -0,0 +1,48 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + */ + +import express, { Request, Response } from 'express'; +import { Contract } from 'fabric-network'; +import { getReasonPhrase, StatusCodes } from 'http-status-codes'; +import { getBlockHeight } from './fabric'; +import { logger } from './logger'; +import * as config from './config'; + +const { SERVICE_UNAVAILABLE, OK } = StatusCodes; + +export const healthRouter = express.Router(); + +/* + * Example of possible health endpoints for use in a cloud environment + */ + +healthRouter.get('/ready', (_req, res: Response) => + res.status(OK).json({ + status: getReasonPhrase(OK), + timestamp: new Date().toISOString(), + }) +); + +healthRouter.get('/live', async (req: Request, res: Response) => { + logger.debug(req.body, 'Liveness request received'); + + const qsccOrg1 = req.app.get(config.mspIdOrg1).qsccContract as Contract; + const qsccOrg2 = req.app.get(config.mspIdOrg2).qsccContract as Contract; + + try { + await Promise.all([getBlockHeight(qsccOrg1), getBlockHeight(qsccOrg2)]); + } catch (err) { + logger.error(err, 'Error processing liveness request'); + + res.status(SERVICE_UNAVAILABLE).json({ + status: getReasonPhrase(SERVICE_UNAVAILABLE), + timestamp: new Date().toISOString(), + }); + } + + res.status(OK).json({ + status: getReasonPhrase(OK), + timestamp: new Date().toISOString(), + }); +}); diff --git a/asset-transfer-basic/rest-api-typescript/src/server.ts b/asset-transfer-basic/rest-api-typescript/src/server.ts index 4aeed8dd..14674a1c 100644 --- a/asset-transfer-basic/rest-api-typescript/src/server.ts +++ b/asset-transfer-basic/rest-api-typescript/src/server.ts @@ -9,24 +9,17 @@ import pinoMiddleware from 'pino-http'; import { logger } from './logger'; import { assetsRouter } from './assets.router'; +import { healthRouter } from './health.router'; import { transactionsRouter } from './transactions.router'; import { getContracts, getNetwork, - getBlockHeight, createGateway, createWallet, } from './fabric'; import { redis } from './redis'; -import { Contract } from 'fabric-network'; import * as config from './config'; -const { - BAD_REQUEST, - INTERNAL_SERVER_ERROR, - NOT_FOUND, - OK, - SERVICE_UNAVAILABLE, -} = StatusCodes; +const { BAD_REQUEST, INTERNAL_SERVER_ERROR, NOT_FOUND } = StatusCodes; import { authenticateApiKey, fabricAPIKeyStrategy } from './auth'; import passport from 'passport'; @@ -100,36 +93,7 @@ export const createServer = async (): Promise => { app.set('redis', redis); - // Health routes - app.get('/ready', (_req, res) => - res.status(OK).json({ - status: getReasonPhrase(OK), - timestamp: new Date().toISOString(), - }) - ); - app.get('/live', async (req, res) => { - logger.debug(req.body, 'Liveness request received'); - - const qsccOrg1 = req.app.get(config.mspIdOrg1).qsccContract as Contract; - const qsccOrg2 = req.app.get(config.mspIdOrg2).qsccContract as Contract; - - try { - await Promise.all([getBlockHeight(qsccOrg1), getBlockHeight(qsccOrg2)]); - } catch (err) { - logger.error(err, 'Error processing liveness request'); - - res.status(SERVICE_UNAVAILABLE).json({ - status: getReasonPhrase(SERVICE_UNAVAILABLE), - timestamp: new Date().toISOString(), - }); - } - - res.status(OK).json({ - status: getReasonPhrase(OK), - timestamp: new Date().toISOString(), - }); - }); - + app.use('/', healthRouter); app.use('/api/assets', authenticateApiKey, assetsRouter); app.use('/api/transactions', authenticateApiKey, transactionsRouter); @@ -142,7 +106,6 @@ export const createServer = async (): Promise => { ); // Print API errors - // TBC in addition to pinoMiddleware errors? app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => { logger.error(err); return res.status(INTERNAL_SERVER_ERROR).json({ From 82b1249f4e5cf4998037527300db8b67a874bcaf Mon Sep 17 00:00:00 2001 From: James Taylor Date: Wed, 18 Aug 2021 17:01:04 +0100 Subject: [PATCH 38/59] Improve Docker support - default command should be start, rather than start:dev in the docker image - added a multistage build - fixed node-gyp error - removed dev dependencies - added a start:dotenv script to support a .env file in production (may be useful for k8s later) - updated Readme and generateEnv script to simplify the setup - updated external network in docker-compose.yaml to match the test network Signed-off-by: James Taylor --- README.md | 41 ++++++++++--------- .../rest-api-typescript/.env.sample | 15 ++++--- .../rest-api-typescript/Dockerfile | 27 +++++++----- .../rest-api-typescript/docker-compose.yaml | 12 +++--- .../rest-api-typescript/package.json | 1 + .../scripts/generateEnv.sh | 40 ++++++++++++++---- 6 files changed, 85 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index b5f40edf..06f58ab8 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,28 @@ Start the sample REST server npm run start:dev ``` +### Docker image + +Alternatively, run the following commands in the `fabric-rest-sample/asset-transfer-basic/rest-api-typescript` directory to start the sample in a Docker container + +Build the Docker image + +```shell +docker build -t fabric-rest-sample . +``` + +Create a `.env` file to configure the server for the test network (make sure `TEST_NETWORK_HOME` is set to the fully qualified `test-network` directory and `AS_LOCAL_HOST` is set to `false` so that the server works inside the Docker Compose network) + +```shell +TEST_NETWORK_HOME=$HOME/fabric-samples/test-network AS_LOCAL_HOST=false npm run generateEnv +``` + +Start the sample REST server and Redis server + +```shell +docker-compose up -d +``` + ## REST API If everything went well, you can now make basic asset transfer REST calls! @@ -105,22 +127,3 @@ curl --include --header "Content-Type: application/json" --header "X-Api-Key: ${ ```shell curl --include --header "X-Api-Key: ${SAMPLE_APIKEY}" --request DELETE http://localhost:3000/api/assets/asset7 ``` -## Steps to run the application using docker: - -Move to directory fabric-rest-sample/asset-transfer-basic/rest-api-typescript - -### Build docker image - docker build -t fabricapp . - -### Generate .env file - TEST_NETWORK_HOME=$HOME/fabric-samples/test-network ./scripts/generateEnv.sh - - Note: Connection profile need to use the peer container’s hostname instead of localhost. - -### Run docker containers - docker-compose up -d - - - - - diff --git a/asset-transfer-basic/rest-api-typescript/.env.sample b/asset-transfer-basic/rest-api-typescript/.env.sample index f81d0dcf..dc052b68 100644 --- a/asset-transfer-basic/rest-api-typescript/.env.sample +++ b/asset-transfer-basic/rest-api-typescript/.env.sample @@ -4,6 +4,10 @@ PORT=3000 RETRY_DELAY=3000 +MAX_RETRY_COUNT=5 + +AS_LOCAL_HOST=true + HLF_CONNECTION_PROFILE_ORG1={"name":"test-network-org1","version":"1.0.0","client":{"organization":"Org1" ... } HLF_CERTIFICATE_ORG1="-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----\n" @@ -16,19 +20,20 @@ HLF_CERTIFICATE_ORG2="-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE---- HLF_PRIVATE_KEY_ORG2="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n" - HLF_COMMIT_TIMEOUT=3000 HLF_ENDORSE_TIMEOUT=30 +HLF_QUERY_TIMEOUT=3 + REDIS_HOST=localhost REDIS_PORT=6379 -ORG1_APIKEY=D2F66BFF-D68B-458D-8FA6-285F172D5B03 - -ORG2_APIKEY=92042C1F-8E58-48F9-9EAF-91A98A2B7648 - #REDIS_USERNAME= #REDIS_PASSWORD= + +ORG1_APIKEY=D2F66BFF-D68B-458D-8FA6-285F172D5B03 + +ORG2_APIKEY=92042C1F-8E58-48F9-9EAF-91A98A2B764 diff --git a/asset-transfer-basic/rest-api-typescript/Dockerfile b/asset-transfer-basic/rest-api-typescript/Dockerfile index 5ebc77bd..073c68f1 100644 --- a/asset-transfer-basic/rest-api-typescript/Dockerfile +++ b/asset-transfer-basic/rest-api-typescript/Dockerfile @@ -1,20 +1,25 @@ -FROM node:14-alpine3.12 -RUN apk add dumb-init -WORKDIR /fabric_app/ +FROM node:14-alpine3.12 AS build -COPY --chown=node:node . /fabric_app/ +RUN apk add --no-cache g++ make python3 dumb-init -RUN npm ci +WORKDIR /app +COPY --chown=node:node . /app + +RUN npm ci RUN npm run build +RUN npm prune --production + +FROM node:14-alpine3.12 +ENV NODE_ENV production +WORKDIR /app + +COPY --from=build /usr/bin/dumb-init /usr/bin/dumb-init +COPY --chown=node:node --from=build /app . EXPOSE 3000 USER node -CMD dumb-init npm run start:dev - - - - - +ENTRYPOINT [ "dumb-init", "--", "npm", "run"] +CMD ["start"] diff --git a/asset-transfer-basic/rest-api-typescript/docker-compose.yaml b/asset-transfer-basic/rest-api-typescript/docker-compose.yaml index 1a2c7b93..b7c4b0b3 100644 --- a/asset-transfer-basic/rest-api-typescript/docker-compose.yaml +++ b/asset-transfer-basic/rest-api-typescript/docker-compose.yaml @@ -6,21 +6,19 @@ services: ports: - 6379:6379 networks: - - net_test + - fabric_test nodeapp: - image: 'fabricapp' + image: 'fabric-rest-sample' + command: ['start:dotenv'] ports: - 3000:3000 env_file: - ./.env - environment: - - REDIS_HOST=redis - - AS_LOCAL_HOST=false networks: - - net_test + - fabric_test networks: - net_test: + fabric_test: external: true diff --git a/asset-transfer-basic/rest-api-typescript/package.json b/asset-transfer-basic/rest-api-typescript/package.json index 18919584..815f9e19 100644 --- a/asset-transfer-basic/rest-api-typescript/package.json +++ b/asset-transfer-basic/rest-api-typescript/package.json @@ -51,6 +51,7 @@ "generateEnv": "./scripts/generateEnv.sh", "lint": "eslint . --ext .ts", "start": "node --require source-map-support/register ./dist", + "start:dotenv": "node --require source-map-support/register --require dotenv/config ./dist", "start:dev": "node --require source-map-support/register --require dotenv/config ./dist | pino-pretty", "start:redis": "docker run -p 6379:6379 --name fabric-sample-redis -d redis", "test": "jest" diff --git a/asset-transfer-basic/rest-api-typescript/scripts/generateEnv.sh b/asset-transfer-basic/rest-api-typescript/scripts/generateEnv.sh index 902397f8..c910651e 100755 --- a/asset-transfer-basic/rest-api-typescript/scripts/generateEnv.sh +++ b/asset-transfer-basic/rest-api-typescript/scripts/generateEnv.sh @@ -4,6 +4,8 @@ # SPDX-License-Identifier: Apache-2.0 # +${AS_LOCAL_HOST:=true} + : "${TEST_NETWORK_HOME:=../..}" : "${CONNECTION_PROFILE_FILE_ORG1:=${TEST_NETWORK_HOME}/organizations/peerOrganizations/org1.example.com/connection-org1.json}" : "${CERTIFICATE_FILE_ORG1:=${TEST_NETWORK_HOME}/organizations/peerOrganizations/org1.example.com/users/Admin@org1.example.com/msp/signcerts/Admin@org1.example.com-cert.pem}" @@ -23,14 +25,10 @@ RETRY_DELAY=3000 MAX_RETRY_COUNT=5 -HLF_CONNECTION_PROFILE_ORG1=$(cat ${CONNECTION_PROFILE_FILE_ORG1} | jq -c .) - HLF_CERTIFICATE_ORG1="$(cat ${CERTIFICATE_FILE_ORG1} | sed -e 's/$/\\n/' | tr -d '\r\n')" HLF_PRIVATE_KEY_ORG1="$(cat ${PRIVATE_KEY_FILE_ORG1} | sed -e 's/$/\\n/' | tr -d '\r\n')" -HLF_CONNECTION_PROFILE_ORG2=$(cat ${CONNECTION_PROFILE_FILE_ORG2} | jq -c .) - HLF_CERTIFICATE_ORG2="$(cat ${CERTIFICATE_FILE_ORG2} | sed -e 's/$/\\n/' | tr -d '\r\n')" HLF_PRIVATE_KEY_ORG2="$(cat ${PRIVATE_KEY_FILE_ORG2} | sed -e 's/$/\\n/' | tr -d '\r\n')" @@ -39,7 +37,7 @@ HLF_COMMIT_TIMEOUT=3000 HLF_ENDORSE_TIMEOUT=30 -REDIS_HOST=localhost +HLF_QUERY_TIMEOUT=3 REDIS_PORT=6379 @@ -47,8 +45,32 @@ ORG1_APIKEY=$(uuidgen) ORG2_APIKEY=$(uuidgen) -#REDIS_USERNAME= - -#REDIS_PASSWORD= - ENV_END + +if [ "${AS_LOCAL_HOST}" = "true" ]; then + +cat << LOCAL_HOST_END >> .env +AS_LOCAL_HOST=true + +HLF_CONNECTION_PROFILE_ORG1=$(cat ${CONNECTION_PROFILE_FILE_ORG1} | jq -c .) + +HLF_CONNECTION_PROFILE_ORG2=$(cat ${CONNECTION_PROFILE_FILE_ORG2} | jq -c .) + +REDIS_HOST=localhost + +LOCAL_HOST_END + +elif [ "${AS_LOCAL_HOST}" = "false" ]; then + +cat << WITH_HOSTNAME_END >> .env +AS_LOCAL_HOST=false + +HLF_CONNECTION_PROFILE_ORG1=$(cat ${CONNECTION_PROFILE_FILE_ORG1} | jq -c '.peers["peer0.org1.example.com"].url = "grpcs://peer0.org1.example.com:7051" | .certificateAuthorities["ca.org1.example.com"].url = "https://ca.org1.example.com:7054"') + +HLF_CONNECTION_PROFILE_ORG2=$(cat ${CONNECTION_PROFILE_FILE_ORG2} | jq -c '.peers["peer0.org2.example.com"].url = "grpcs://peer0.org2.example.com:9051" | .certificateAuthorities["ca.org2.example.com"].url = "https://ca.org2.example.com:8054"') + +REDIS_HOST=redis + +WITH_HOSTNAME_END + +fi From bf91df7ef3dc4ee9cf01070fa097fac0df08702e Mon Sep 17 00:00:00 2001 From: James Taylor Date: Tue, 17 Aug 2021 17:34:45 +0100 Subject: [PATCH 39/59] Update transaction retry to use correct user Also improves test coverage Signed-off-by: James Taylor --- .../src/__mocks__/IORedis.ts | 21 - .../src/__mocks__/fabric-network.ts | 2 +- .../src/__tests__/api.test.ts | 2 +- .../rest-api-typescript/src/assets.router.ts | 4 + .../rest-api-typescript/src/fabric.spec.ts | 452 +++++++++++++++--- .../rest-api-typescript/src/fabric.ts | 137 +++--- .../rest-api-typescript/src/index.ts | 11 +- .../rest-api-typescript/src/logger.ts | 4 + .../rest-api-typescript/src/redis.spec.ts | 225 ++++++--- .../rest-api-typescript/src/redis.ts | 126 ++++- .../rest-api-typescript/src/server.ts | 7 + .../src/transactions.router.ts | 15 +- 12 files changed, 776 insertions(+), 230 deletions(-) delete mode 100644 asset-transfer-basic/rest-api-typescript/src/__mocks__/IORedis.ts diff --git a/asset-transfer-basic/rest-api-typescript/src/__mocks__/IORedis.ts b/asset-transfer-basic/rest-api-typescript/src/__mocks__/IORedis.ts deleted file mode 100644 index bc31167e..00000000 --- a/asset-transfer-basic/rest-api-typescript/src/__mocks__/IORedis.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { RedisOptions } from 'ioredis'; - -class IORedis { - redisOptions: RedisOptions; - constructor(options: RedisOptions) { - this.redisOptions = options; - } - - hincrby = jest.fn().mockReturnThis(); - multi = jest.fn().mockReturnThis(); - del = jest.fn().mockReturnThis(); - - zrem = jest.fn().mockReturnThis(); - - exec = jest.fn().mockReturnThis(); - - hset = jest.fn().mockReturnThis(); - zadd = jest.fn().mockReturnThis(); -} - -export default IORedis; diff --git a/asset-transfer-basic/rest-api-typescript/src/__mocks__/fabric-network.ts b/asset-transfer-basic/rest-api-typescript/src/__mocks__/fabric-network.ts index 626f34e6..4f58ae24 100644 --- a/asset-transfer-basic/rest-api-typescript/src/__mocks__/fabric-network.ts +++ b/asset-transfer-basic/rest-api-typescript/src/__mocks__/fabric-network.ts @@ -144,7 +144,7 @@ mockBasicContract.createTransaction const mockGetTransactionByIDTransaction = mock(); mockGetTransactionByIDTransaction.evaluate - .calledWith('mychannel', 'txn1') + .calledWith('mychannel', 'txn2') .mockResolvedValue(processedTransactionBuffer); mockGetTransactionByIDTransaction.evaluate .calledWith('mychannel', 'txn3') diff --git a/asset-transfer-basic/rest-api-typescript/src/__tests__/api.test.ts b/asset-transfer-basic/rest-api-typescript/src/__tests__/api.test.ts index 9b686f63..5b065426 100644 --- a/asset-transfer-basic/rest-api-typescript/src/__tests__/api.test.ts +++ b/asset-transfer-basic/rest-api-typescript/src/__tests__/api.test.ts @@ -592,7 +592,7 @@ describe('Asset Transfer Besic REST API', () => { it('GET should respond with json details for the specified transaction ID', async () => { const response = await request(app) - .get('/api/transactions/txn1') + .get('/api/transactions/txn2') .set('X-Api-Key', 'ORG1MOCKAPIKEY'); expect(response.statusCode).toEqual(200); expect(response.header).toHaveProperty( diff --git a/asset-transfer-basic/rest-api-typescript/src/assets.router.ts b/asset-transfer-basic/rest-api-typescript/src/assets.router.ts index 5d75c65a..6bac3866 100644 --- a/asset-transfer-basic/rest-api-typescript/src/assets.router.ts +++ b/asset-transfer-basic/rest-api-typescript/src/assets.router.ts @@ -84,6 +84,7 @@ assetsRouter.post( const transactionId = await submitTransaction( contract, redis, + mspId, 'CreateAsset', assetId, req.body.color, @@ -235,6 +236,7 @@ assetsRouter.put( const transactionId = await submitTransaction( contract, redis, + mspId, 'UpdateAsset', assetId, req.body.color, @@ -306,6 +308,7 @@ assetsRouter.patch( const transactionId = await submitTransaction( contract, redis, + mspId, 'TransferAsset', assetId, newOwner @@ -351,6 +354,7 @@ assetsRouter.delete('/:assetId', async (req: Request, res: Response) => { const transactionId = await submitTransaction( contract, redis, + mspId, 'DeleteAsset', assetId ); diff --git a/asset-transfer-basic/rest-api-typescript/src/fabric.spec.ts b/asset-transfer-basic/rest-api-typescript/src/fabric.spec.ts index 7c37bf00..b74ddf7a 100644 --- a/asset-transfer-basic/rest-api-typescript/src/fabric.spec.ts +++ b/asset-transfer-basic/rest-api-typescript/src/fabric.spec.ts @@ -7,12 +7,20 @@ import { createWallet, getContracts, getNetwork, - retryTransaction, + evatuateTransaction, + submitTransaction, + getBlockHeight, + startRetryLoop, } from './fabric'; import * as config from './config'; -import IORedis from './__mocks__/IORedis'; -import { Redis } from 'ioredis'; +import { + AssetExistsError, + AssetNotFoundError, + TransactionError, + TransactionNotFoundError, +} from './errors'; + import { Contract, Gateway, @@ -22,19 +30,14 @@ import { Wallet, } from 'fabric-network'; -import { mock } from 'jest-mock-extended'; +import * as fabricProtos from 'fabric-protos'; + +import { MockProxy, mock } from 'jest-mock-extended'; +import IORedis, { Redis } from 'ioredis'; +import Long from 'long'; jest.mock('./config'); -jest.mock('ioredis'); - -const redisOptions = { - port: config.redisPort, - host: config.redisHost, - username: config.redisUsername, - password: config.redisPassword, -}; - -const redis = new IORedis(redisOptions) as unknown as Redis; +jest.mock('ioredis', () => require('ioredis-mock/jest')); describe('Fabric', () => { describe('createWallet', () => { @@ -101,66 +104,393 @@ describe('Fabric', () => { }); }); - describe('Testing retryTransaction', () => { - const transactionId = + describe('startRetryLoop', () => { + let redis: Redis; + let mockTransaction: MockProxy; + let mockContract: MockProxy; + let mockContracts: Map; + + const mockTransactionId = '0ae62c01e4c4b112c3f3954a2f11243da76778e46df9ad2783bcbafc79652b95'; - const state = `{"name":"CreateAsset","nonce":"damqinq8nrI4n4qY8lFVsZw7RwG2ufrv","transactionId":${transactionId}`; - const args = '["test111","red",400,"Jean",101]'; - const timestamp = 1628078044362; - const savedTransaction = { - timestamp: timestamp.toString(), - state: state, - retries: '', - args: args, + const mockKey = `txn:${mockTransactionId}`; + const mockMspId = 'Org1MSP'; + const mockState = Buffer.from( + `{"name":"CreateAsset","nonce":"damqinq8nrI4n4qY8lFVsZw7RwG2ufrv","transactionId":${mockTransactionId}` + ); + const mockArgs = '["test111","red",400,"Jean",101]'; + const mockTimestamp = 1628078044362; + + const flushPromises = () => { + jest.useRealTimers(); + return new Promise((resolve) => setImmediate(resolve)); }; - describe('Check retry increment ', () => { - const transactionId = - '0ae62c01e4c4b112c3f3954a2f11243da76778e46df9ad2783bcbafc79652b95'; - const state = `{"name":"CreateAsset","nonce":"damqinq8nrI4n4qY8lFVsZw7RwG2ufrv","transactionId":${transactionId}`; - const args = '["test111","red",400,"Jean",101]'; - const timestamp = 1628078044362; - const savedTransaction = { - timestamp: timestamp.toString(), - state: state, - retries: '', - args: args, + const addMockTransationDetails = async (redis: Redis) => { + await redis + .multi() + .hset( + mockKey, + 'mspId', + mockMspId, + 'state', + mockState, + 'args', + mockArgs, + 'timestamp', + mockTimestamp, + 'retries', + '0' + ) + .zadd('index:txn:timestamp', mockTimestamp, mockTransactionId) + .exec(); + }; + + beforeEach(() => { + const redisOptions = { + port: config.redisPort, + host: config.redisHost, + username: config.redisUsername, + password: config.redisPassword, }; - it('Transaction failure, check redis increment func call', async () => { - const mockTransaction = mock(); - mockTransaction.submit.mockRejectedValue('MOCKERROR'); - const mockContract = mock(); - mockContract.deserializeTransaction.mockReturnValue(mockTransaction); + redis = new IORedis(redisOptions) as unknown as Redis; - savedTransaction.retries = '3'; - await retryTransaction( - mockContract, - redis, - transactionId, - savedTransaction - ); - expect(redis.hincrby).toHaveBeenCalledTimes(1); - }); + mockTransaction = mock(); + mockTransaction.submit + .mockResolvedValue(Buffer.from('MOCK PAYLOAD')) + .mockName('submit'); + mockContract = mock(); + mockContract.deserializeTransaction.mockReturnValue(mockTransaction); + mockContracts = new Map(); + mockContracts.set(mockMspId, mockContract); + + jest.useFakeTimers(); }); - describe('Transaction successful, check redis delete key func call ', () => { - it('call redis increment', async () => { - const mockTransaction = mock(); - mockTransaction.submit.mockResolvedValue(Buffer.from('{}')); - const mockContract = mock(); - mockContract.deserializeTransaction.mockReturnValue(mockTransaction); + afterEach(() => { + jest.useRealTimers(); + }); - savedTransaction.retries = '3'; - await retryTransaction( + it('starts a retry loop which does nothing if there are no saved transaction details', async () => { + const getContractSpy = jest.spyOn(mockContracts, 'get'); + + startRetryLoop(mockContracts, redis); + jest.runOnlyPendingTimers(); + await flushPromises(); + + expect(getContractSpy).not.toBeCalled(); + }); + + it('starts a retry loop which clears the saved details after succesfully retrying a transaction', async () => { + addMockTransationDetails(redis); + + startRetryLoop(mockContracts, redis); + jest.runOnlyPendingTimers(); + await flushPromises(); + + expect(mockContract.deserializeTransaction).toBeCalledWith(mockState); + expect(mockTransaction.submit).toBeCalledWith( + 'test111', + 'red', + 400, + 'Jean', + 101 + ); + + const index = await redis.zrange('index:txn:timestamp', 0, -1); + expect(index).toStrictEqual([]); + }); + + it('starts a retry loop which increments the retry count when a transaction fails', async () => { + addMockTransationDetails(redis); + mockTransaction.submit.mockRejectedValue(new Error('MOCK ERROR')); + + startRetryLoop(mockContracts, redis); + jest.runOnlyPendingTimers(); + await flushPromises(); + + expect(mockContract.deserializeTransaction).toBeCalledWith(mockState); + expect(mockTransaction.submit).toBeCalledWith( + 'test111', + 'red', + 400, + 'Jean', + 101 + ); + + const index = await redis.zrange('index:txn:timestamp', 0, -1); + expect(index).toStrictEqual([ + '0ae62c01e4c4b112c3f3954a2f11243da76778e46df9ad2783bcbafc79652b95', + ]); + + const savedTransaction = await (redis as Redis).hgetall(mockKey); + expect(savedTransaction.retries).toBe('1'); + }); + + it('starts a retry loop which clears the saved details when a transaction fails as a duplicate', async () => { + addMockTransationDetails(redis); + const mockDuplicateTransactionError = new Error('MOCK ERROR'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (mockDuplicateTransactionError as any).errors = [ + { + endorsements: [ + { + details: 'duplicate transaction found', + }, + ], + }, + ]; + mockTransaction.submit.mockRejectedValue(mockDuplicateTransactionError); + + startRetryLoop(mockContracts, redis); + jest.runOnlyPendingTimers(); + await flushPromises(); + + expect(mockContract.deserializeTransaction).toBeCalledWith(mockState); + expect(mockTransaction.submit).toBeCalledWith( + 'test111', + 'red', + 400, + 'Jean', + 101 + ); + + const index = await redis.zrange('index:txn:timestamp', 0, -1); + expect(index).toStrictEqual([]); + }); + + it('starts a retry loop which clears the saved details when a transaction fails the final attempt', async () => { + addMockTransationDetails(redis); + await (redis as Redis).hincrby(mockKey, 'retries', 5); + mockTransaction.submit.mockRejectedValue(new Error('MOCK ERROR')); + + startRetryLoop(mockContracts, redis); + jest.runOnlyPendingTimers(); + await flushPromises(); + + expect(mockContract.deserializeTransaction).toBeCalledWith(mockState); + expect(mockTransaction.submit).toBeCalledWith( + 'test111', + 'red', + 400, + 'Jean', + 101 + ); + + const index = await redis.zrange('index:txn:timestamp', 0, -1); + expect(index).toStrictEqual([]); + }); + }); + + describe('evatuateTransaction', () => { + const mockPayload = Buffer.from('MOCK PAYLOAD'); + let mockTransaction: MockProxy; + let mockContract: MockProxy; + + beforeEach(() => { + mockTransaction = mock(); + mockTransaction.evaluate.mockResolvedValue(mockPayload); + mockContract = mock(); + mockContract.createTransaction + .calledWith('txn') + .mockReturnValue(mockTransaction); + }); + + it('gets the result of evaluating a transaction', async () => { + const result = await evatuateTransaction( + mockContract, + 'txn', + 'arga', + 'argb' + ); + expect(result.toString()).toBe(mockPayload.toString()); + }); + + it.each([ + 'the asset GOCHAINCODE already exists', + 'Asset JAVACHAINCODE already exists', + 'The asset JSCHAINCODE already exists', + ])( + 'throws an AssetExistsError an asset already exists error occurs: %s', + async (msg) => { + mockTransaction.evaluate.mockRejectedValue(new Error(msg)); + + await expect(async () => { + await evatuateTransaction(mockContract, 'txn', 'arga', 'argb'); + }).rejects.toThrow(AssetExistsError); + } + ); + + it.each([ + 'the asset GOCHAINCODE does not exist', + 'Asset JAVACHAINCODE does not exist', + 'The asset JSCHAINCODE does not exist', + ])( + 'throws an AssetNotFoundError if an asset does not exist error occurs: %s', + async (msg) => { + mockTransaction.evaluate.mockRejectedValue(new Error(msg)); + + await expect(async () => { + await evatuateTransaction(mockContract, 'txn', 'arga', 'argb'); + }).rejects.toThrow(AssetNotFoundError); + } + ); + + it('throws a TransactionNotFoundError if a transaction not found error occurs', async () => { + mockTransaction.evaluate.mockRejectedValue( + new Error( + 'Failed to get transaction with id txn, error Entry not found in index' + ) + ); + + await expect(async () => { + await evatuateTransaction(mockContract, 'txn', 'arga', 'argb'); + }).rejects.toThrow(TransactionNotFoundError); + }); + + it('throws a TransactionError for other errors', async () => { + mockTransaction.evaluate.mockRejectedValue(new Error('MOCK ERROR')); + + await expect(async () => { + await evatuateTransaction(mockContract, 'txn', 'arga', 'argb'); + }).rejects.toThrow(TransactionError); + }); + }); + + describe('submitTransaction', () => { + let redis: Redis; + const mockPayload = Buffer.from('MOCK PAYLOAD'); + let mockTransaction: MockProxy; + let mockContract: MockProxy; + + beforeEach(async () => { + const redisOptions = { + port: config.redisPort, + host: config.redisHost, + username: config.redisUsername, + password: config.redisPassword, + }; + + redis = new IORedis(redisOptions) as unknown as Redis; + + mockTransaction = mock(); + mockTransaction.submit.mockResolvedValue(mockPayload); + mockTransaction.getTransactionId.mockReturnValue('MOCK TXN ID'); + mockTransaction.serialize.mockReturnValue(Buffer.from('MOCK TXN STATE')); + mockContract = mock(); + mockContract.createTransaction + .calledWith('txn') + .mockReturnValue(mockTransaction); + }); + + it('gets the transaction ID of the submitted transaction', async () => { + const result = await submitTransaction( + mockContract, + redis, + 'mspid', + 'txn', + 'arga', + 'argb' + ); + expect(result).toBe('MOCK TXN ID'); + }); + + it.each([ + 'the asset GOCHAINCODE already exists', + 'Asset JAVACHAINCODE already exists', + 'The asset JSCHAINCODE already exists', + ])( + 'throws an AssetExistsError an asset already exists error occurs: %s', + async (msg) => { + mockTransaction.submit.mockRejectedValue(new Error(msg)); + + await expect(async () => { + await submitTransaction( + mockContract, + redis, + 'mspid', + 'txn', + 'arga', + 'argb' + ); + }).rejects.toThrow(AssetExistsError); + } + ); + + it.each([ + 'the asset GOCHAINCODE does not exist', + 'Asset JAVACHAINCODE does not exist', + 'The asset JSCHAINCODE does not exist', + ])( + 'throws an AssetNotFoundError if an asset does not exist error occurs: %s', + async (msg) => { + mockTransaction.submit.mockRejectedValue(new Error(msg)); + + await expect(async () => { + await submitTransaction( + mockContract, + redis, + 'mspid', + 'txn', + 'arga', + 'argb' + ); + }).rejects.toThrow(AssetNotFoundError); + } + ); + + it('throws a TransactionNotFoundError if a transaction not found error occurs', async () => { + mockTransaction.submit.mockRejectedValue( + new Error( + 'Failed to get transaction with id txn, error Entry not found in index' + ) + ); + + await expect(async () => { + await submitTransaction( mockContract, redis, - transactionId, - savedTransaction + 'mspid', + 'txn', + 'arga', + 'argb' ); + }).rejects.toThrow(TransactionNotFoundError); + }); - expect(redis.del).toHaveBeenCalledTimes(1); - }); + it('throws a TransactionError for other errors', async () => { + mockTransaction.submit.mockRejectedValue(new Error('MOCK ERROR')); + + await expect(async () => { + await submitTransaction( + mockContract, + redis, + 'mspid', + 'txn', + 'arga', + 'argb' + ); + }).rejects.toThrow(TransactionError); + }); + }); + + describe('getBlockHeight', () => { + it('gets the current block height', async () => { + const mockBlockchainInfoProto = + fabricProtos.common.BlockchainInfo.create(); + mockBlockchainInfoProto.height = 42; + const mockBlockchainInfoBuffer = Buffer.from( + fabricProtos.common.BlockchainInfo.encode( + mockBlockchainInfoProto + ).finish() + ); + const mockContract = mock(); + mockContract.evaluateTransaction + .calledWith('GetChainInfo', 'mychannel') + .mockResolvedValue(mockBlockchainInfoBuffer); + + const result = (await getBlockHeight(mockContract)) as Long; + expect(result.toInt()).toStrictEqual(42); }); }); }); diff --git a/asset-transfer-basic/rest-api-typescript/src/fabric.ts b/asset-transfer-basic/rest-api-typescript/src/fabric.ts index 44014ce4..363f9c72 100644 --- a/asset-transfer-basic/rest-api-typescript/src/fabric.ts +++ b/asset-transfer-basic/rest-api-typescript/src/fabric.ts @@ -20,8 +20,10 @@ import * as config from './config'; import { logger } from './logger'; import { storeTransactionDetails, + getRetryTransactionDetails, clearTransactionDetails, incrementRetryCount, + TransactionDetails, } from './redis'; import { AssetExistsError, @@ -115,52 +117,48 @@ export const getContracts = async ( return { assetContract, qsccContract }; }; -export const startRetryLoop = (contract: Contract, redis: Redis): void => { - setInterval( - async (redis) => { - try { - const pendingTransactionCount = await (redis as Redis).zcard( - 'index:txn:timestamp' - ); - logger.debug( - 'Transactions awaiting retry: %d', - pendingTransactionCount - ); - - // TODO pick a random transaction instead to reduce chances of - // clashing with other instances? Currently no zrandmember - // command though... - // https://github.com/luin/ioredis/issues/1374 - const transactionIds = await (redis as Redis).zrange( - 'index:txn:timestamp', - -1, - -1 - ); - - if (transactionIds.length > 0) { - const transactionId = transactionIds[0]; - const savedTransaction = await (redis as Redis).hgetall( - `txn:${transactionId}` +export const startRetryLoop = ( + contracts: Map, + redis: Redis +): void => { + const retryInterval = setInterval( + async (contracts, redis) => { + if (logger.isLevelEnabled('debug')) { + try { + const pendingTransactionCount = await (redis as Redis).zcard( + 'index:txn:timestamp' + ); + logger.debug( + '%d transactions awaiting retry', + pendingTransactionCount + ); + } catch (err) { + logger.warn({ err }, 'Error getting pending transaction count'); + } + } + + const savedTransaction = await getRetryTransactionDetails(redis); + + if (savedTransaction) { + const contract = contracts.get(savedTransaction.mspId); + + if (contract) { + await retryTransaction(contract, redis, savedTransaction); + } else { + logger.error( + 'No contract found for %s to retry transaction %s', + savedTransaction.mspId, + savedTransaction.transactionId ); - if (parseInt(savedTransaction.retries) >= config.maxRetryCount) { - await clearTransactionDetails(redis, transactionId); - } else { - await retryTransaction( - contract, - redis, - transactionId, - savedTransaction - ); - } } - } catch (err) { - // TODO just log? - logger.error(err, 'error getting saved transaction state'); } }, config.retryDelay, + contracts, redis ); + + retryInterval.unref(); }; export const evatuateTransaction = async ( @@ -173,7 +171,10 @@ export const evatuateTransaction = async ( try { const payload = await txn.evaluate(...transactionArgs); - logger.debug({ payload }, 'Evaluate transaction response received'); + logger.debug( + { transactionId: txnId, payload: payload.toString() }, + 'Evaluate transaction response received' + ); return payload; } catch (err) { throw handleError(txnId, err); @@ -183,6 +184,7 @@ export const evatuateTransaction = async ( export const submitTransaction = async ( contract: Contract, redis: Redis, + mspId: string, transactionName: string, ...transactionArgs: string[] ): Promise => { @@ -195,7 +197,14 @@ export const submitTransaction = async ( try { // Store the transaction details and set the event handler in case there // are problems later with commiting the transaction - await storeTransactionDetails(redis, txnId, txnState, txnArgs, timestamp); + await storeTransactionDetails( + redis, + txnId, + mspId, + txnState, + txnArgs, + timestamp + ); txn.setEventHandler(DefaultEventHandlerStrategies.NONE); await txn.submit(...transactionArgs); } catch (err) { @@ -212,6 +221,7 @@ export const submitTransaction = async ( // Unfortunately the chaincode samples do not use error codes, and the error // message text is not the same for each implementation +// TODO move to errors.ts? const handleError = (transactionId: string, err: Error): Error => { // This regex needs to match the following error messages: // "the asset %s already exists" @@ -266,40 +276,55 @@ const handleError = (transactionId: string, err: Error): Error => { return new TransactionError('Transaction error', transactionId); }; -export const retryTransaction = async ( +const retryTransaction = async ( contract: Contract, redis: Redis, - transactionId: string, - savedTransaction: Record + savedTransaction: TransactionDetails ): Promise => { - logger.debug('Retrying transaction %s', transactionId); + logger.debug('Retrying transaction %s', savedTransaction.transactionId); try { const transaction = contract.deserializeTransaction( - Buffer.from(savedTransaction.state) + savedTransaction.transactionState ); - const args: string[] = JSON.parse(savedTransaction.args); + const args: string[] = JSON.parse(savedTransaction.transactionArgs); - await transaction.submit(...args); - await clearTransactionDetails(redis, transactionId); + const payload = await transaction.submit(...args); + logger.debug( + { + transactionId: savedTransaction.transactionId, + payload: payload.toString(), + }, + 'Retry transaction response received' + ); + + await clearTransactionDetails(redis, savedTransaction.transactionId); } catch (err) { - if (isDuplicateTransaction(err)) { - logger.warn('Transaction %s has already been committed', transactionId); - await clearTransactionDetails(redis, transactionId); + if (isDuplicateTransactionError(err)) { + logger.warn( + 'Transaction %s has already been committed', + savedTransaction.transactionId + ); + await clearTransactionDetails(redis, savedTransaction.transactionId); } else { - // TODO check for retry limit and update timestamp logger.warn( err, 'Retry %d failed for transaction %s', savedTransaction.retries, - transactionId + savedTransaction.transactionId ); - await incrementRetryCount(redis, transactionId); + + if (savedTransaction.retries < config.maxRetryCount) { + await incrementRetryCount(redis, savedTransaction.transactionId); + } else { + await clearTransactionDetails(redis, savedTransaction.transactionId); + } } } }; -const isDuplicateTransaction = (error: { +// TODO move to errors.ts? +const isDuplicateTransactionError = (error: { errors: { endorsements: { details: string }[] }[]; }) => { // TODO this is horrible! Isn't it possible to check for TxValidationCode DUPLICATE_TXID somehow? diff --git a/asset-transfer-basic/rest-api-typescript/src/index.ts b/asset-transfer-basic/rest-api-typescript/src/index.ts index 722079c5..b8b13d69 100644 --- a/asset-transfer-basic/rest-api-typescript/src/index.ts +++ b/asset-transfer-basic/rest-api-typescript/src/index.ts @@ -2,24 +2,21 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { Contract, Network } from 'fabric-network'; +import { Network } from 'fabric-network'; import { Redis } from 'ioredis'; import * as config from './config'; -import { startRetryLoop, blockEventHandler } from './fabric'; +import { blockEventHandler } from './fabric'; import { logger } from './logger'; import { createServer } from './server'; async function main() { const app = await createServer(); - // TODO block listener and retry logic currently only handles a single org!!! - // TODO should these be initialised here? - const contract = app.get(config.mspIdOrg1).assetContract as Contract; + // TODO block listener currently only handles a single org!!! + // TODO should it be initialised here? const redis = app.get('redis') as Redis; const network = app.get('networkOrg1') as Network; - await network.addBlockListener(blockEventHandler(redis)); - startRetryLoop(contract, redis); app.listen(config.port, () => { logger.info('Express server started on port: %d', config.port); diff --git a/asset-transfer-basic/rest-api-typescript/src/logger.ts b/asset-transfer-basic/rest-api-typescript/src/logger.ts index fe53a01e..1f1cea83 100644 --- a/asset-transfer-basic/rest-api-typescript/src/logger.ts +++ b/asset-transfer-basic/rest-api-typescript/src/logger.ts @@ -1,3 +1,7 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + */ + import pino from 'pino'; import * as config from './config'; diff --git a/asset-transfer-basic/rest-api-typescript/src/redis.spec.ts b/asset-transfer-basic/rest-api-typescript/src/redis.spec.ts index 7d81cb99..8b4f291c 100644 --- a/asset-transfer-basic/rest-api-typescript/src/redis.spec.ts +++ b/asset-transfer-basic/rest-api-typescript/src/redis.spec.ts @@ -1,75 +1,184 @@ -import IORedis from './__mocks__/IORedis'; +/* + * SPDX-License-Identifier: Apache-2.0 + */ + import * as config from './config'; -import { Redis } from 'ioredis'; +import IORedis, { Redis } from 'ioredis'; import { clearTransactionDetails, incrementRetryCount, storeTransactionDetails, + getTransactionDetails, + getRetryTransactionDetails, } from './redis'; -jest.mock('ioredis'); +jest.mock('ioredis', () => require('ioredis-mock/jest')); jest.mock('./config'); -const redisOptions = { - port: config.redisPort, - host: config.redisHost, - username: config.redisUsername, - password: config.redisPassword, -}; -const redis = new IORedis(redisOptions) as unknown as Redis; -describe('Testing increment retries ', () => { - const transactionId = +describe('Redis', () => { + let redis: Redis; + + const mockTransactionId = '0ae62c01e4c4b112c3f3954a2f11243da76778e46df9ad2783bcbafc79652b95'; - it('Should increment retries for valid transction id', async () => { - await incrementRetryCount(redis, transactionId); - expect(redis.hincrby).toHaveBeenCalledTimes(1); + const mockKey = `txn:${mockTransactionId}`; + const mockMspId = 'Org1MSP'; + const mockState = Buffer.from( + `{"name":"CreateAsset","nonce":"damqinq8nrI4n4qY8lFVsZw7RwG2ufrv","transactionId":${mockTransactionId}` + ); + const mockArgs = '["test111","red",400,"Jean",101]'; + const mockTimestamp = 1628078044362; + + const addMockTransationDetails = async (redis: Redis) => { + await redis + .multi() + .hset( + mockKey, + 'mspId', + mockMspId, + 'state', + mockState, + 'args', + mockArgs, + 'timestamp', + mockTimestamp, + 'retries', + '0' + ) + .zadd('index:txn:timestamp', mockTimestamp, mockTransactionId) + .exec(); + }; + + beforeEach(async () => { + const redisOptions = { + port: config.redisPort, + host: config.redisHost, + username: config.redisUsername, + password: config.redisPassword, + }; + + redis = new IORedis(redisOptions) as unknown as Redis; + }); + describe('storeTransactionDetails', () => { + it('stores transaction details as a hash', async () => { + await storeTransactionDetails( + redis, + mockTransactionId, + mockMspId, + mockState, + mockArgs, + mockTimestamp + ); + + const storedTransaction = await redis.hgetall(mockKey); + const expectedTransaction = { + mspId: mockMspId, + state: mockState, + args: mockArgs, + retries: '0', + timestamp: mockTimestamp.toString(), + }; + expect(storedTransaction).toStrictEqual(expectedTransaction); + }); + + it('adds the transaction ID to the sorted set timestamp index', async () => { + await storeTransactionDetails( + redis, + mockTransactionId, + mockMspId, + mockState, + mockArgs, + mockTimestamp + ); + + const index = await redis.zrange('index:txn:timestamp', 0, -1); + expect(index).toStrictEqual([mockTransactionId]); + }); + + // TODO this seems to work for spying/mocking... + // jest.spyOn(redis, 'multi').mock... + // but haven't worked out how to spy on the hset, zadd, exec in that chain + // Ask Mark? + it.todo('handles an error from redis'); }); - it('Should not increment retries for empty transaction id ', async () => { - await incrementRetryCount(redis, ''); - expect(redis.hincrby).toHaveBeenCalledTimes(0); - }); -}); + describe('getTransactionDetails', () => { + it('gets the transaction details from a hash', async () => { + await addMockTransationDetails(redis); -describe('Testing storeTransactionDetails ', () => { - const args = '["test111","red",400,"Jean",101]'; - const timestamp = 1628078044362; - it('Should store details for valid transction Id', async () => { - const transactionId = - '0ae62c01e4c4b112c3f3954a2f11243da76778e46df9ad2783bcbafc79652b95'; - const state = `{"name":"CreateAsset","nonce":"damqinq8nrI4n4qY8lFVsZw7RwG2ufrv","transactionId":${transactionId}`; - await storeTransactionDetails( - redis, - transactionId, - Buffer.from(state), - args, - timestamp + const details = await getTransactionDetails(redis, mockTransactionId); + + expect(details).toStrictEqual({ + transactionId: mockTransactionId, + mspId: mockMspId, + transactionState: mockState, + transactionArgs: mockArgs, + retries: 0, + timestamp: mockTimestamp, + }); + }); + + it.todo('handles an error from redis'); + }); + + describe('getRetryTransactionDetails', () => { + it('gets the oldest transaction details from a hash', async () => { + await addMockTransationDetails(redis); + + const details = await getRetryTransactionDetails(redis); + + expect(details).toStrictEqual({ + transactionId: mockTransactionId, + mspId: mockMspId, + transactionState: mockState, + transactionArgs: mockArgs, + retries: 0, + timestamp: mockTimestamp, + }); + }); + + it('gets undefined if there are no transactions to retry', async () => { + const details = await getRetryTransactionDetails(redis); + + expect(details).toBeUndefined(); + }); + + it.todo('handles an error from redis'); + }); + + describe('clearTransactionDetails', () => { + it('removes the transaction details hash', async () => { + await addMockTransationDetails(redis); + + await clearTransactionDetails(redis, mockTransactionId); + + const storedTransaction = await redis.hgetall(mockKey); + expect(storedTransaction).not.toHaveProperty('state'); + }); + + it('removes the transaction ID from the sorted set timestamp index', async () => { + await addMockTransationDetails(redis); + + await clearTransactionDetails(redis, mockTransactionId); + + const index = await redis.zrange('index:txn:timestamp', 0, -1); + expect(index).toStrictEqual([]); + }); + }); + + describe('incrementRetryCount', () => { + it('increments the retries value in the transction details hash', async () => { + await addMockTransationDetails(redis); + + await incrementRetryCount(redis, mockTransactionId); + + const retries = await redis.hget(mockKey, 'retries'); + expect(retries).toBe('1'); + }); + + it.todo( + 'updates the position of the transaction ID in the sorted set timestamp index' ); - expect(redis.hset).toHaveBeenCalledTimes(1); - expect(redis.zadd).toHaveBeenCalledTimes(1); - }); - it('Should not store details for empty transction Id', async () => { - const transactionId = ''; - const state = `{"name":"CreateAsset","nonce":"damqinq8nrI4n4qY8lFVsZw7RwG2ufrv","transactionId":${transactionId}`; - await storeTransactionDetails( - redis, - transactionId, - Buffer.from(state), - args, - timestamp - ); - expect(redis.hset).toHaveBeenCalledTimes(0); - expect(redis.zadd).toHaveBeenCalledTimes(0); - }); -}); - -describe('Testing clearTransactionDetails ', () => { - it('Should clear details ', async () => { - const transactionId = - '0ae62c01e4c4b112c3f3954a2f11243da76778e46df9ad2783bcbafc79652b95'; - await clearTransactionDetails(redis, transactionId); - expect(redis.del).toHaveBeenCalledTimes(1); - expect(redis.zrem).toHaveBeenCalledTimes(1); + it.todo('handles an error from redis'); }); }); diff --git a/asset-transfer-basic/rest-api-typescript/src/redis.ts b/asset-transfer-basic/rest-api-typescript/src/redis.ts index c4e8b7c3..fc89fa9b 100644 --- a/asset-transfer-basic/rest-api-typescript/src/redis.ts +++ b/asset-transfer-basic/rest-api-typescript/src/redis.ts @@ -1,5 +1,12 @@ /* * SPDX-License-Identifier: Apache-2.0 + * + * This sample includes basic retry logic so it needs somewhere to store + * transaction details in case the app restarts for any reason, and Redis is + * just one of the options available + * + * Note: This implementation is not designed with multiple instances of the + * REST app in mind, which is likely to be required in a production environment */ import IORedis, { Redis, RedisOptions } from 'ioredis'; @@ -16,29 +23,44 @@ const redisOptions: RedisOptions = { export const redis = new IORedis(redisOptions); +export type TransactionDetails = { + transactionId: string; + mspId: string; + transactionState: Buffer; + transactionArgs: string; + timestamp: number; + retries: number; +}; + +/* + * Store enough information in order to resubmit a transaction + */ export const storeTransactionDetails = async ( redis: Redis, transactionId: string, + mspId: string, transactionState: Buffer, transactionArgs: string, timestamp: number ): Promise => { try { - if (transactionId.length === 0) { - throw new Error('Empty transaction Id found'); - } const key = `txn:${transactionId}`; logger.debug( - 'Storing transaction details. Key: %s State: %s Args: %s Timestamp: %d', - key, - transactionState, - transactionArgs, - timestamp + { + key, + mspId, + transactionState, + transactionArgs, + timestamp, + }, + 'Storing transaction details' ); await redis .multi() .hset( key, + 'mspId', + mspId, 'state', transactionState, 'args', @@ -51,14 +73,84 @@ export const storeTransactionDetails = async ( .zadd('index:txn:timestamp', timestamp, transactionId) .exec(); } catch (err) { + // TODO just log?! logger.error( - err, - 'Error storing transaction details. ID %s', + { err }, + 'Error storing details for transaction ID %s', transactionId ); } }; +/* + * Get the information required to resubmit a transaction + */ +export const getTransactionDetails = async ( + redis: Redis, + transactionId: string +): Promise => { + try { + const savedTransaction = await (redis as Redis).hgetall( + `txn:${transactionId}` + ); + logger.debug( + { transactionId: transactionId, state: savedTransaction }, + 'Got transaction details' + ); + + const transactionDetails = { + transactionId: transactionId, + mspId: savedTransaction.mspId, + transactionState: Buffer.from(savedTransaction.state), + transactionArgs: savedTransaction.args, + timestamp: parseInt(savedTransaction.timestamp), + retries: parseInt(savedTransaction.retries), + }; + return transactionDetails; + } catch (err) { + // TODO just log?! + logger.error( + { err }, + 'Error getting details for transaction ID %s', + transactionId + ); + } +}; + +/* + * Get the oldest transaction details + */ +export const getRetryTransactionDetails = async ( + redis: Redis +): Promise => { + try { + const transactionIds = await (redis as Redis).zrange( + 'index:txn:timestamp', + -1, + -1 + ); + + if (transactionIds.length > 0) { + const transactionId = transactionIds[0]; + + const savedTransaction = await getTransactionDetails( + redis, + transactionId + ); + return savedTransaction; + } + } catch (err) { + // TODO just log?! + logger.error( + { err }, + 'Error getting details for next transaction to retry' + ); + } +}; + +/* + * Delete transaction details + */ export const clearTransactionDetails = async ( redis: Redis, transactionId: string @@ -72,16 +164,20 @@ export const clearTransactionDetails = async ( .zrem('index:txn:timestamp', transactionId) .exec(); } catch (err) { + // TODO just log?! logger.error( - err, - 'Error remove saved transaction state for transaction ID %s', + { err }, + 'Error remove details for transaction ID %s', transactionId ); } }; -// TODO add getTransaction etc. helpers? +/* + * Increment the number of times the transaction has been retried + * TODO needs to update the timestamp and index as well + */ export const incrementRetryCount = async ( redis: Redis, transactionId: string @@ -89,11 +185,9 @@ export const incrementRetryCount = async ( const key = `txn:${transactionId}`; logger.debug('Incrementing retries fortransaction Key: %s', key); try { - if (transactionId.length === 0) { - throw new Error('Empty transaction Id found'); - } await (redis as Redis).hincrby(`txn:${transactionId}`, 'retries', 1); } catch (err) { + // TODO just log?! logger.error( err, 'Error incrementing retries for transaction ID %s', diff --git a/asset-transfer-basic/rest-api-typescript/src/server.ts b/asset-transfer-basic/rest-api-typescript/src/server.ts index 14674a1c..e9e57e96 100644 --- a/asset-transfer-basic/rest-api-typescript/src/server.ts +++ b/asset-transfer-basic/rest-api-typescript/src/server.ts @@ -6,6 +6,7 @@ import helmet from 'helmet'; import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import express, { Application, NextFunction, Request, Response } from 'express'; import pinoMiddleware from 'pino-http'; +import { Contract } from 'fabric-network'; import { logger } from './logger'; import { assetsRouter } from './assets.router'; @@ -16,6 +17,7 @@ import { getNetwork, createGateway, createWallet, + startRetryLoop, } from './fabric'; import { redis } from './redis'; import * as config from './config'; @@ -91,6 +93,11 @@ export const createServer = async (): Promise => { const contractsOrg2 = await getContracts(networkOrg2); app.set(config.mspIdOrg2, contractsOrg2); + const assetContracts = new Map(); + assetContracts.set(config.mspIdOrg1, contractsOrg1.assetContract); + assetContracts.set(config.mspIdOrg2, contractsOrg2.assetContract); + startRetryLoop(assetContracts, redis); + app.set('redis', redis); app.use('/', healthRouter); diff --git a/asset-transfer-basic/rest-api-typescript/src/transactions.router.ts b/asset-transfer-basic/rest-api-typescript/src/transactions.router.ts index 27f6438b..a91c2fc5 100644 --- a/asset-transfer-basic/rest-api-typescript/src/transactions.router.ts +++ b/asset-transfer-basic/rest-api-typescript/src/transactions.router.ts @@ -7,6 +7,7 @@ import { Contract } from 'fabric-network'; import { protos } from 'fabric-protos'; import { getReasonPhrase, StatusCodes } from 'http-status-codes'; import { Redis } from 'ioredis'; +import { getTransactionDetails } from './redis'; import { evatuateTransaction } from './fabric'; import { logger } from './logger'; import * as config from './config'; @@ -33,18 +34,14 @@ transactionsRouter.get( const redis = req.app.get('redis') as Redis; try { - const savedTransaction = await (redis as Redis).hgetall( - `txn:${transactionId}` - ); - logger.debug( - { transactionId: transactionId, state: savedTransaction }, - 'Saved transaction state' + const savedTransaction = await getTransactionDetails( + redis, + transactionId ); - if (savedTransaction.state) { + if (savedTransaction?.transactionState) { foundTransaction = true; - const retries = parseInt(savedTransaction.retries); - if (retries > 0) { + if (savedTransaction.retries > 0) { progress = 'RETRYING'; } else { progress = 'ACCEPTED'; From 9aec7ffd2a9a30a614d279c225251050dfa6e822 Mon Sep 17 00:00:00 2001 From: sapthasurendran Date: Fri, 10 Sep 2021 13:50:20 +0530 Subject: [PATCH 40/59] Clear Transaction when no contract found Signed-off-by: sapthasurendran --- .../rest-api-typescript/src/fabric.spec.ts | 12 ++++++++++++ .../rest-api-typescript/src/fabric.ts | 1 + 2 files changed, 13 insertions(+) diff --git a/asset-transfer-basic/rest-api-typescript/src/fabric.spec.ts b/asset-transfer-basic/rest-api-typescript/src/fabric.spec.ts index b74ddf7a..8f915849 100644 --- a/asset-transfer-basic/rest-api-typescript/src/fabric.spec.ts +++ b/asset-transfer-basic/rest-api-typescript/src/fabric.spec.ts @@ -280,6 +280,18 @@ describe('Fabric', () => { const index = await redis.zrange('index:txn:timestamp', 0, -1); expect(index).toStrictEqual([]); }); + + it('starts a retry loop which clears the saved details when no contract exist for the org', async () => { + addMockTransationDetails(redis); + mockContracts = new Map(); + startRetryLoop(mockContracts, redis); + jest.runOnlyPendingTimers(); + await flushPromises(); + + const index = await redis.zrange('index:txn:timestamp', 0, -1); + expect(index).toStrictEqual([]); + }); + }); describe('evatuateTransaction', () => { diff --git a/asset-transfer-basic/rest-api-typescript/src/fabric.ts b/asset-transfer-basic/rest-api-typescript/src/fabric.ts index 363f9c72..8a99d7cf 100644 --- a/asset-transfer-basic/rest-api-typescript/src/fabric.ts +++ b/asset-transfer-basic/rest-api-typescript/src/fabric.ts @@ -145,6 +145,7 @@ export const startRetryLoop = ( if (contract) { await retryTransaction(contract, redis, savedTransaction); } else { + clearTransactionDetails(redis,savedTransaction.transactionId) logger.error( 'No contract found for %s to retry transaction %s', savedTransaction.mspId, From e6738818e52bed41dbfa669f435102b9de744d41 Mon Sep 17 00:00:00 2001 From: James Taylor Date: Thu, 9 Sep 2021 14:41:23 +0100 Subject: [PATCH 41/59] Update readmes Move usage instructions to the node sample directory and add overview/next steps to top level readme Signed-off-by: James Taylor --- README.md | 129 ++++-------------- .../rest-api-typescript/README.md | 126 +++++++++++++++++ 2 files changed, 152 insertions(+), 103 deletions(-) diff --git a/README.md b/README.md index 06f58ab8..076cf6c8 100644 --- a/README.md +++ b/README.md @@ -2,128 +2,51 @@ Prototype sample REST server to demonstrate good Fabric Node SDK practices for parts of [FAB-18511](https://jira.hyperledger.org/browse/FAB-18511) -The primary aim of this sample is to show how to write a long running client application using the Fabric Node SDK +The intention is to deliver the sample to the [asset-transfer-basic/rest-api-typescript directory of the fabric-samples repository](https://github.com/hyperledger/fabric-samples/tree/main/asset-transfer-basic) -The REST API is intended to work with the [basic asset transfer example](https://github.com/hyperledger/fabric-samples/tree/main/asset-transfer-basic) +See the [sample readme for usage intructions](asset-transfer-basic/rest-api-typescript/README.md) -To install the basic asset transfer chaincode on a local Fabric network, follow the [Using the Fabric test network](https://hyperledger-fabric.readthedocs.io/en/release-2.2/test_network.html) tutorial +## Overview -## Usage +The primary aim of this sample is to show how to write a long running client application using the Fabric Node SDK, i.e. without reconnecting for each transaction -**Note:** these instructions should work with the release-2.2 branch of `fabric-samples` but later versions require some changes +It should also show: -To build and start the sample REST server, you'll need to [download and install an LTS version of node](https://nodejs.org/en/download/) +- basic transaction retries +- long running event handling +- requests from multiple users -Clone this repository and change to the `fabric-rest-sample/asset-transfer-basic/rest-api-typescript` directory before running the following commands +## Next steps -Install dependencies +### Handling transaction errors -```shell -npm install -``` +Should transactions be retried _unless they fail_ with specific errors, e.g. duplicate transaction (the current implementation)? -Build the REST server +**or** -```shell -npm run build -``` +Should transactions be retried _when they fail_ with specific errors? -Create a `.env` file to configure the server for the test network (make sure TEST_NETWORK_HOME is set to the fully qualified `test-network` directory) +Also, transactions are currently only retried if they are successfully endorsed- does that seem reasonable? -```shell -TEST_NETWORK_HOME=$HOME/fabric-samples/test-network npm run generateEnv -``` +If the transaction failed because of MVCC_READ_CONFLICT, is a chance that it could pass when retrying? (Is MVCC_READ_CONFLICT an endorsement error?) -Start a Redis server +### Handling other errors -```shell -npm run start:redis -``` +Need to make sure it's clear what went wrong and fail properly it necessary, for example when starting without a redis instance -Start the sample REST server +### Finish off unit tests -```shell -npm run start:dev -``` +Coverage is looking much better now but there are a few more todos -### Docker image +### More comments -Alternatively, run the following commands in the `fabric-rest-sample/asset-transfer-basic/rest-api-typescript` directory to start the sample in a Docker container +Need to document what's going on and why, especially in the fabric.ts file! -Build the Docker image +### Feedback -```shell -docker build -t fabric-rest-sample . -``` +- More people trying out the sample (and ideally trying to break it a bit!) +- Code review to merge sample into fabric-samples -Create a `.env` file to configure the server for the test network (make sure `TEST_NETWORK_HOME` is set to the fully qualified `test-network` directory and `AS_LOCAL_HOST` is set to `false` so that the server works inside the Docker Compose network) +### Known problems -```shell -TEST_NETWORK_HOME=$HOME/fabric-samples/test-network AS_LOCAL_HOST=false npm run generateEnv -``` - -Start the sample REST server and Redis server - -```shell -docker-compose up -d -``` - -## REST API - -If everything went well, you can now make basic asset transfer REST calls! - -The examples below require a `SAMPLE_APIKEY` environment variable which must be set to an API key from the `.env` file created above. - -For example, to use the ORG1_APIKEY... - -``` -SAMPLE_APIKEY=$(grep ORG1_APIKEY .env | cut -d '=' -f 2-) -``` - -### Get all assets... - -```shell -curl --header "X-Api-Key: ${SAMPLE_APIKEY}" http://localhost:3000/api/assets -``` - -### Check whether an asset exists... - -```shell -curl --include --header "X-Api-Key: ${SAMPLE_APIKEY}" --request OPTIONS http://localhost:3000/api/assets/asset7 -``` - -### Create an asset... - -```shell -curl --include --header "Content-Type: application/json" --header "X-Api-Key: ${SAMPLE_APIKEY}" --request POST --data '{"id":"asset7","color":"red","size":42,"owner":"Jean","appraisedValue":101}' http://localhost:3000/api/assets -``` - -### Read transaction status... - -```shell -curl --header "X-Api-Key: ${SAMPLE_APIKEY}" http://localhost:3000/api/transactions/__transaction_id__ -``` - -### Read an asset... - -```shell -curl --header "X-Api-Key: ${SAMPLE_APIKEY}" http://localhost:3000/api/assets/asset7 -``` - -### Update an asset... - -```shell -curl --include --header "Content-Type: application/json" --header "X-Api-Key: ${SAMPLE_APIKEY}" --request PUT --data '{"id":"asset7","color":"red","size":11,"owner":"Jean","appraisedValue":101}' http://localhost:3000/api/assets/asset7 -``` - -### Transfer an asset... - -```shell -curl --include --header "Content-Type: application/json" --header "X-Api-Key: ${SAMPLE_APIKEY}" --request PATCH --data '[{"op":"replace","path":"/owner","value":"Ashleigh"}]' http://localhost:3000/api/assets/asset7 -``` - -### Delete an asset... - -```shell -curl --include --header "X-Api-Key: ${SAMPLE_APIKEY}" --request DELETE http://localhost:3000/api/assets/asset7 -``` +See [issues](https://github.com/hyperledgendary/fabric-rest-sample/issues) diff --git a/asset-transfer-basic/rest-api-typescript/README.md b/asset-transfer-basic/rest-api-typescript/README.md index d73efbdc..ea61b775 100644 --- a/asset-transfer-basic/rest-api-typescript/README.md +++ b/asset-transfer-basic/rest-api-typescript/README.md @@ -1,3 +1,129 @@ # Asset Transfer REST API Sample Prototype sample REST server to demonstrate good Fabric Node SDK practices + +The primary aim of this sample is to show how to write a long running client application using the Fabric Node SDK + +The REST API is intended to work with the [basic asset transfer example](https://github.com/hyperledger/fabric-samples/tree/main/asset-transfer-basic) + +To install the basic asset transfer chaincode on a local Fabric network, follow the [Using the Fabric test network](https://hyperledger-fabric.readthedocs.io/en/release-2.2/test_network.html) tutorial + +## Usage + +**Note:** these instructions should work with the release-2.2 branch of `fabric-samples` but later versions require some changes + +To build and start the sample REST server, you'll need to [download and install an LTS version of node](https://nodejs.org/en/download/) + +Clone this repository and change to the `fabric-rest-sample/asset-transfer-basic/rest-api-typescript` directory before running the following commands + +Install dependencies + +```shell +npm install +``` + +Build the REST server + +```shell +npm run build +``` + +Create a `.env` file to configure the server for the test network (make sure TEST_NETWORK_HOME is set to the fully qualified `test-network` directory) + +```shell +TEST_NETWORK_HOME=$HOME/fabric-samples/test-network npm run generateEnv +``` + +Start a Redis server + +```shell +npm run start:redis +``` + +Start the sample REST server + +```shell +npm run start:dev +``` + +### Docker image + +Alternatively, run the following commands in the `fabric-rest-sample/asset-transfer-basic/rest-api-typescript` directory to start the sample in a Docker container + +Build the Docker image + +```shell +docker build -t fabric-rest-sample . +``` + +Create a `.env` file to configure the server for the test network (make sure `TEST_NETWORK_HOME` is set to the fully qualified `test-network` directory and `AS_LOCAL_HOST` is set to `false` so that the server works inside the Docker Compose network) + +```shell +TEST_NETWORK_HOME=$HOME/fabric-samples/test-network AS_LOCAL_HOST=false npm run generateEnv +``` + +Start the sample REST server and Redis server + +```shell +docker-compose up -d +``` + +## REST API + +If everything went well, you can now make basic asset transfer REST calls! + +The examples below require a `SAMPLE_APIKEY` environment variable which must be set to an API key from the `.env` file created above. + +For example, to use the ORG1_APIKEY... + +``` +SAMPLE_APIKEY=$(grep ORG1_APIKEY .env | cut -d '=' -f 2-) +``` + +### Get all assets... + +```shell +curl --header "X-Api-Key: ${SAMPLE_APIKEY}" http://localhost:3000/api/assets +``` + +### Check whether an asset exists... + +```shell +curl --include --header "X-Api-Key: ${SAMPLE_APIKEY}" --request OPTIONS http://localhost:3000/api/assets/asset7 +``` + +### Create an asset... + +```shell +curl --include --header "Content-Type: application/json" --header "X-Api-Key: ${SAMPLE_APIKEY}" --request POST --data '{"id":"asset7","color":"red","size":42,"owner":"Jean","appraisedValue":101}' http://localhost:3000/api/assets +``` + +### Read transaction status... + +```shell +curl --header "X-Api-Key: ${SAMPLE_APIKEY}" http://localhost:3000/api/transactions/__transaction_id__ +``` + +### Read an asset... + +```shell +curl --header "X-Api-Key: ${SAMPLE_APIKEY}" http://localhost:3000/api/assets/asset7 +``` + +### Update an asset... + +```shell +curl --include --header "Content-Type: application/json" --header "X-Api-Key: ${SAMPLE_APIKEY}" --request PUT --data '{"id":"asset7","color":"red","size":11,"owner":"Jean","appraisedValue":101}' http://localhost:3000/api/assets/asset7 +``` + +### Transfer an asset... + +```shell +curl --include --header "Content-Type: application/json" --header "X-Api-Key: ${SAMPLE_APIKEY}" --request PATCH --data '[{"op":"replace","path":"/owner","value":"Ashleigh"}]' http://localhost:3000/api/assets/asset7 +``` + +### Delete an asset... + +```shell +curl --include --header "X-Api-Key: ${SAMPLE_APIKEY}" --request DELETE http://localhost:3000/api/assets/asset7 +``` From f1a9fea77d913b38de79d032815363de2cdcad03 Mon Sep 17 00:00:00 2001 From: James Taylor Date: Fri, 10 Sep 2021 14:48:49 +0100 Subject: [PATCH 42/59] Fix lint errors Signed-off-by: James Taylor --- asset-transfer-basic/rest-api-typescript/src/fabric.spec.ts | 1 - asset-transfer-basic/rest-api-typescript/src/fabric.ts | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/asset-transfer-basic/rest-api-typescript/src/fabric.spec.ts b/asset-transfer-basic/rest-api-typescript/src/fabric.spec.ts index 8f915849..d04d8b6d 100644 --- a/asset-transfer-basic/rest-api-typescript/src/fabric.spec.ts +++ b/asset-transfer-basic/rest-api-typescript/src/fabric.spec.ts @@ -291,7 +291,6 @@ describe('Fabric', () => { const index = await redis.zrange('index:txn:timestamp', 0, -1); expect(index).toStrictEqual([]); }); - }); describe('evatuateTransaction', () => { diff --git a/asset-transfer-basic/rest-api-typescript/src/fabric.ts b/asset-transfer-basic/rest-api-typescript/src/fabric.ts index 8a99d7cf..ea81b386 100644 --- a/asset-transfer-basic/rest-api-typescript/src/fabric.ts +++ b/asset-transfer-basic/rest-api-typescript/src/fabric.ts @@ -145,7 +145,7 @@ export const startRetryLoop = ( if (contract) { await retryTransaction(contract, redis, savedTransaction); } else { - clearTransactionDetails(redis,savedTransaction.transactionId) + clearTransactionDetails(redis, savedTransaction.transactionId); logger.error( 'No contract found for %s to retry transaction %s', savedTransaction.mspId, From 00a2dea50b0cba25ad638e8fa4fbbda60f94094f Mon Sep 17 00:00:00 2001 From: James Taylor Date: Fri, 10 Sep 2021 15:53:51 +0100 Subject: [PATCH 43/59] Add block event listener config Signed-off-by: James Taylor --- .../rest-api-typescript/src/config.spec.ts | 22 ++++ .../rest-api-typescript/src/config.ts | 19 ++- .../rest-api-typescript/src/fabric.spec.ts | 120 +++++++++++++----- .../rest-api-typescript/src/fabric.ts | 34 +++-- .../rest-api-typescript/src/index.ts | 9 -- .../rest-api-typescript/src/server.ts | 11 +- 6 files changed, 158 insertions(+), 57 deletions(-) diff --git a/asset-transfer-basic/rest-api-typescript/src/config.spec.ts b/asset-transfer-basic/rest-api-typescript/src/config.spec.ts index cdfc6ecb..fdd5d91e 100644 --- a/asset-transfer-basic/rest-api-typescript/src/config.spec.ts +++ b/asset-transfer-basic/rest-api-typescript/src/config.spec.ts @@ -152,6 +152,28 @@ describe('Config values', () => { }); }); + describe('blockListenerOrg', () => { + it('defaults to "Org1"', () => { + const config = require('./config'); + expect(config.blockListenerOrg).toBe('Org1'); + }); + + it('can be configured using the "HLF_BLOCK_LISTENER_ORG" environment variable', () => { + process.env.HLF_BLOCK_LISTENER_ORG = 'Org2'; + const config = require('./config'); + expect(config.blockListenerOrg).toBe('Org2'); + }); + + it('throws an error when the "HLF_BLOCK_LISTENER_ORG" environment variable has an invalid value', () => { + process.env.HLF_BLOCK_LISTENER_ORG = 'Org3'; + expect(() => { + require('./config'); + }).toThrow( + 'env-var: "HLF_BLOCK_LISTENER_ORG" should be one of [Org1, Org2]' + ); + }); + }); + describe('channelName', () => { it('defaults to "mychannel"', () => { const config = require('./config'); diff --git a/asset-transfer-basic/rest-api-typescript/src/config.ts b/asset-transfer-basic/rest-api-typescript/src/config.ts index afc15aac..47c0f909 100644 --- a/asset-transfer-basic/rest-api-typescript/src/config.ts +++ b/asset-transfer-basic/rest-api-typescript/src/config.ts @@ -4,6 +4,9 @@ import * as env from 'env-var'; +export const ORG1 = 'Org1'; +export const ORG2 = 'Org2'; + /* * Log level for the REST server */ @@ -55,8 +58,8 @@ export const asLocalhost = env */ export const mspIdOrg1 = env .get('HLF_MSP_ID_ORG1') - .default('Org1MSP') - .example('Org1MSP') + .default(`${ORG1}MSP`) + .example(`${ORG1}MSP`) .asString(); /* @@ -64,10 +67,18 @@ export const mspIdOrg1 = env */ export const mspIdOrg2 = env .get('HLF_MSP_ID_ORG2') - .default('Org2MSP') - .example('Org2MSP') + .default(`${ORG2}MSP`) + .example(`${ORG2}MSP`) .asString(); +/* + * The block listener org + */ +export const blockListenerOrg = env + .get('HLF_BLOCK_LISTENER_ORG') + .default(ORG1) + .asEnum([ORG1, ORG2]); + /* * Name of the channel which the basic asset sample chaincode has been installed on */ diff --git a/asset-transfer-basic/rest-api-typescript/src/fabric.spec.ts b/asset-transfer-basic/rest-api-typescript/src/fabric.spec.ts index d04d8b6d..f871c395 100644 --- a/asset-transfer-basic/rest-api-typescript/src/fabric.spec.ts +++ b/asset-transfer-basic/rest-api-typescript/src/fabric.spec.ts @@ -11,6 +11,7 @@ import { submitTransaction, getBlockHeight, startRetryLoop, + blockEventHandler, } from './fabric'; import * as config from './config'; @@ -22,11 +23,13 @@ import { } from './errors'; import { + BlockEvent, Contract, Gateway, GatewayOptions, Network, Transaction, + TransactionEvent, Wallet, } from 'fabric-network'; @@ -40,6 +43,36 @@ jest.mock('./config'); jest.mock('ioredis', () => require('ioredis-mock/jest')); describe('Fabric', () => { + const mockTransactionId = + '0ae62c01e4c4b112c3f3954a2f11243da76778e46df9ad2783bcbafc79652b95'; + const mockKey = `txn:${mockTransactionId}`; + const mockMspId = 'Org1MSP'; + const mockState = Buffer.from( + `{"name":"CreateAsset","nonce":"damqinq8nrI4n4qY8lFVsZw7RwG2ufrv","transactionId":${mockTransactionId}` + ); + const mockArgs = '["test111","red",400,"Jean",101]'; + const mockTimestamp = 1628078044362; + + const addMockTransationDetails = async (redis: Redis) => { + await redis + .multi() + .hset( + mockKey, + 'mspId', + mockMspId, + 'state', + mockState, + 'args', + mockArgs, + 'timestamp', + mockTimestamp, + 'retries', + '0' + ) + .zadd('index:txn:timestamp', mockTimestamp, mockTransactionId) + .exec(); + }; + describe('createWallet', () => { it('creates a wallet containing identities for both orgs', async () => { const wallet = await createWallet(); @@ -110,41 +143,11 @@ describe('Fabric', () => { let mockContract: MockProxy; let mockContracts: Map; - const mockTransactionId = - '0ae62c01e4c4b112c3f3954a2f11243da76778e46df9ad2783bcbafc79652b95'; - const mockKey = `txn:${mockTransactionId}`; - const mockMspId = 'Org1MSP'; - const mockState = Buffer.from( - `{"name":"CreateAsset","nonce":"damqinq8nrI4n4qY8lFVsZw7RwG2ufrv","transactionId":${mockTransactionId}` - ); - const mockArgs = '["test111","red",400,"Jean",101]'; - const mockTimestamp = 1628078044362; - const flushPromises = () => { jest.useRealTimers(); return new Promise((resolve) => setImmediate(resolve)); }; - const addMockTransationDetails = async (redis: Redis) => { - await redis - .multi() - .hset( - mockKey, - 'mspId', - mockMspId, - 'state', - mockState, - 'args', - mockArgs, - 'timestamp', - mockTimestamp, - 'retries', - '0' - ) - .zadd('index:txn:timestamp', mockTimestamp, mockTransactionId) - .exec(); - }; - beforeEach(() => { const redisOptions = { port: config.redisPort, @@ -485,6 +488,63 @@ describe('Fabric', () => { }); }); + describe('blockEventHandler', () => { + let redis: Redis; + let mockIsValidGetter: jest.Mock; + let mockTransactionIdGetter: jest.Mock; + let mockTransactionEvent: MockProxy; + let mockBlockEvent: MockProxy; + + beforeEach(async () => { + const redisOptions = { + port: config.redisPort, + host: config.redisHost, + username: config.redisUsername, + password: config.redisPassword, + }; + + redis = new IORedis(redisOptions) as unknown as Redis; + addMockTransationDetails(redis); + + const baseMock = {}; + mockTransactionEvent = mock(baseMock); + mockIsValidGetter = jest.fn(); + Object.defineProperty(baseMock, 'isValid', { get: mockIsValidGetter }); + mockTransactionIdGetter = jest.fn(); + Object.defineProperty(baseMock, 'transactionId', { + get: mockTransactionIdGetter, + }); + + mockBlockEvent = mock(); + mockBlockEvent.getTransactionEvents.mockReturnValue([ + mockTransactionEvent, + ]); + }); + + it('clears saved details for valid transactions', async () => { + const blockListener = blockEventHandler(redis); + mockIsValidGetter.mockReturnValue(true); + mockTransactionIdGetter.mockReturnValue(mockTransactionId); + + await blockListener(mockBlockEvent); + + const index = await redis.zrange('index:txn:timestamp', 0, -1); + expect(index).toStrictEqual([]); + }); + + it('does not clear saved details for invalid transactions', async () => { + const blockListener = blockEventHandler(redis); + mockIsValidGetter.mockReturnValue(false); + + await blockListener(mockBlockEvent); + + const index = await redis.zrange('index:txn:timestamp', 0, -1); + expect(index).toStrictEqual([ + '0ae62c01e4c4b112c3f3954a2f11243da76778e46df9ad2783bcbafc79652b95', + ]); + }); + }); + describe('getBlockHeight', () => { it('gets the current block height', async () => { const mockBlockchainInfoProto = diff --git a/asset-transfer-basic/rest-api-typescript/src/fabric.ts b/asset-transfer-basic/rest-api-typescript/src/fabric.ts index ea81b386..1e0844d2 100644 --- a/asset-transfer-basic/rest-api-typescript/src/fabric.ts +++ b/asset-transfer-basic/rest-api-typescript/src/fabric.ts @@ -344,23 +344,35 @@ const isDuplicateTransactionError = (error: { return false; }; +/* + * Block event listener to handle successful transactions + * + * Transaction details are saved before being submitted so that + * they can be retried, and this listener deletes those transaction + * details for any successful transactions + * + * Transactions can be submitted using one of two identities + * however one one of those identities is used to listen for + * block events + */ export const blockEventHandler = (redis: Redis): BlockListener => { - const blockListner = async (event: BlockEvent) => { - logger.debug('Block event received '); - const transEvents: Array = event.getTransactionEvents(); + const blockListener = async (event: BlockEvent) => { + logger.debug( + { blockNumber: event.blockNumber.toString() }, + 'Block event received' + ); + const transactionEvents: Array = + event.getTransactionEvents(); - for (const transEvent of transEvents) { - if (transEvent && transEvent.isValid) { - logger.debug( - 'Remove transation with txnId %s', - transEvent.transactionId - ); - await clearTransactionDetails(redis, transEvent.transactionId); + for (const event of transactionEvents) { + if (event && event.isValid) { + logger.debug('Remove transation with txnId %s', event.transactionId); + await clearTransactionDetails(redis, event.transactionId); } } }; - return blockListner; + return blockListener; }; export const getBlockHeight = async ( diff --git a/asset-transfer-basic/rest-api-typescript/src/index.ts b/asset-transfer-basic/rest-api-typescript/src/index.ts index b8b13d69..98f91ebc 100644 --- a/asset-transfer-basic/rest-api-typescript/src/index.ts +++ b/asset-transfer-basic/rest-api-typescript/src/index.ts @@ -2,22 +2,13 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { Network } from 'fabric-network'; -import { Redis } from 'ioredis'; import * as config from './config'; -import { blockEventHandler } from './fabric'; import { logger } from './logger'; import { createServer } from './server'; async function main() { const app = await createServer(); - // TODO block listener currently only handles a single org!!! - // TODO should it be initialised here? - const redis = app.get('redis') as Redis; - const network = app.get('networkOrg1') as Network; - await network.addBlockListener(blockEventHandler(redis)); - app.listen(config.port, () => { logger.info('Express server started on port: %d', config.port); }); diff --git a/asset-transfer-basic/rest-api-typescript/src/server.ts b/asset-transfer-basic/rest-api-typescript/src/server.ts index e9e57e96..e1f360aa 100644 --- a/asset-transfer-basic/rest-api-typescript/src/server.ts +++ b/asset-transfer-basic/rest-api-typescript/src/server.ts @@ -18,6 +18,7 @@ import { createGateway, createWallet, startRetryLoop, + blockEventHandler, } from './fabric'; import { redis } from './redis'; import * as config from './config'; @@ -81,9 +82,6 @@ export const createServer = async (): Promise => { const contractsOrg1 = await getContracts(networkOrg1); app.set(config.mspIdOrg1, contractsOrg1); - // TODO used for block listener, which needs fixing! - app.set('networkOrg1', networkOrg1); - const gatewayOrg2 = await createGateway( config.connectionProfileOrg2, config.mspIdOrg2, @@ -100,6 +98,13 @@ export const createServer = async (): Promise => { app.set('redis', redis); + logger.debug('Adding block listener to %s network', config.blockListenerOrg); + if (config.blockListenerOrg === config.ORG1) { + await networkOrg1.addBlockListener(blockEventHandler(redis)); + } else { + await networkOrg2.addBlockListener(blockEventHandler(redis)); + } + app.use('/', healthRouter); app.use('/api/assets', authenticateApiKey, assetsRouter); app.use('/api/transactions', authenticateApiKey, transactionsRouter); From fd269237d4debbee8428808d4bbae870a201db3c Mon Sep 17 00:00:00 2001 From: James Taylor Date: Mon, 13 Sep 2021 17:02:30 +0100 Subject: [PATCH 44/59] Add comments to fabric.ts Signed-off-by: James Taylor --- .../rest-api-typescript/src/fabric.ts | 50 ++++++++++++++++--- 1 file changed, 44 insertions(+), 6 deletions(-) diff --git a/asset-transfer-basic/rest-api-typescript/src/fabric.ts b/asset-transfer-basic/rest-api-typescript/src/fabric.ts index 1e0844d2..acb81d5c 100644 --- a/asset-transfer-basic/rest-api-typescript/src/fabric.ts +++ b/asset-transfer-basic/rest-api-typescript/src/fabric.ts @@ -104,11 +104,22 @@ export const createGateway = async ( return gateway; }; +/* + * Get the network which the asset transfer sample chaincode is running on + * + * In addion to getting the contract, the network will also be used to + * start a block event listener + */ export const getNetwork = async (gateway: Gateway): Promise => { const network = await gateway.getNetwork(config.channelName); return network; }; +/* + * Get the asset transfer sample contract and the qscc system contract + * + * The system contract is used for the liveness REST endpoint + */ export const getContracts = async ( network: Network ): Promise<{ assetContract: Contract; qsccContract: Contract }> => { @@ -117,6 +128,13 @@ export const getContracts = async ( return { assetContract, qsccContract }; }; +/* + * Starts a timer to retry transactions at regular intervals + * + * Note: there is check for whether the transaction has successfully completed + * since it could succeed between any check and the retry, so the additional + * transaction to get the status is unlikely to be worthwhile + */ export const startRetryLoop = ( contracts: Map, redis: Redis @@ -162,6 +180,9 @@ export const startRetryLoop = ( retryInterval.unref(); }; +/* + * Evaluate a transaction and handle any errors + */ export const evatuateTransaction = async ( contract: Contract, transactionName: string, @@ -182,6 +203,12 @@ export const evatuateTransaction = async ( } }; +/* + * Submit a transaction and handle any errors + * + * Transaction details are saved before being submitted so that they can be + * retried if any errors occur + */ export const submitTransaction = async ( contract: Contract, redis: Redis, @@ -277,6 +304,12 @@ const handleError = (transactionId: string, err: Error): Error => { return new TransactionError('Transaction error', transactionId); }; +/* + * Retry a transaction + * + * The saved transaction details include a retry count which is used to ensure + * failing transactions are not retried indefinitely + */ const retryTransaction = async ( contract: Contract, redis: Redis, @@ -347,13 +380,12 @@ const isDuplicateTransactionError = (error: { /* * Block event listener to handle successful transactions * - * Transaction details are saved before being submitted so that - * they can be retried, and this listener deletes those transaction - * details for any successful transactions + * Transaction details are saved before being submitted so that they can be + * retried, and this listener deletes those transaction details for any + * successful transactions * - * Transactions can be submitted using one of two identities - * however one one of those identities is used to listen for - * block events + * Transactions can be submitted using one of two identities however one one + * of those identities is used to listen for block events */ export const blockEventHandler = (redis: Redis): BlockListener => { const blockListener = async (event: BlockEvent) => { @@ -375,6 +407,12 @@ export const blockEventHandler = (redis: Redis): BlockListener => { return blockListener; }; +/* + * Get the current block height + * + * This example of using a system contract is used for the liveness REST + * endpoint + */ export const getBlockHeight = async ( qscc: Contract ): Promise => { From 0456a9e94c24ce987afb2dbfc74da08d71b679ac Mon Sep 17 00:00:00 2001 From: James Taylor Date: Tue, 14 Sep 2021 17:07:15 +0100 Subject: [PATCH 45/59] Move error related functions to errors.ts Signed-off-by: James Taylor --- .../rest-api-typescript/src/errors.spec.ts | 102 ++++++++++++++++++ .../rest-api-typescript/src/errors.ts | 90 ++++++++++++++++ .../rest-api-typescript/src/fabric.spec.ts | 42 +++----- .../rest-api-typescript/src/fabric.ts | 88 +-------------- 4 files changed, 211 insertions(+), 111 deletions(-) create mode 100644 asset-transfer-basic/rest-api-typescript/src/errors.spec.ts diff --git a/asset-transfer-basic/rest-api-typescript/src/errors.spec.ts b/asset-transfer-basic/rest-api-typescript/src/errors.spec.ts new file mode 100644 index 00000000..a0813c90 --- /dev/null +++ b/asset-transfer-basic/rest-api-typescript/src/errors.spec.ts @@ -0,0 +1,102 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + AssetExistsError, + AssetNotFoundError, + TransactionError, + TransactionNotFoundError, + handleError, + isDuplicateTransactionError, +} from './errors'; + +describe('Errors', () => { + describe('isDuplicateTransactionError', () => { + it('returns true for an error with duplicate transaction endorsement details', () => { + const mockDuplicateTransactionError = { + errors: [ + { + endorsements: [ + { + details: 'duplicate transaction found', + }, + ], + }, + ], + }; + + expect(isDuplicateTransactionError(mockDuplicateTransactionError)).toBe( + true + ); + }); + + it('returns false for an error without duplicate transaction endorsement details', () => { + const mockDuplicateTransactionError = { + errors: [ + { + endorsements: [ + { + details: 'mock endorsement details', + }, + ], + }, + ], + }; + + expect(isDuplicateTransactionError(mockDuplicateTransactionError)).toBe( + false + ); + }); + }); + + describe('handleError', () => { + it.each([ + 'the asset GOCHAINCODE already exists', + 'Asset JAVACHAINCODE already exists', + 'The asset JSCHAINCODE already exists', + ])( + 'returns an AssetExistsError for errors with an asset already exists message: %s', + (msg) => { + expect(handleError('txn1', new Error(msg))).toStrictEqual( + new AssetExistsError(msg, 'txn1') + ); + } + ); + + it.each([ + 'the asset GOCHAINCODE does not exist', + 'Asset JAVACHAINCODE does not exist', + 'The asset JSCHAINCODE does not exist', + ])( + 'returns an AssetNotFoundError for errors with an asset does not exist message: %s', + (msg) => { + expect(handleError('txn1', new Error(msg))).toStrictEqual( + new AssetNotFoundError(msg, 'txn1') + ); + } + ); + + it('returns a TransactionNotFoundError for errors with a transaction not found message', () => { + expect( + handleError( + 'txn1', + new Error( + 'Failed to get transaction with id txn, error Entry not found in index' + ) + ) + ).toStrictEqual( + new TransactionNotFoundError( + 'Failed to get transaction with id txn, error Entry not found in index', + 'txn1' + ) + ); + }); + + it('returns a TransactionError for errors with other messages', () => { + expect(handleError('txn1', new Error('MOCK ERROR'))).toStrictEqual( + new TransactionError('Transaction error', 'txn1') + ); + }); + }); +}); diff --git a/asset-transfer-basic/rest-api-typescript/src/errors.ts b/asset-transfer-basic/rest-api-typescript/src/errors.ts index 21692ac1..5a1fde2e 100644 --- a/asset-transfer-basic/rest-api-typescript/src/errors.ts +++ b/asset-transfer-basic/rest-api-typescript/src/errors.ts @@ -2,6 +2,8 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { logger } from './logger'; + export class TransactionError extends Error { transactionId: string; @@ -43,3 +45,91 @@ export class AssetNotFoundError extends TransactionError { this.name = 'AssetNotFoundError'; } } + +/* + * Checks whether an error was caused by a duplicate transaction. + * + * Checking error strings like this is not ideal, unfortunately it appears to + * be the only option. In this case it would be better to check for the + * DUPLICATE_TXID TxValidationCode somehow but that does not seem to be + * possible. + */ +export const isDuplicateTransactionError = (error: { + errors: { endorsements: { details: string }[] }[]; +}): boolean => { + try { + const isDuplicateTxn = error?.errors?.some((err) => + err?.endorsements?.some((endorsement) => + endorsement?.details?.startsWith('duplicate transaction found') + ) + ); + + return isDuplicateTxn; + } catch (err) { + logger.warn(err, 'Error checking for duplicate transaction'); + } + + return false; +}; + +/* + * Handles errors from evaluating and submitting transactions. + * + * As with duplicate transaction errors, checking error strings like this is + * not ideal. Unfortunately the chaincode samples do not use error codes so + * again it's the only option. The error message text is not even the same for + * the Go, Java, and Javascript implementations of the chaincode! + */ +export const handleError = (transactionId: string, err: Error): Error => { + // This regex needs to match the following error messages: + // "the asset %s already exists" + // "The asset ${id} already exists" + // "Asset %s already exists" + const assetAlreadyExistsRegex = /([tT]he )?[aA]sset \w* already exists/g; + const assetAlreadyExistsMatch = err.message.match(assetAlreadyExistsRegex); + logger.debug( + { message: err.message, result: assetAlreadyExistsMatch }, + 'Checking for asset already exists message' + ); + if (assetAlreadyExistsMatch) { + return new AssetExistsError(assetAlreadyExistsMatch[0], transactionId); + } + + // This regex needs to match the following error messages: + // "the asset %s does not exist" + // "The asset ${id} does not exist" + // "Asset %s does not exist" + const assetDoesNotExistRegex = /([tT]he )?[aA]sset \w* does not exist/g; + const assetDoesNotExistMatch = err.message.match(assetDoesNotExistRegex); + logger.debug( + { message: err.message, result: assetDoesNotExistMatch }, + 'Checking for asset does not exist message' + ); + if (assetDoesNotExistMatch) { + return new AssetNotFoundError(assetDoesNotExistMatch[0], transactionId); + } + + // This regex needs to match the following error messages: + // "Failed to get transaction with id %s, error Entry not found in index" + const transactionDoesNotExistRegex = + /Failed to get transaction with id [^,]*, error Entry not found in index/g; + const transactionDoesNotExistMatch = err.message.match( + transactionDoesNotExistRegex + ); + logger.debug( + { message: err.message, result: transactionDoesNotExistMatch }, + 'Checking for transaction does not exist message' + ); + if (transactionDoesNotExistMatch) { + return new TransactionNotFoundError( + transactionDoesNotExistMatch[0], + transactionId + ); + } + + logger.error( + { transactionId: transactionId, error: err }, + 'Unhandled transaction error' + ); + return new TransactionError('Transaction error', transactionId); +}; diff --git a/asset-transfer-basic/rest-api-typescript/src/fabric.spec.ts b/asset-transfer-basic/rest-api-typescript/src/fabric.spec.ts index f871c395..a551dac9 100644 --- a/asset-transfer-basic/rest-api-typescript/src/fabric.spec.ts +++ b/asset-transfer-basic/rest-api-typescript/src/fabric.spec.ts @@ -320,35 +320,25 @@ describe('Fabric', () => { expect(result.toString()).toBe(mockPayload.toString()); }); - it.each([ - 'the asset GOCHAINCODE already exists', - 'Asset JAVACHAINCODE already exists', - 'The asset JSCHAINCODE already exists', - ])( - 'throws an AssetExistsError an asset already exists error occurs: %s', - async (msg) => { - mockTransaction.evaluate.mockRejectedValue(new Error(msg)); + it('throws an AssetExistsError an asset already exists error occurs', async () => { + mockTransaction.evaluate.mockRejectedValue( + new Error('The asset JSCHAINCODE already exists') + ); - await expect(async () => { - await evatuateTransaction(mockContract, 'txn', 'arga', 'argb'); - }).rejects.toThrow(AssetExistsError); - } - ); + await expect(async () => { + await evatuateTransaction(mockContract, 'txn', 'arga', 'argb'); + }).rejects.toThrow(AssetExistsError); + }); - it.each([ - 'the asset GOCHAINCODE does not exist', - 'Asset JAVACHAINCODE does not exist', - 'The asset JSCHAINCODE does not exist', - ])( - 'throws an AssetNotFoundError if an asset does not exist error occurs: %s', - async (msg) => { - mockTransaction.evaluate.mockRejectedValue(new Error(msg)); + it('throws an AssetNotFoundError if an asset does not exist error occurs', async () => { + mockTransaction.evaluate.mockRejectedValue( + new Error('The asset JSCHAINCODE does not exist') + ); - await expect(async () => { - await evatuateTransaction(mockContract, 'txn', 'arga', 'argb'); - }).rejects.toThrow(AssetNotFoundError); - } - ); + await expect(async () => { + await evatuateTransaction(mockContract, 'txn', 'arga', 'argb'); + }).rejects.toThrow(AssetNotFoundError); + }); it('throws a TransactionNotFoundError if a transaction not found error occurs', async () => { mockTransaction.evaluate.mockRejectedValue( diff --git a/asset-transfer-basic/rest-api-typescript/src/fabric.ts b/asset-transfer-basic/rest-api-typescript/src/fabric.ts index acb81d5c..aa8586ea 100644 --- a/asset-transfer-basic/rest-api-typescript/src/fabric.ts +++ b/asset-transfer-basic/rest-api-typescript/src/fabric.ts @@ -25,12 +25,7 @@ import { incrementRetryCount, TransactionDetails, } from './redis'; -import { - AssetExistsError, - AssetNotFoundError, - TransactionError, - TransactionNotFoundError, -} from './errors'; +import { handleError, isDuplicateTransactionError } from './errors'; import protos from 'fabric-protos'; /* @@ -247,63 +242,6 @@ export const submitTransaction = async ( return txnId; }; -// Unfortunately the chaincode samples do not use error codes, and the error -// message text is not the same for each implementation -// TODO move to errors.ts? -const handleError = (transactionId: string, err: Error): Error => { - // This regex needs to match the following error messages: - // "the asset %s already exists" - // "The asset ${id} already exists" - // "Asset %s already exists" - const assetAlreadyExistsRegex = /([tT]he )?[aA]sset \w* already exists/g; - const assetAlreadyExistsMatch = err.message.match(assetAlreadyExistsRegex); - logger.debug( - { message: err.message, result: assetAlreadyExistsMatch }, - 'Checking for asset already exists message' - ); - if (assetAlreadyExistsMatch) { - return new AssetExistsError(assetAlreadyExistsMatch[0], transactionId); - } - - // This regex needs to match the following error messages: - // "the asset %s does not exist" - // "The asset ${id} does not exist" - // "Asset %s does not exist" - const assetDoesNotExistRegex = /([tT]he )?[aA]sset \w* does not exist/g; - const assetDoesNotExistMatch = err.message.match(assetDoesNotExistRegex); - logger.debug( - { message: err.message, result: assetDoesNotExistMatch }, - 'Checking for asset does not exist message' - ); - if (assetDoesNotExistMatch) { - return new AssetNotFoundError(assetDoesNotExistMatch[0], transactionId); - } - - // This regex needs to match the following error messages: - // "Failed to get transaction with id %s, error Entry not found in index" - const transactionDoesNotExistRegex = - /Failed to get transaction with id [^,]*, error Entry not found in index/g; - const transactionDoesNotExistMatch = err.message.match( - transactionDoesNotExistRegex - ); - logger.debug( - { message: err.message, result: transactionDoesNotExistMatch }, - 'Checking for transaction does not exist message' - ); - if (transactionDoesNotExistMatch) { - return new TransactionNotFoundError( - transactionDoesNotExistMatch[0], - transactionId - ); - } - - logger.error( - { transactionId: transactionId, error: err }, - 'Unhandled transaction error' - ); - return new TransactionError('Transaction error', transactionId); -}; - /* * Retry a transaction * @@ -357,26 +295,6 @@ const retryTransaction = async ( } }; -// TODO move to errors.ts? -const isDuplicateTransactionError = (error: { - errors: { endorsements: { details: string }[] }[]; -}) => { - // TODO this is horrible! Isn't it possible to check for TxValidationCode DUPLICATE_TXID somehow? - try { - const isDuplicateTxn = error?.errors?.some((err) => - err?.endorsements?.some((endorsement) => - endorsement?.details?.startsWith('duplicate transaction found') - ) - ); - - return isDuplicateTxn; - } catch (err) { - logger.warn(err, 'Error checking for duplicate transaction'); - } - - return false; -}; - /* * Block event listener to handle successful transactions * @@ -409,9 +327,9 @@ export const blockEventHandler = (redis: Redis): BlockListener => { /* * Get the current block height - * + * * This example of using a system contract is used for the liveness REST - * endpoint + * endpoint */ export const getBlockHeight = async ( qscc: Contract From 211523eb9faf292372cb29c0c874d488ed0fc4b5 Mon Sep 17 00:00:00 2001 From: Josh Kneubuhl Date: Thu, 30 Sep 2021 10:18:16 -0400 Subject: [PATCH 46/59] Set up a GitHub action to publish the image to ghcr.io/hyperledgendary/fabric-rest-sample Signed-off-by: Josh Kneubuhl --- .github/workflows/publish.yaml | 54 ++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 .github/workflows/publish.yaml diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml new file mode 100644 index 00000000..0068013a --- /dev/null +++ b/.github/workflows/publish.yaml @@ -0,0 +1,54 @@ +name: fabric-rest-sample + +on: + push: + # Publish `main` as Docker `latest` image. + branches: + - main + + # Publish `v1.2.3` tags as releases. + tags: + - v* + + # Run tests for any PRs. + pull_request: + +env: + IMAGE_NAME: fabric-rest-sample + SOURCE_FOLDER: asset-transfer-basic/rest-api-typescript + +jobs: + # Push image to GitHub Packages. + # See also https://docs.docker.com/docker-hub/builds/ + push: + runs-on: ubuntu-latest + permissions: + packages: write + contents: read + + steps: + - uses: actions/checkout@v2 + + - name: Build image + run: docker build $SOURCE_FOLDER --file Dockerfile --tag $IMAGE_NAME --label "runnumber=${GITHUB_RUN_ID}" + + - name: Log in to registry + # This is where you will update the PAT to GITHUB_TOKEN + run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin + + - name: Push image + run: | + IMAGE_ID=ghcr.io/${{ github.repository_owner }}/$IMAGE_NAME + + # Change all uppercase to lowercase + IMAGE_ID=$(echo $IMAGE_ID | tr '[A-Z]' '[a-z]') + # Strip git ref prefix from version + VERSION=$(echo "${{ github.ref }}" | sed -e 's,.*/\(.*\),\1,') + # Strip "v" prefix from tag name + [[ "${{ github.ref }}" == "refs/tags/"* ]] && VERSION=$(echo $VERSION | sed -e 's/^v//') + # Use Docker `latest` tag convention + [ "$VERSION" == "main" ] && VERSION=latest + echo IMAGE_ID=$IMAGE_ID + echo VERSION=$VERSION + docker tag $IMAGE_NAME $IMAGE_ID:$VERSION + docker push $IMAGE_ID:$VERSION \ No newline at end of file From 3e49e92703fa3cb48163e59db6f9d1e6b05ca739 Mon Sep 17 00:00:00 2001 From: Josh Kneubuhl Date: Thu, 30 Sep 2021 10:21:02 -0400 Subject: [PATCH 47/59] wrong Dockerfile path - try again Signed-off-by: Josh Kneubuhl --- .github/workflows/publish.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index 0068013a..d24714b2 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -30,7 +30,7 @@ jobs: - uses: actions/checkout@v2 - name: Build image - run: docker build $SOURCE_FOLDER --file Dockerfile --tag $IMAGE_NAME --label "runnumber=${GITHUB_RUN_ID}" + run: docker build --tag $IMAGE_NAME --label "runnumber=${GITHUB_RUN_ID}" $SOURCE_FOLDER - name: Log in to registry # This is where you will update the PAT to GITHUB_TOKEN From ad3fd7e832f9dd349ec2fd2bcdfc29e74a6296e1 Mon Sep 17 00:00:00 2001 From: James Taylor Date: Wed, 22 Sep 2021 19:27:38 +0100 Subject: [PATCH 48/59] Update retry logic Previously transactions were only retried after being successfully endorsed, and always with the same transaction ID Transactions will now be added to a queue for processing and will also be retried if endorsement fails (with a different transaction id for invalid transactions) Signed-off-by: James Taylor --- .../rest-api-typescript/README.md | 57 ++- .../rest-api-typescript/demo.http | 5 + .../rest-api-typescript/package-lock.json | 130 ++++- .../rest-api-typescript/package.json | 5 +- .../src/__mocks__/fabric-network.ts | 178 ------- .../src/__tests__/api.test.ts | 298 +++++++---- .../rest-api-typescript/src/assets.router.ts | 131 ++--- .../rest-api-typescript/src/config.spec.ts | 168 +++++-- .../rest-api-typescript/src/config.ts | 70 ++- .../rest-api-typescript/src/errors.spec.ts | 143 +++++- .../rest-api-typescript/src/errors.ts | 213 +++++--- .../rest-api-typescript/src/fabric.spec.ts | 472 +++++++----------- .../rest-api-typescript/src/fabric.ts | 342 ++++++------- .../rest-api-typescript/src/health.router.ts | 23 +- .../rest-api-typescript/src/index.ts | 82 ++- .../rest-api-typescript/src/jobs.router.ts | 41 ++ .../rest-api-typescript/src/jobs.spec.ts | 155 ++++++ .../rest-api-typescript/src/jobs.ts | 216 ++++++++ .../rest-api-typescript/src/redis.spec.ts | 188 +------ .../rest-api-typescript/src/redis.ts | 208 ++------ .../rest-api-typescript/src/server.ts | 68 +-- .../src/transactions.router.ts | 85 +--- 22 files changed, 1826 insertions(+), 1452 deletions(-) delete mode 100644 asset-transfer-basic/rest-api-typescript/src/__mocks__/fabric-network.ts create mode 100644 asset-transfer-basic/rest-api-typescript/src/jobs.router.ts create mode 100644 asset-transfer-basic/rest-api-typescript/src/jobs.spec.ts create mode 100644 asset-transfer-basic/rest-api-typescript/src/jobs.ts diff --git a/asset-transfer-basic/rest-api-typescript/README.md b/asset-transfer-basic/rest-api-typescript/README.md index ea61b775..b5191620 100644 --- a/asset-transfer-basic/rest-api-typescript/README.md +++ b/asset-transfer-basic/rest-api-typescript/README.md @@ -8,6 +8,21 @@ The REST API is intended to work with the [basic asset transfer example](https:/ To install the basic asset transfer chaincode on a local Fabric network, follow the [Using the Fabric test network](https://hyperledger-fabric.readthedocs.io/en/release-2.2/test_network.html) tutorial +## Overview + +The sample creates two long lived connections to a Fabric network in order to submit and evaluate transactions using two different identities + +To ensure requests respond quickly enough to avoid timeouts, all submit transactions are queued for processing and will be retried if they fail + +Submit transactions are retried if they fail with any error, except for errors from the smart contract, or duplicate transaction errors + +Alternatively you might prefer to modify the sample to only retry transactions which fail with specific errors instead, for example: +- MVCC_READ_CONFLICT +- PHANTOM_READ_CONFLICT +- ENDORSEMENT_POLICY_FAILURE +- CHAINCODE_VERSION_CONFLICT +- EXPIRED_CHAINCODE + ## Usage **Note:** these instructions should work with the release-2.2 branch of `fabric-samples` but later versions require some changes @@ -70,7 +85,7 @@ docker-compose up -d ## REST API -If everything went well, you can now make basic asset transfer REST calls! +If everything went well, you can now open a new terminal and try out some basic asset transfer REST calls! The examples below require a `SAMPLE_APIKEY` environment variable which must be set to an API key from the `.env` file created above. @@ -86,6 +101,12 @@ SAMPLE_APIKEY=$(grep ORG1_APIKEY .env | cut -d '=' -f 2-) curl --header "X-Api-Key: ${SAMPLE_APIKEY}" http://localhost:3000/api/assets ``` +You should see all the available assets, for example + +``` +[{"AppraisedValue":300,"Color":"blue","ID":"asset1","Owner":"Tomoko","Size":5},{"AppraisedValue":400,"Color":"red","ID":"asset2","Owner":"Brad","Size":5},{"AppraisedValue":500,"Color":"green","ID":"asset3","Owner":"Jin Soo","Size":10},{"AppraisedValue":600,"Color":"yellow","ID":"asset4","Owner":"Max","Size":10},{"AppraisedValue":700,"Color":"black","ID":"asset5","Owner":"Adriana","Size":15},{"AppraisedValue":800,"Color":"white","ID":"asset6","Owner":"Michel","Size":15}] +``` + ### Check whether an asset exists... ```shell @@ -98,18 +119,52 @@ curl --include --header "X-Api-Key: ${SAMPLE_APIKEY}" --request OPTIONS http://l curl --include --header "Content-Type: application/json" --header "X-Api-Key: ${SAMPLE_APIKEY}" --request POST --data '{"id":"asset7","color":"red","size":42,"owner":"Jean","appraisedValue":101}' http://localhost:3000/api/assets ``` +The response should include a `jobId` which you can use to check the job status in next step + +``` +{"status":"Accepted","jobId":"1","timestamp":"2021-10-22T16:27:09.426Z"} +``` + +### Read job status... + +```shell +curl --header "X-Api-Key: ${SAMPLE_APIKEY}" http://localhost:3000/api/jobs/__job_id__ +``` + +The response should include a list of `transactionIds` which you can use to check the transaction status in next step, for example + +``` +{"jobId":"1","transactionIds":["1dd35c2e5d840fec1dccc6e8cfce886c660c103de3e7b93dd774d04f39eef82a"],"transactionPayload":""} +``` + +There may be more transaction IDs if the job was retried + ### Read transaction status... ```shell curl --header "X-Api-Key: ${SAMPLE_APIKEY}" http://localhost:3000/api/transactions/__transaction_id__ ``` +The response will show the validation code of the transaction, for example + +``` +{"transactionId":"1dd35c2e5d840fec1dccc6e8cfce886c660c103de3e7b93dd774d04f39eef82a","validationCode":"VALID"} +``` + +Alternatively, you will get a 404 not found response if the transaction was not committed + ### Read an asset... ```shell curl --header "X-Api-Key: ${SAMPLE_APIKEY}" http://localhost:3000/api/assets/asset7 ``` +You should see the newly created asset, for example + +``` +{"AppraisedValue":101,"Color":"red","ID":"asset7","Owner":"Jean","Size":42} +``` + ### Update an asset... ```shell diff --git a/asset-transfer-basic/rest-api-typescript/demo.http b/asset-transfer-basic/rest-api-typescript/demo.http index 163c7632..13ebfe99 100644 --- a/asset-transfer-basic/rest-api-typescript/demo.http +++ b/asset-transfer-basic/rest-api-typescript/demo.http @@ -40,6 +40,11 @@ X-Api-Key: {{api-key}} "appraisedValue": 101 } +### Read job status + +GET {{apiUrl}}/jobs/__job_id__ HTTP/1.1 +X-Api-Key: {{api-key}} + ### Read transaction status GET {{apiUrl}}/transactions/__transaction_id__ HTTP/1.1 diff --git a/asset-transfer-basic/rest-api-typescript/package-lock.json b/asset-transfer-basic/rest-api-typescript/package-lock.json index e7e35369..c56206e9 100644 --- a/asset-transfer-basic/rest-api-typescript/package-lock.json +++ b/asset-transfer-basic/rest-api-typescript/package-lock.json @@ -1955,6 +1955,67 @@ "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz", "integrity": "sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A==" }, + "bullmq": { + "version": "1.47.2", + "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-1.47.2.tgz", + "integrity": "sha512-IMzWjXdw6B5RSqPyEiOvoA0efjfTFx2DuB1N+z3T2wYcOVLIcIFybbFjhqVn9Sv/Zb5l6TpuFiU52P+C+/DpNA==", + "requires": { + "@types/ioredis": "^4.27.0", + "cron-parser": "^2.7.3", + "get-port": "^5.0.0", + "ioredis": "^4.27.8", + "lodash": "^4.17.21", + "semver": "^6.3.0", + "tslib": "^1.10.0", + "uuid": "^8.3.2" + }, + "dependencies": { + "@types/ioredis": { + "version": "4.27.4", + "resolved": "https://registry.npmjs.org/@types/ioredis/-/ioredis-4.27.4.tgz", + "integrity": "sha512-uTAA/woL//GxXQI1e9FuUoDZCpP8yn5LXQdea1IEFyLtb8GP2w3HfOE+SqglF6QSAp/3cZLWzrMhHqWSYI3bfg==", + "requires": { + "@types/node": "*" + } + }, + "debug": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.2.tgz", + "integrity": "sha512-mOp8wKcvj7XxC78zLgw/ZA+6TSgkoE2C/ienthhRD298T7UNwAg9diBpLRxC0mOezLl4B0xV7M0cCO6P/O0Xhw==", + "requires": { + "ms": "2.1.2" + } + }, + "ioredis": { + "version": "4.27.9", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-4.27.9.tgz", + "integrity": "sha512-hAwrx9F+OQ0uIvaJefuS3UTqW+ByOLyLIV+j0EH8ClNVxvFyH9Vmb08hCL4yje6mDYT5zMquShhypkd50RRzkg==", + "requires": { + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.1", + "denque": "^1.1.0", + "lodash.defaults": "^4.2.0", + "lodash.flatten": "^4.4.0", + "lodash.isarguments": "^3.1.0", + "p-map": "^2.1.0", + "redis-commands": "1.7.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==" + } + } + }, "bytes": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.0.tgz", @@ -2179,6 +2240,15 @@ "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", "dev": true }, + "cron-parser": { + "version": "2.18.0", + "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-2.18.0.tgz", + "integrity": "sha512-s4odpheTyydAbTBQepsqd2rNWGa2iV3cyo8g7zbI2QQYGLVsfbhmwukayS1XHppe02Oy1fg7mg6xoaraVJeEcg==", + "requires": { + "is-nan": "^1.3.0", + "moment-timezone": "^0.5.31" + } + }, "cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -2267,6 +2337,14 @@ "integrity": "sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==", "dev": true }, + "define-properties": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.3.tgz", + "integrity": "sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ==", + "requires": { + "object-keys": "^1.0.12" + } + }, "delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -3105,6 +3183,11 @@ "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", "dev": true }, + "get-port": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/get-port/-/get-port-5.1.1.tgz", + "integrity": "sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ==" + }, "get-stream": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", @@ -3360,15 +3443,16 @@ "integrity": "sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA==" }, "ioredis": { - "version": "4.27.6", - "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-4.27.6.tgz", - "integrity": "sha512-6W3ZHMbpCa8ByMyC1LJGOi7P2WiOKP9B3resoZOVLDhi+6dDBOW+KNsRq3yI36Hmnb2sifCxHX+YSarTeXh48A==", + "version": "4.27.9", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-4.27.9.tgz", + "integrity": "sha512-hAwrx9F+OQ0uIvaJefuS3UTqW+ByOLyLIV+j0EH8ClNVxvFyH9Vmb08hCL4yje6mDYT5zMquShhypkd50RRzkg==", "requires": { "cluster-key-slot": "^1.1.0", "debug": "^4.3.1", "denque": "^1.1.0", "lodash.defaults": "^4.2.0", "lodash.flatten": "^4.4.0", + "lodash.isarguments": "^3.1.0", "p-map": "^2.1.0", "redis-commands": "1.7.0", "redis-errors": "^1.2.0", @@ -3452,6 +3536,15 @@ "is-extglob": "^2.1.1" } }, + "is-nan": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/is-nan/-/is-nan-1.3.2.tgz", + "integrity": "sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w==", + "requires": { + "call-bind": "^1.0.0", + "define-properties": "^1.1.3" + } + }, "is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -4997,6 +5090,11 @@ "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", "integrity": "sha1-8xwiIlqWMtK7+OSt2+8kCqdlph8=" }, + "lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha1-L1c9hcaiQon/AGY7SRwdM4/zRYo=" + }, "lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -5147,6 +5245,19 @@ "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", "dev": true }, + "moment": { + "version": "2.29.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.1.tgz", + "integrity": "sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ==" + }, + "moment-timezone": { + "version": "0.5.33", + "resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.33.tgz", + "integrity": "sha512-PTc2vcT8K9J5/9rDEPe5czSIKgLoGsH8UNpA4qZTVw0Vd/Uz19geE9abbIOQKaAQFcnQ3v5YEXrbSc5BpshH+w==", + "requires": { + "moment": ">= 2.9.0" + } + }, "mri": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/mri/-/mri-1.1.4.tgz", @@ -5252,6 +5363,11 @@ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.10.3.tgz", "integrity": "sha512-e5mCJlSH7poANfC8z8S9s9S2IN5/4Zb3aZ33f5s8YqoazCFzNLloLU8r5VCG+G7WoqLvAAZoVMcy3tp/3X0Plw==" }, + "object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==" + }, "on-finished": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", @@ -6378,8 +6494,7 @@ "tslib": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "dev": true + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" }, "tsutils": { "version": "3.21.0", @@ -6473,6 +6588,11 @@ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", "integrity": "sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=" }, + "uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==" + }, "v8-compile-cache": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz", diff --git a/asset-transfer-basic/rest-api-typescript/package.json b/asset-transfer-basic/rest-api-typescript/package.json index 815f9e19..6a9317ff 100644 --- a/asset-transfer-basic/rest-api-typescript/package.json +++ b/asset-transfer-basic/rest-api-typescript/package.json @@ -4,6 +4,7 @@ "description": "Asset Transfer Basic REST API implemented in TypeScript", "main": "dist/index.js", "dependencies": { + "bullmq": "^1.47.2", "dotenv": "^10.0.0", "env-var": "^7.0.1", "express": "^4.17.1", @@ -11,7 +12,7 @@ "fabric-network": "^2.2.8", "helmet": "^4.6.0", "http-status-codes": "^2.1.4", - "ioredis": "^4.27.6", + "ioredis": "^4.27.8", "passport": "^0.4.1", "passport-headerapikey": "^1.2.2", "pino": "^6.11.3", @@ -53,7 +54,7 @@ "start": "node --require source-map-support/register ./dist", "start:dotenv": "node --require source-map-support/register --require dotenv/config ./dist", "start:dev": "node --require source-map-support/register --require dotenv/config ./dist | pino-pretty", - "start:redis": "docker run -p 6379:6379 --name fabric-sample-redis -d redis", + "start:redis": "docker run -p 6379:6379 --name fabric-sample-redis -d redis --maxmemory-policy noeviction", "test": "jest" }, "author": "Hyperledger", diff --git a/asset-transfer-basic/rest-api-typescript/src/__mocks__/fabric-network.ts b/asset-transfer-basic/rest-api-typescript/src/__mocks__/fabric-network.ts deleted file mode 100644 index 4f58ae24..00000000 --- a/asset-transfer-basic/rest-api-typescript/src/__mocks__/fabric-network.ts +++ /dev/null @@ -1,178 +0,0 @@ -/* - * SPDX-License-Identifier: Apache-2.0 - */ - -import { mock } from 'jest-mock-extended'; -import { Contract, Network, Transaction } from 'fabric-network'; -import { mocked } from 'ts-jest/utils'; -import * as fabricProtos from 'fabric-protos'; - -const actualFabricNetwork = jest.requireActual('fabric-network'); -const Wallet = actualFabricNetwork.Wallet; -const Wallets = actualFabricNetwork.Wallets; - -const mockAsset1 = { - ID: 'asset1', - Color: 'blue', - Size: 5, - Owner: 'Tomoko', - AppraisedValue: 300, -}; -const mockAsset1Buffer = Buffer.from(JSON.stringify(mockAsset1)); - -const mockAsset2 = { - ID: 'asset2', - Color: 'red', - Size: 5, - Owner: 'Brad', - AppraisedValue: 400, -}; - -const mockAllAssetsBuffer = Buffer.from( - JSON.stringify([mockAsset1, mockAsset2]) -); - -const mockBlockchainInfoProto = fabricProtos.common.BlockchainInfo.create(); -mockBlockchainInfoProto.height = 42; -const mockBlockchainInfoBuffer = Buffer.from( - fabricProtos.common.BlockchainInfo.encode(mockBlockchainInfoProto).finish() -); - -const processedTransactionProto = - fabricProtos.protos.ProcessedTransaction.create(); -processedTransactionProto.validationCode = - fabricProtos.protos.TxValidationCode.VALID; -const processedTransactionBuffer = Buffer.from( - fabricProtos.protos.ProcessedTransaction.encode( - processedTransactionProto - ).finish() -); - -type FabricNetworkModule = jest.Mocked; - -const { - DefaultEventHandlerStrategies, - DefaultQueryHandlerStrategies, - Gateway, -}: FabricNetworkModule = jest.createMockFromModule('fabric-network'); - -const mockAssetExistsTransaction = mock(); -mockAssetExistsTransaction.evaluate - .calledWith('asset1') - .mockResolvedValue(Buffer.from('true')); -mockAssetExistsTransaction.evaluate - .calledWith('asset3') - .mockResolvedValue(Buffer.from('false')); - -const mockReadAssetTransaction = mock(); -mockReadAssetTransaction.evaluate - .calledWith('asset1') - .mockResolvedValue(mockAsset1Buffer); -mockReadAssetTransaction.evaluate - .calledWith('asset3') - .mockRejectedValue(new Error('the asset asset3 does not exist')); - -const mockCreateAssetTransaction = mock(); -mockCreateAssetTransaction.getTransactionId.mockReturnValue('txn1'); -mockCreateAssetTransaction.submit - .calledWith('asset1') - .mockRejectedValue( - new Error( - 'No valid responses from any peers. Errors:\n peer=peer0.org1.example.com:7051, status=500, message=the asset asset1 already exists\n peer=peer0.org2.example.com:9051, status=500, message=the asset asset3 already exists' - ) - ); - -// NOTE: only the second mocked GetAllAssets with return no assets -// TODO find a better alternative so that test order does not matter -const mockGetAllAssetsTransaction = mock(); -mockGetAllAssetsTransaction.evaluate - .mockResolvedValueOnce(Buffer.from('')) - .mockResolvedValueOnce(mockAllAssetsBuffer); - -const mockUpdateAssetTransaction = mock(); -mockUpdateAssetTransaction.getTransactionId.mockReturnValue('txn1'); -mockUpdateAssetTransaction.submit - .calledWith('asset3') - .mockRejectedValue( - new Error( - 'No valid responses from any peers. Errors:\n peer=peer0.org1.example.com:7051, status=500, message=the asset asset3 does not exist\n peer=peer0.org2.example.com:9051, status=500, message=the asset asset3 does not exist' - ) - ); - -const mockTransferAssetTransaction = mock(); -mockTransferAssetTransaction.getTransactionId.mockReturnValue('txn1'); -mockTransferAssetTransaction.submit - .calledWith('asset3') - .mockRejectedValue( - new Error( - 'No valid responses from any peers. Errors:\n peer=peer0.org1.example.com:7051, status=500, message=the asset asset3 does not exist\n peer=peer0.org2.example.com:9051, status=500, message=the asset asset3 does not exist' - ) - ); - -const mockDeleteAssetTransaction = mock(); -mockDeleteAssetTransaction.getTransactionId.mockReturnValue('txn1'); -mockDeleteAssetTransaction.submit - .calledWith('asset3') - .mockRejectedValue( - new Error( - 'No valid responses from any peers. Errors:\n peer=peer0.org1.example.com:7051, status=500, message=the asset asset3 does not exist\n peer=peer0.org2.example.com:9051, status=500, message=the asset asset3 does not exist' - ) - ); - -const mockBasicContract = mock(); -mockBasicContract.createTransaction - .calledWith('AssetExists') - .mockReturnValue(mockAssetExistsTransaction); -mockBasicContract.createTransaction - .calledWith('ReadAsset') - .mockReturnValue(mockReadAssetTransaction); -mockBasicContract.createTransaction - .calledWith('CreateAsset') - .mockReturnValue(mockCreateAssetTransaction); -mockBasicContract.createTransaction - .calledWith('GetAllAssets') - .mockReturnValue(mockGetAllAssetsTransaction); -mockBasicContract.createTransaction - .calledWith('UpdateAsset') - .mockReturnValue(mockUpdateAssetTransaction); -mockBasicContract.createTransaction - .calledWith('TransferAsset') - .mockReturnValue(mockTransferAssetTransaction); -mockBasicContract.createTransaction - .calledWith('DeleteAsset') - .mockReturnValue(mockDeleteAssetTransaction); - -const mockGetTransactionByIDTransaction = mock(); -mockGetTransactionByIDTransaction.evaluate - .calledWith('mychannel', 'txn2') - .mockResolvedValue(processedTransactionBuffer); -mockGetTransactionByIDTransaction.evaluate - .calledWith('mychannel', 'txn3') - .mockRejectedValue( - new Error( - 'Failed to get transaction with id txn3, error Entry not found in index' - ) - ); - -const mockSystemContract = mock(); -mockSystemContract.evaluateTransaction - .calledWith('GetChainInfo') - .mockResolvedValue(mockBlockchainInfoBuffer); -mockSystemContract.createTransaction - .calledWith('GetTransactionByID') - .mockReturnValue(mockGetTransactionByIDTransaction); - -const mockNetwork = mock(); -mockNetwork.getContract.calledWith('basic').mockReturnValue(mockBasicContract); -mockNetwork.getContract.calledWith('qscc').mockReturnValue(mockSystemContract); - -mocked(Gateway.prototype.getNetwork).mockResolvedValue(mockNetwork); - -export { - DefaultEventHandlerStrategies, - DefaultQueryHandlerStrategies, - Contract, - Gateway, - Wallet, - Wallets, -}; diff --git a/asset-transfer-basic/rest-api-typescript/src/__tests__/api.test.ts b/asset-transfer-basic/rest-api-typescript/src/__tests__/api.test.ts index 5b065426..06d29dba 100644 --- a/asset-transfer-basic/rest-api-typescript/src/__tests__/api.test.ts +++ b/asset-transfer-basic/rest-api-typescript/src/__tests__/api.test.ts @@ -2,20 +2,53 @@ * SPDX-License-Identifier: Apache-2.0 */ -jest.mock('fabric-network'); -jest.mock('ioredis', () => require('ioredis-mock/jest')); - -import { createServer } from '../server'; +import { Job, Queue } from 'bullmq'; import { Application } from 'express'; +import { Contract, Transaction } from 'fabric-network'; +import * as fabricProtos from 'fabric-protos'; +import { mock, MockProxy } from 'jest-mock-extended'; +import { mocked } from 'ts-jest/utils'; import request from 'supertest'; +import * as config from '../config'; +import { createServer } from '../server'; + +jest.mock('../config'); +jest.mock('bullmq'); + +const mockAsset1 = { + ID: 'asset1', + Color: 'blue', + Size: 5, + Owner: 'Tomoko', + AppraisedValue: 300, +}; +const mockAsset1Buffer = Buffer.from(JSON.stringify(mockAsset1)); + +const mockAsset2 = { + ID: 'asset2', + Color: 'red', + Size: 5, + Owner: 'Brad', + AppraisedValue: 400, +}; + +const mockAllAssetsBuffer = Buffer.from( + JSON.stringify([mockAsset1, mockAsset2]) +); // TODO add tests for server errors -// TODO implement 405 Method Not Allowed where appropriate and add tests describe('Asset Transfer Besic REST API', () => { let app: Application; + let mockJobQueue: MockProxy; beforeEach(async () => { app = await createServer(); + + const mockJob = mock(); + mockJob.id = '1'; + mockJobQueue = mock(); + mockJobQueue.add.mockResolvedValue(mockJob); + app.set('jobq', mockJobQueue); }); describe('/ready', () => { @@ -35,6 +68,31 @@ describe('Asset Transfer Besic REST API', () => { describe('/live', () => { it('GET should respond with 200 OK json', async () => { + const mockBlockchainInfoProto = + fabricProtos.common.BlockchainInfo.create(); + mockBlockchainInfoProto.height = 42; + const mockBlockchainInfoBuffer = Buffer.from( + fabricProtos.common.BlockchainInfo.encode( + mockBlockchainInfoProto + ).finish() + ); + + const mockOrg1QsccContract = mock(); + mockOrg1QsccContract.evaluateTransaction + .calledWith('GetChainInfo') + .mockResolvedValue(mockBlockchainInfoBuffer); + app.set(config.mspIdOrg1, { + qsccContract: mockOrg1QsccContract, + }); + + const mockOrg2QsccContract = mock(); + mockOrg2QsccContract.evaluateTransaction + .calledWith('GetChainInfo') + .mockResolvedValue(mockBlockchainInfoBuffer); + app.set(config.mspIdOrg2, { + qsccContract: mockOrg2QsccContract, + }); + const response = await request(app).get('/live'); expect(response.statusCode).toEqual(200); expect(response.header).toHaveProperty( @@ -49,6 +107,19 @@ describe('Asset Transfer Besic REST API', () => { }); describe('/api/assets', () => { + let mockGetAllAssetsTransaction: MockProxy; + + beforeEach(() => { + mockGetAllAssetsTransaction = mock(); + const mockBasicContract = mock(); + mockBasicContract.createTransaction + .calledWith('GetAllAssets') + .mockReturnValue(mockGetAllAssetsTransaction); + app.set(config.mspIdOrg1, { + assetContract: mockBasicContract, + }); + }); + it('GET should respond with 401 unauthorized json when an invalid API key is specified', async () => { const response = await request(app) .get('/api/assets') @@ -66,8 +137,8 @@ describe('Asset Transfer Besic REST API', () => { }); it('GET should respond with an empty json array when there are no assets', async () => { - // NOTE: only the first mocked GetAllAssets with return no assets - // TODO find a better alternative so that test order does not matter + mockGetAllAssetsTransaction.evaluate.mockResolvedValue(Buffer.from('')); + const response = await request(app) .get('/api/assets') .set('X-Api-Key', 'ORG1MOCKAPIKEY'); @@ -80,8 +151,10 @@ describe('Asset Transfer Besic REST API', () => { }); it('GET should respond with json array of assets', async () => { - // NOTE: only the second mocked GetAllAssets with return no assets - // TODO find a better alternative so that test order does not matter + mockGetAllAssetsTransaction.evaluate.mockResolvedValue( + mockAllAssetsBuffer + ); + const response = await request(app) .get('/api/assets') .set('X-Api-Key', 'ORG1MOCKAPIKEY'); @@ -180,37 +253,34 @@ describe('Asset Transfer Besic REST API', () => { ); expect(response.body).toEqual({ status: 'Accepted', - transactionId: 'txn1', - timestamp: expect.any(String), - }); - }); - - it('POST should respond with 409 conflict json when asset already exists', async () => { - const response = await request(app) - .post('/api/assets') - .send({ - id: 'asset1', - color: 'blue', - size: 5, - owner: 'Tomoko', - appraisedValue: 300, - }) - .set('X-Api-Key', 'ORG1MOCKAPIKEY'); - expect(response.statusCode).toEqual(409); - expect(response.header).toHaveProperty( - 'content-type', - 'application/json; charset=utf-8' - ); - expect(response.body).toEqual({ - status: 'Conflict', - reason: 'ASSET_EXISTS', - message: 'the asset asset1 already exists', + jobId: '1', timestamp: expect.any(String), }); }); }); describe('/api/assets/:id', () => { + let mockAssetExistsTransaction: MockProxy; + let mockReadAssetTransaction: MockProxy; + + beforeEach(() => { + const mockBasicContract = mock(); + + mockAssetExistsTransaction = mock(); + mockBasicContract.createTransaction + .calledWith('AssetExists') + .mockReturnValue(mockAssetExistsTransaction); + + mockReadAssetTransaction = mock(); + mockBasicContract.createTransaction + .calledWith('ReadAsset') + .mockReturnValue(mockReadAssetTransaction); + + app.set(config.mspIdOrg1, { + assetContract: mockBasicContract, + }); + }); + it('OPTIONS should respond with 401 unauthorized json when an invalid API key is specified', async () => { const response = await request(app) .options('/api/assets/asset1') @@ -228,6 +298,10 @@ describe('Asset Transfer Besic REST API', () => { }); it('OPTIONS should respond with 404 not found json without the allow header when there is no asset with the specified ID', async () => { + mockAssetExistsTransaction.evaluate + .calledWith('asset3') + .mockResolvedValue(Buffer.from('false')); + const response = await request(app) .options('/api/assets/asset3') .set('X-Api-Key', 'ORG1MOCKAPIKEY'); @@ -244,6 +318,10 @@ describe('Asset Transfer Besic REST API', () => { }); it('OPTIONS should respond with 200 OK json with the allow header', async () => { + mockAssetExistsTransaction.evaluate + .calledWith('asset1') + .mockResolvedValue(Buffer.from('true')); + const response = await request(app) .options('/api/assets/asset1') .set('X-Api-Key', 'ORG1MOCKAPIKEY'); @@ -279,6 +357,10 @@ describe('Asset Transfer Besic REST API', () => { }); it('GET should respond with 404 not found json when there is no asset with the specified ID', async () => { + mockReadAssetTransaction.evaluate + .calledWith('asset3') + .mockRejectedValue(new Error('the asset asset3 does not exist')); + const response = await request(app) .get('/api/assets/asset3') .set('X-Api-Key', 'ORG1MOCKAPIKEY'); @@ -294,6 +376,10 @@ describe('Asset Transfer Besic REST API', () => { }); it('GET should respond with the asset json when the asset exists', async () => { + mockReadAssetTransaction.evaluate + .calledWith('asset1') + .mockResolvedValue(mockAsset1Buffer); + const response = await request(app) .get('/api/assets/asset1') .set('X-Api-Key', 'ORG1MOCKAPIKEY'); @@ -334,28 +420,6 @@ describe('Asset Transfer Besic REST API', () => { }); }); - it('PUT should respond with 404 not found json when there is no asset with the specified ID', async () => { - const response = await request(app) - .put('/api/assets/asset3') - .send({ - id: 'asset3', - color: 'red', - size: 5, - owner: 'Brad', - appraisedValue: 400, - }) - .set('X-Api-Key', 'ORG1MOCKAPIKEY'); - expect(response.statusCode).toEqual(404); - expect(response.header).toHaveProperty( - 'content-type', - 'application/json; charset=utf-8' - ); - expect(response.body).toEqual({ - status: 'Not Found', - timestamp: expect.any(String), - }); - }); - it('PUT should respond with 400 bad request json when IDs do not match', async () => { const response = await request(app) .put('/api/assets/asset1') @@ -429,7 +493,7 @@ describe('Asset Transfer Besic REST API', () => { ); expect(response.body).toEqual({ status: 'Accepted', - transactionId: 'txn1', + jobId: '1', timestamp: expect.any(String), }); }); @@ -451,22 +515,6 @@ describe('Asset Transfer Besic REST API', () => { }); }); - it('PATCH should respond with 404 not found json when there is no asset with the specified ID', async () => { - const response = await request(app) - .patch('/api/assets/asset3') - .send([{ op: 'replace', path: '/owner', value: 'Ashleigh' }]) - .set('X-Api-Key', 'ORG1MOCKAPIKEY'); - expect(response.statusCode).toEqual(404); - expect(response.header).toHaveProperty( - 'content-type', - 'application/json; charset=utf-8' - ); - expect(response.body).toEqual({ - status: 'Not Found', - timestamp: expect.any(String), - }); - }); - it('PATCH should respond with 400 bad request json for invalid patch op/path', async () => { const response = await request(app) .patch('/api/assets/asset1') @@ -505,7 +553,7 @@ describe('Asset Transfer Besic REST API', () => { ); expect(response.body).toEqual({ status: 'Accepted', - transactionId: 'txn1', + jobId: '1', timestamp: expect.any(String), }); }); @@ -526,9 +574,45 @@ describe('Asset Transfer Besic REST API', () => { }); }); - it('DELETE should respond with 404 not found json when there is no asset with the specified ID', async () => { + it('DELETE should respond with 202 accepted json', async () => { const response = await request(app) - .delete('/api/assets/asset3') + .delete('/api/assets/asset1') + .set('X-Api-Key', 'ORG1MOCKAPIKEY'); + expect(response.statusCode).toEqual(202); + expect(response.header).toHaveProperty( + 'content-type', + 'application/json; charset=utf-8' + ); + expect(response.body).toEqual({ + status: 'Accepted', + jobId: '1', + timestamp: expect.any(String), + }); + }); + }); + + describe('/api/jobs/:id', () => { + it('GET should respond with 401 unauthorized json when an invalid API key is specified', async () => { + const response = await request(app) + .get('/api/jobs/1') + .set('X-Api-Key', 'NOTTHERIGHTAPIKEY'); + expect(response.statusCode).toEqual(401); + expect(response.header).toHaveProperty( + 'content-type', + 'application/json; charset=utf-8' + ); + expect(response.body).toEqual({ + reason: 'NO_VALID_APIKEY', + status: 'Unauthorized', + timestamp: expect.any(String), + }); + }); + + it('GET should respond with 404 not found json when there is no job with the specified ID', async () => { + mocked(Job.fromId).mockResolvedValue(undefined); + + const response = await request(app) + .get('/api/jobs/3') .set('X-Api-Key', 'ORG1MOCKAPIKEY'); expect(response.statusCode).toEqual(404); expect(response.header).toHaveProperty( @@ -541,24 +625,49 @@ describe('Asset Transfer Besic REST API', () => { }); }); - it('DELETE should respond with 202 accepted json', async () => { + it('GET should respond with json details for the specified job ID', async () => { + const mockJob = mock(); + mockJob.id = '2'; + mockJob.data = { + transactionIds: ['txn1', 'txn2'], + }; + mockJob.returnvalue = { + transactionError: 'Mock error', + transactionPayload: Buffer.from('Mock payload'), + }; + mockJobQueue.getJob.calledWith('2').mockResolvedValue(mockJob); + const response = await request(app) - .delete('/api/assets/asset1') + .get('/api/jobs/2') .set('X-Api-Key', 'ORG1MOCKAPIKEY'); - expect(response.statusCode).toEqual(202); + expect(response.statusCode).toEqual(200); expect(response.header).toHaveProperty( 'content-type', 'application/json; charset=utf-8' ); expect(response.body).toEqual({ - status: 'Accepted', - transactionId: 'txn1', - timestamp: expect.any(String), + jobId: '2', + transactionIds: ['txn1', 'txn2'], + transactionError: 'Mock error', + transactionPayload: 'Mock payload', }); }); }); describe('/api/transactions/:id', () => { + let mockGetTransactionByIDTransaction: MockProxy; + + beforeEach(() => { + mockGetTransactionByIDTransaction = mock(); + const mockQsccContract = mock(); + mockQsccContract.createTransaction + .calledWith('GetTransactionByID') + .mockReturnValue(mockGetTransactionByIDTransaction); + app.set(config.mspIdOrg1, { + qsccContract: mockQsccContract, + }); + }); + it('GET should respond with 401 unauthorized json when an invalid API key is specified', async () => { const response = await request(app) .get('/api/transactions/txn1') @@ -576,6 +685,14 @@ describe('Asset Transfer Besic REST API', () => { }); it('GET should respond with 404 not found json when there is no transaction with the specified ID', async () => { + mockGetTransactionByIDTransaction.evaluate + .calledWith('mychannel', 'txn3') + .mockRejectedValue( + new Error( + 'Failed to get transaction with id txn3, error Entry not found in index' + ) + ); + const response = await request(app) .get('/api/transactions/txn3') .set('X-Api-Key', 'ORG1MOCKAPIKEY'); @@ -591,6 +708,19 @@ describe('Asset Transfer Besic REST API', () => { }); it('GET should respond with json details for the specified transaction ID', async () => { + const processedTransactionProto = + fabricProtos.protos.ProcessedTransaction.create(); + processedTransactionProto.validationCode = + fabricProtos.protos.TxValidationCode.VALID; + const processedTransactionBuffer = Buffer.from( + fabricProtos.protos.ProcessedTransaction.encode( + processedTransactionProto + ).finish() + ); + mockGetTransactionByIDTransaction.evaluate + .calledWith('mychannel', 'txn2') + .mockResolvedValue(processedTransactionBuffer); + const response = await request(app) .get('/api/transactions/txn2') .set('X-Api-Key', 'ORG1MOCKAPIKEY'); @@ -600,10 +730,8 @@ describe('Asset Transfer Besic REST API', () => { 'application/json; charset=utf-8' ); expect(response.body).toEqual({ - status: 'OK', - progress: 'DONE', + transactionId: 'txn2', validationCode: 'VALID', - timestamp: expect.any(String), }); }); }); diff --git a/asset-transfer-basic/rest-api-typescript/src/assets.router.ts b/asset-transfer-basic/rest-api-typescript/src/assets.router.ts index 6bac3866..5d6c96f5 100644 --- a/asset-transfer-basic/rest-api-typescript/src/assets.router.ts +++ b/asset-transfer-basic/rest-api-typescript/src/assets.router.ts @@ -1,32 +1,35 @@ /* * SPDX-License-Identifier: Apache-2.0 * - * Note: this sample is intended to work with the basic asset transfer + * This sample is intended to work with the basic asset transfer * chaincode which imposes some constraints on what is possible here. * * For example, * - There is no validation for Asset IDs * - There are no error codes from the chaincode * + * To avoid timeouts, long running tasks should be decoupled from HTTP request + * processing + * + * Submit transactions can potentially be very long running, especially if the + * transaction fails and needs to be retried one or more times + * + * To allow requests to respond quickly enough, this sample queues submit + * requests for processing asynchronously and immediately returns 202 Accepted */ import express, { Request, Response } from 'express'; import { body, validationResult } from 'express-validator'; import { Contract } from 'fabric-network'; import { getReasonPhrase, StatusCodes } from 'http-status-codes'; -import { Redis } from 'ioredis'; -import { AssetExistsError, AssetNotFoundError } from './errors'; -import { evatuateTransaction, submitTransaction } from './fabric'; +import { Queue } from 'bullmq'; +import { AssetNotFoundError } from './errors'; +import { evatuateTransaction } from './fabric'; +import { addSubmitTransactionJob } from './jobs'; import { logger } from './logger'; -const { - ACCEPTED, - BAD_REQUEST, - CONFLICT, - INTERNAL_SERVER_ERROR, - NOT_FOUND, - OK, -} = StatusCodes; +const { ACCEPTED, BAD_REQUEST, INTERNAL_SERVER_ERROR, NOT_FOUND, OK } = + StatusCodes; export const assetsRouter = express.Router(); @@ -45,7 +48,7 @@ assetsRouter.get('/', async (req: Request, res: Response) => { return res.status(OK).json(assets); } catch (err) { - logger.error(err, 'Error processing get all assets request'); + logger.error({ err }, 'Error processing get all assets request'); return res.status(INTERNAL_SERVER_ERROR).json({ status: getReasonPhrase(INTERNAL_SERVER_ERROR), timestamp: new Date().toISOString(), @@ -76,14 +79,12 @@ assetsRouter.post( } const mspId = req.user as string; - const contract = req.app.get(mspId).assetContract as Contract; - const redis = req.app.get('redis') as Redis; const assetId = req.body.id; try { - const transactionId = await submitTransaction( - contract, - redis, + const submitQueue = req.app.get('jobq') as Queue; + const jobId = await addSubmitTransactionJob( + submitQueue, mspId, 'CreateAsset', assetId, @@ -95,26 +96,16 @@ assetsRouter.post( return res.status(ACCEPTED).json({ status: getReasonPhrase(ACCEPTED), - transactionId: transactionId, + jobId: jobId, timestamp: new Date().toISOString(), }); } catch (err) { logger.error( - err, - 'Error processing create asset request for asset ID %s with transaction ID %s', - assetId, - err.transactionId + { err }, + 'Error processing create asset request for asset ID %s', + assetId ); - if (err instanceof AssetExistsError) { - return res.status(CONFLICT).json({ - status: getReasonPhrase(CONFLICT), - reason: 'ASSET_EXISTS', - message: err.message, - timestamp: new Date().toISOString(), - }); - } - return res.status(INTERNAL_SERVER_ERROR).json({ status: getReasonPhrase(INTERNAL_SERVER_ERROR), timestamp: new Date().toISOString(), @@ -152,7 +143,7 @@ assetsRouter.options('/:assetId', async (req: Request, res: Response) => { } } catch (err) { logger.error( - err, + { err }, 'Error processing asset options request for asset ID %s', assetId ); @@ -177,7 +168,7 @@ assetsRouter.get('/:assetId', async (req: Request, res: Response) => { return res.status(OK).json(asset); } catch (err) { logger.error( - err, + { err }, 'Error processing read asset request for asset ID %s', assetId ); @@ -228,14 +219,12 @@ assetsRouter.put( } const mspId = req.user as string; - const contract = req.app.get(mspId).assetContract as Contract; - const redis = req.app.get('redis') as Redis; const assetId = req.params.assetId; try { - const transactionId = await submitTransaction( - contract, - redis, + const submitQueue = req.app.get('jobq') as Queue; + const jobId = await addSubmitTransactionJob( + submitQueue, mspId, 'UpdateAsset', assetId, @@ -247,24 +236,16 @@ assetsRouter.put( return res.status(ACCEPTED).json({ status: getReasonPhrase(ACCEPTED), - transactionId: transactionId, + jobId: jobId, timestamp: new Date().toISOString(), }); } catch (err) { logger.error( - err, - 'Error processing update asset request for asset ID %s with transaction ID %s', - assetId, - err.transactionId + { err }, + 'Error processing update asset request for asset ID %s', + assetId ); - if (err instanceof AssetNotFoundError) { - return res.status(NOT_FOUND).json({ - status: getReasonPhrase(NOT_FOUND), - timestamp: new Date().toISOString(), - }); - } - return res.status(INTERNAL_SERVER_ERROR).json({ status: getReasonPhrase(INTERNAL_SERVER_ERROR), timestamp: new Date().toISOString(), @@ -299,15 +280,13 @@ assetsRouter.patch( } const mspId = req.user as string; - const contract = req.app.get(mspId).assetContract as Contract; - const redis = req.app.get('redis') as Redis; const assetId = req.params.assetId; const newOwner = req.body[0].value; try { - const transactionId = await submitTransaction( - contract, - redis, + const submitQueue = req.app.get('jobq') as Queue; + const jobId = await addSubmitTransactionJob( + submitQueue, mspId, 'TransferAsset', assetId, @@ -316,24 +295,16 @@ assetsRouter.patch( return res.status(ACCEPTED).json({ status: getReasonPhrase(ACCEPTED), - transactionId: transactionId, + jobId: jobId, timestamp: new Date().toISOString(), }); } catch (err) { logger.error( - err, - 'Error processing update asset request for asset ID %s with transaction ID %s', - req.params.assetId, - err.transactionId + { err }, + 'Error processing update asset request for asset ID %s', + req.params.assetId ); - if (err instanceof AssetNotFoundError) { - return res.status(NOT_FOUND).json({ - status: getReasonPhrase(NOT_FOUND), - timestamp: new Date().toISOString(), - }); - } - return res.status(INTERNAL_SERVER_ERROR).json({ status: getReasonPhrase(INTERNAL_SERVER_ERROR), timestamp: new Date().toISOString(), @@ -346,14 +317,12 @@ assetsRouter.delete('/:assetId', async (req: Request, res: Response) => { logger.debug(req.body, 'Delete asset request received'); const mspId = req.user as string; - const contract = req.app.get(mspId).assetContract as Contract; - const redis = req.app.get('redis') as Redis; const assetId = req.params.assetId; try { - const transactionId = await submitTransaction( - contract, - redis, + const submitQueue = req.app.get('jobq') as Queue; + const jobId = await addSubmitTransactionJob( + submitQueue, mspId, 'DeleteAsset', assetId @@ -361,24 +330,16 @@ assetsRouter.delete('/:assetId', async (req: Request, res: Response) => { return res.status(ACCEPTED).json({ status: getReasonPhrase(ACCEPTED), - transactionId: transactionId, + jobId: jobId, timestamp: new Date().toISOString(), }); } catch (err) { logger.error( - err, - 'Error processing delete asset request for asset ID %s with transaction ID %s', - assetId, - err.transactionId + { err }, + 'Error processing delete asset request for asset ID %s', + assetId ); - if (err instanceof AssetNotFoundError) { - return res.status(NOT_FOUND).json({ - status: getReasonPhrase(NOT_FOUND), - timestamp: new Date().toISOString(), - }); - } - return res.status(INTERNAL_SERVER_ERROR).json({ status: getReasonPhrase(INTERNAL_SERVER_ERROR), timestamp: new Date().toISOString(), diff --git a/asset-transfer-basic/rest-api-typescript/src/config.spec.ts b/asset-transfer-basic/rest-api-typescript/src/config.spec.ts index fdd5d91e..f095c1b5 100644 --- a/asset-transfer-basic/rest-api-typescript/src/config.spec.ts +++ b/asset-transfer-basic/rest-api-typescript/src/config.spec.ts @@ -60,46 +60,156 @@ describe('Config values', () => { }); }); - describe('retryDelay', () => { - it('defaults to "3000"', () => { + describe('submitJobBackoffType', () => { + it('defaults to "fixed"', () => { const config = require('./config'); - expect(config.retryDelay).toBe(3000); + expect(config.submitJobBackoffType).toBe('fixed'); }); - it('can be configured using the "RETRY_DELAY" environment variable', () => { - process.env.RETRY_DELAY = '9999'; + it('can be configured using the "SUBMIT_JOB_BACKOFF_TYPE" environment variable', () => { + process.env.SUBMIT_JOB_BACKOFF_TYPE = 'exponential'; const config = require('./config'); - expect(config.retryDelay).toBe(9999); + expect(config.submitJobBackoffType).toBe('exponential'); }); - it('throws an error when the "RETRY_DELAY" environment variable has an invalid number', () => { - process.env.RETRY_DELAY = 'short'; + it('throws an error when the "LOG_LEVEL" environment variable has an invalid log level', () => { + process.env.SUBMIT_JOB_BACKOFF_TYPE = 'jitter'; expect(() => { require('./config'); }).toThrow( - 'env-var: "RETRY_DELAY" should be a valid integer. An example of a valid value would be: 3000' + 'env-var: "SUBMIT_JOB_BACKOFF_TYPE" should be one of [fixed, exponential]' ); }); }); - describe('maxRetryCount', () => { - it('defaults to "5"', () => { + describe('submitJobBackoffDelay', () => { + it('defaults to "3000"', () => { const config = require('./config'); - expect(config.maxRetryCount).toBe(5); + expect(config.submitJobBackoffDelay).toBe(3000); }); - it('can be configured using the "MAX_RETRY_COUNT" environment variable', () => { - process.env.MAX_RETRY_COUNT = '9999'; + it('can be configured using the "SUBMIT_JOB_BACKOFF_DELAY" environment variable', () => { + process.env.SUBMIT_JOB_BACKOFF_DELAY = '9999'; const config = require('./config'); - expect(config.maxRetryCount).toBe(9999); + expect(config.submitJobBackoffDelay).toBe(9999); }); - it('throws an error when the "MAX_RETRY_COUNT" environment variable has an invalid number', () => { - process.env.MAX_RETRY_COUNT = 'lots'; + it('throws an error when the "SUBMIT_JOB_BACKOFF_DELAY" environment variable has an invalid number', () => { + process.env.SUBMIT_JOB_BACKOFF_DELAY = 'short'; expect(() => { require('./config'); }).toThrow( - 'env-var: "MAX_RETRY_COUNT" should be a valid integer. An example of a valid value would be: 5' + 'env-var: "SUBMIT_JOB_BACKOFF_DELAY" should be a valid integer. An example of a valid value would be: 3000' + ); + }); + }); + + describe('submitJobAttempts', () => { + it('defaults to "5"', () => { + const config = require('./config'); + expect(config.submitJobAttempts).toBe(5); + }); + + it('can be configured using the "SUBMIT_JOB_ATTEMPTS" environment variable', () => { + process.env.SUBMIT_JOB_ATTEMPTS = '9999'; + const config = require('./config'); + expect(config.submitJobAttempts).toBe(9999); + }); + + it('throws an error when the "SUBMIT_JOB_ATTEMPTS" environment variable has an invalid number', () => { + process.env.SUBMIT_JOB_ATTEMPTS = 'lots'; + expect(() => { + require('./config'); + }).toThrow( + 'env-var: "SUBMIT_JOB_ATTEMPTS" should be a valid integer. An example of a valid value would be: 5' + ); + }); + }); + + describe('submitJobConcurrency', () => { + it('defaults to "5"', () => { + const config = require('./config'); + expect(config.submitJobConcurrency).toBe(5); + }); + + it('can be configured using the "SUBMIT_JOB_CONCURRENCY" environment variable', () => { + process.env.SUBMIT_JOB_CONCURRENCY = '9999'; + const config = require('./config'); + expect(config.submitJobConcurrency).toBe(9999); + }); + + it('throws an error when the "SUBMIT_JOB_CONCURRENCY" environment variable has an invalid number', () => { + process.env.SUBMIT_JOB_CONCURRENCY = 'lots'; + expect(() => { + require('./config'); + }).toThrow( + 'env-var: "SUBMIT_JOB_CONCURRENCY" should be a valid integer. An example of a valid value would be: 5' + ); + }); + }); + + describe('maxCompletedSubmitJobs', () => { + it('defaults to "1000"', () => { + const config = require('./config'); + expect(config.maxCompletedSubmitJobs).toBe(1000); + }); + + it('can be configured using the "MAX_COMPLETED_SUBMIT_JOBS" environment variable', () => { + process.env.MAX_COMPLETED_SUBMIT_JOBS = '9999'; + const config = require('./config'); + expect(config.maxCompletedSubmitJobs).toBe(9999); + }); + + it('throws an error when the "MAX_COMPLETED_SUBMIT_JOBS" environment variable has an invalid number', () => { + process.env.MAX_COMPLETED_SUBMIT_JOBS = 'lots'; + expect(() => { + require('./config'); + }).toThrow( + 'env-var: "MAX_COMPLETED_SUBMIT_JOBS" should be a valid integer. An example of a valid value would be: 1000' + ); + }); + }); + + describe('maxFailedSubmitJobs', () => { + it('defaults to "1000"', () => { + const config = require('./config'); + expect(config.maxFailedSubmitJobs).toBe(1000); + }); + + it('can be configured using the "MAX_FAILED_SUBMIT_JOBS" environment variable', () => { + process.env.MAX_FAILED_SUBMIT_JOBS = '9999'; + const config = require('./config'); + expect(config.maxFailedSubmitJobs).toBe(9999); + }); + + it('throws an error when the "MAX_FAILED_SUBMIT_JOBS" environment variable has an invalid number', () => { + process.env.MAX_FAILED_SUBMIT_JOBS = 'lots'; + expect(() => { + require('./config'); + }).toThrow( + 'env-var: "MAX_FAILED_SUBMIT_JOBS" should be a valid integer. An example of a valid value would be: 1000' + ); + }); + }); + + describe('submitJobQueueScheduler', () => { + it('defaults to "true"', () => { + const config = require('./config'); + expect(config.submitJobQueueScheduler).toBe(true); + }); + + it('can be configured using the "SUBMIT_JOB_QUEUE_SCHEDULER" environment variable', () => { + process.env.SUBMIT_JOB_QUEUE_SCHEDULER = 'false'; + const config = require('./config'); + expect(config.submitJobQueueScheduler).toBe(false); + }); + + it('throws an error when the "SUBMIT_JOB_QUEUE_SCHEDULER" environment variable has an invalid boolean value', () => { + process.env.SUBMIT_JOB_QUEUE_SCHEDULER = '11'; + expect(() => { + require('./config'); + }).toThrow( + 'env-var: "SUBMIT_JOB_QUEUE_SCHEDULER" should be either "true", "false", "TRUE", or "FALSE". An example of a valid value would be: true' ); }); }); @@ -152,28 +262,6 @@ describe('Config values', () => { }); }); - describe('blockListenerOrg', () => { - it('defaults to "Org1"', () => { - const config = require('./config'); - expect(config.blockListenerOrg).toBe('Org1'); - }); - - it('can be configured using the "HLF_BLOCK_LISTENER_ORG" environment variable', () => { - process.env.HLF_BLOCK_LISTENER_ORG = 'Org2'; - const config = require('./config'); - expect(config.blockListenerOrg).toBe('Org2'); - }); - - it('throws an error when the "HLF_BLOCK_LISTENER_ORG" environment variable has an invalid value', () => { - process.env.HLF_BLOCK_LISTENER_ORG = 'Org3'; - expect(() => { - require('./config'); - }).toThrow( - 'env-var: "HLF_BLOCK_LISTENER_ORG" should be one of [Org1, Org2]' - ); - }); - }); - describe('channelName', () => { it('defaults to "mychannel"', () => { const config = require('./config'); diff --git a/asset-transfer-basic/rest-api-typescript/src/config.ts b/asset-transfer-basic/rest-api-typescript/src/config.ts index 47c0f909..a15c0cec 100644 --- a/asset-transfer-basic/rest-api-typescript/src/config.ts +++ b/asset-transfer-basic/rest-api-typescript/src/config.ts @@ -7,6 +7,8 @@ import * as env from 'env-var'; export const ORG1 = 'Org1'; export const ORG2 = 'Org2'; +export const JOB_QUEUE_NAME = 'submit'; + /* * Log level for the REST server */ @@ -25,23 +27,69 @@ export const port = env .asPortNumber(); /* - * The delay between each retry attempt in milliseconds + * The type of backoff to use for retrying failed submit jobs */ -export const retryDelay = env - .get('RETRY_DELAY') +export const submitJobBackoffType = env + .get('SUBMIT_JOB_BACKOFF_TYPE') + .default('fixed') + .asEnum(['fixed', 'exponential']); + +/* + * Backoff delay for retrying failed submit jobs in milliseconds + */ +export const submitJobBackoffDelay = env + .get('SUBMIT_JOB_BACKOFF_DELAY') .default('3000') .example('3000') .asIntPositive(); /* - * The maximum number of times to retry a failing transaction + * The total number of attempts to try a submit job until it completes */ -export const maxRetryCount = env - .get('MAX_RETRY_COUNT') +export const submitJobAttempts = env + .get('SUBMIT_JOB_ATTEMPTS') .default('5') .example('5') .asIntPositive(); +/* + * The maximum number of submit jobs that can be processed in parallel + */ +export const submitJobConcurrency = env + .get('SUBMIT_JOB_CONCURRENCY') + .default('5') + .example('5') + .asIntPositive(); + +/* + * The number of completed submit jobs to keep + */ +export const maxCompletedSubmitJobs = env + .get('MAX_COMPLETED_SUBMIT_JOBS') + .default('1000') + .example('1000') + .asIntPositive(); + +/* + * The number of failed submit jobs to keep + */ +export const maxFailedSubmitJobs = env + .get('MAX_FAILED_SUBMIT_JOBS') + .default('1000') + .example('1000') + .asIntPositive(); + +/* + * Whether to initialise a scheduler for the submit job queue + * There must be at least on queue scheduler to handle retries and you may want + * more than one for redundancy + */ +export const submitJobQueueScheduler = env + .get('SUBMIT_JOB_QUEUE_SCHEDULER') + .default('true') + .example('true') + .asBoolStrict(); + /* * Whether to convert discovered host addresses to be 'localhost' * This should be set to 'true' when running a docker composed fabric network on the @@ -71,14 +119,6 @@ export const mspIdOrg2 = env .example(`${ORG2}MSP`) .asString(); -/* - * The block listener org - */ -export const blockListenerOrg = env - .get('HLF_BLOCK_LISTENER_ORG') - .default(ORG1) - .asEnum([ORG1, ORG2]); - /* * Name of the channel which the basic asset sample chaincode has been installed on */ @@ -205,7 +245,7 @@ export const redisPort = env */ export const redisUsername = env .get('REDIS_USERNAME') - .example('conga') + .example('fabric') .asString(); /* diff --git a/asset-transfer-basic/rest-api-typescript/src/errors.spec.ts b/asset-transfer-basic/rest-api-typescript/src/errors.spec.ts index a0813c90..6ef490f6 100644 --- a/asset-transfer-basic/rest-api-typescript/src/errors.spec.ts +++ b/asset-transfer-basic/rest-api-typescript/src/errors.spec.ts @@ -5,15 +5,55 @@ import { AssetExistsError, AssetNotFoundError, - TransactionError, TransactionNotFoundError, handleError, isDuplicateTransactionError, + isErrorLike, } from './errors'; describe('Errors', () => { + describe('isErrorLike', () => { + it('returns false for null', () => { + expect(isErrorLike(null)).toBe(false); + }); + + it('returns false for undefined', () => { + expect(isErrorLike(undefined)).toBe(false); + }); + + it('returns false for empty object', () => { + expect(isErrorLike({})).toBe(false); + }); + + it('returns false for string', () => { + expect(isErrorLike('true')).toBe(false); + }); + + it('returns false for non-error object', () => { + expect(isErrorLike({ size: 42 })).toBe(false); + }); + + it('returns false for invalid error object', () => { + expect(isErrorLike({ name: 'MockError', message: 42 })).toBe(false); + }); + + it('returns false for error like object with invalid stack', () => { + expect( + isErrorLike({ name: 'MockError', message: 'Fail', stack: false }) + ).toBe(false); + }); + + it('returns true for error like object', () => { + expect(isErrorLike({ name: 'MockError', message: 'Fail' })).toBe(true); + }); + + it('returns true for new Error', () => { + expect(isErrorLike(new Error('Error'))).toBe(true); + }); + }); + describe('isDuplicateTransactionError', () => { - it('returns true for an error with duplicate transaction endorsement details', () => { + it('returns true for an error when all endorsement details are duplicate transaction found', () => { const mockDuplicateTransactionError = { errors: [ { @@ -21,6 +61,36 @@ describe('Errors', () => { { details: 'duplicate transaction found', }, + { + details: 'duplicate transaction found', + }, + { + details: 'duplicate transaction found', + }, + ], + }, + ], + }; + + expect(isDuplicateTransactionError(mockDuplicateTransactionError)).toBe( + true + ); + }); + + it('returns true for an error when at least one endorsement details are duplicate transaction found', () => { + const mockDuplicateTransactionError = { + errors: [ + { + endorsements: [ + { + details: 'duplicate transaction found', + }, + { + details: 'mock endorsement details', + }, + { + details: 'mock endorsement details', + }, ], }, ], @@ -39,6 +109,9 @@ describe('Errors', () => { { details: 'mock endorsement details', }, + { + details: 'mock endorsement details', + }, ], }, ], @@ -48,6 +121,38 @@ describe('Errors', () => { false ); }); + + it('returns false for an error without endorsement details', () => { + const mockDuplicateTransactionError = { + errors: [ + { + rejections: [ + { + details: 'duplicate transaction found', + }, + ], + }, + ], + }; + + expect(isDuplicateTransactionError(mockDuplicateTransactionError)).toBe( + false + ); + }); + + it('returns false for a basic Error object without endorsement details', () => { + expect( + isDuplicateTransactionError(new Error('duplicate transaction found')) + ).toBe(false); + }); + + it('returns false for an undefined error', () => { + expect(isDuplicateTransactionError(undefined)).toBe(false); + }); + + it('returns false for a null error', () => { + expect(isDuplicateTransactionError(null)).toBe(false); + }); }); describe('handleError', () => { @@ -77,25 +182,27 @@ describe('Errors', () => { } ); - it('returns a TransactionNotFoundError for errors with a transaction not found message', () => { - expect( - handleError( - 'txn1', - new Error( - 'Failed to get transaction with id txn, error Entry not found in index' - ) - ) - ).toStrictEqual( - new TransactionNotFoundError( - 'Failed to get transaction with id txn, error Entry not found in index', - 'txn1' - ) + it.each([ + 'Failed to get transaction with id txn, error Entry not found in index', + 'Failed to get transaction with id txn, error no such transaction ID [txn] in index', + ])( + 'returns a TransactionNotFoundError for errors with a transaction not found message: %s', + (msg) => { + expect(handleError('txn1', new Error(msg))).toStrictEqual( + new TransactionNotFoundError(msg, 'txn1') + ); + } + ); + + it('returns the original error for errors with other messages', () => { + expect(handleError('txn1', new Error('MOCK ERROR'))).toStrictEqual( + new Error('MOCK ERROR') ); }); - it('returns a TransactionError for errors with other messages', () => { - expect(handleError('txn1', new Error('MOCK ERROR'))).toStrictEqual( - new TransactionError('Transaction error', 'txn1') + it('returns a new Error object for errors of other types', () => { + expect(handleError('txn1', 42)).toStrictEqual( + new Error('Unhandled error: 42') ); }); }); diff --git a/asset-transfer-basic/rest-api-typescript/src/errors.ts b/asset-transfer-basic/rest-api-typescript/src/errors.ts index 5a1fde2e..89eaaa6b 100644 --- a/asset-transfer-basic/rest-api-typescript/src/errors.ts +++ b/asset-transfer-basic/rest-api-typescript/src/errors.ts @@ -4,23 +4,23 @@ import { logger } from './logger'; -export class TransactionError extends Error { +export class ContractError extends Error { transactionId: string; constructor(message: string, transactionId: string) { super(message); - Object.setPrototypeOf(this, TransactionError.prototype); + Object.setPrototypeOf(this, ContractError.prototype); this.name = 'TransactionError'; this.transactionId = transactionId; } } -export class TransactionNotFoundError extends Error { +export class TransactionNotFoundError extends ContractError { transactionId: string; constructor(message: string, transactionId: string) { - super(message); + super(message, transactionId); Object.setPrototypeOf(this, TransactionNotFoundError.prototype); this.name = 'TransactionNotFoundError'; @@ -28,7 +28,7 @@ export class TransactionNotFoundError extends Error { } } -export class AssetExistsError extends TransactionError { +export class AssetExistsError extends ContractError { constructor(message: string, transactionId: string) { super(message, transactionId); Object.setPrototypeOf(this, AssetExistsError.prototype); @@ -37,7 +37,7 @@ export class AssetExistsError extends TransactionError { } } -export class AssetNotFoundError extends TransactionError { +export class AssetNotFoundError extends ContractError { constructor(message: string, transactionId: string) { super(message, transactionId); Object.setPrototypeOf(this, AssetNotFoundError.prototype); @@ -46,6 +46,29 @@ export class AssetNotFoundError extends TransactionError { } } +export class JobNotFoundError extends Error { + jobId: string; + + constructor(message: string, jobId: string) { + super(message); + Object.setPrototypeOf(this, JobNotFoundError.prototype); + + this.name = 'JobNotFoundError'; + this.jobId = jobId; + } +} + +export const isErrorLike = (err: unknown): err is Error => { + return ( + err != undefined && + err != null && + typeof (err as Error).name === 'string' && + typeof (err as Error).message === 'string' && + ((err as Error).stack === undefined || + typeof (err as Error).stack === 'string') + ); +}; + /* * Checks whether an error was caused by a duplicate transaction. * @@ -54,19 +77,103 @@ export class AssetNotFoundError extends TransactionError { * DUPLICATE_TXID TxValidationCode somehow but that does not seem to be * possible. */ -export const isDuplicateTransactionError = (error: { - errors: { endorsements: { details: string }[] }[]; -}): boolean => { - try { - const isDuplicateTxn = error?.errors?.some((err) => - err?.endorsements?.some((endorsement) => - endorsement?.details?.startsWith('duplicate transaction found') - ) - ); +export const isDuplicateTransactionError = (err: unknown): boolean => { + if (err === undefined || err === null) return false; - return isDuplicateTxn; - } catch (err) { - logger.warn(err, 'Error checking for duplicate transaction'); + const endorsementError = err as { + errors: { endorsements: { details: string }[] }[]; + }; + + const isDuplicate = endorsementError?.errors?.some((err) => + err?.endorsements?.some((endorsement) => + endorsement?.details?.startsWith('duplicate transaction found') + ) + ); + + return isDuplicate === true; +}; + +/* + * Matches asset already exists error strings from the asset contract + * + * The regex needs to match the following error messages: + * "the asset %s already exists" + * "The asset ${id} already exists" + * "Asset %s already exists" + */ +const matchAssetAlreadyExistsMessage = (message: string): string | null => { + // + const assetAlreadyExistsRegex = /([tT]he )?[aA]sset \w* already exists/g; + const assetAlreadyExistsMatch = message.match(assetAlreadyExistsRegex); + logger.debug( + { message: message, result: assetAlreadyExistsMatch }, + 'Checking for asset already exists message' + ); + + if (assetAlreadyExistsMatch !== null) { + return assetAlreadyExistsMatch[0]; + } + + return null; +}; + +/* + * Matches asset does not exist error strings from the asset contract + * + * The regex needs to match the following error messages: + * "the asset %s does not exist" + * "The asset ${id} does not exist" + * "Asset %s does not exist" + */ +const matchAssetDoesNotExistMessage = (message: string): string | null => { + const assetDoesNotExistRegex = /([tT]he )?[aA]sset \w* does not exist/g; + const assetDoesNotExistMatch = message.match(assetDoesNotExistRegex); + logger.debug( + { message: message, result: assetDoesNotExistMatch }, + 'Checking for asset does not exist message' + ); + + if (assetDoesNotExistMatch !== null) { + return assetDoesNotExistMatch[0]; + } + + return null; +}; + +/* + * Matches transaction does not exist error strings from the contract API + * + * The regex needs to match the following error messages: + * "Failed to get transaction with id %s, error Entry not found in index" + * "Failed to get transaction with id %s, error no such transaction ID [%s] in index" + */ +const matchTransactionDoesNotExistMessage = ( + message: string +): string | null => { + const transactionDoesNotExistRegex = + /Failed to get transaction with id [^,]*, error (?:(?:Entry not found)|(?:no such transaction ID \[[^\]]*\])) in index/g; + const transactionDoesNotExistMatch = message.match( + transactionDoesNotExistRegex + ); + logger.debug( + { message: message, result: transactionDoesNotExistMatch }, + 'Checking for transaction does not exist message' + ); + + if (transactionDoesNotExistMatch !== null) { + return transactionDoesNotExistMatch[0]; + } + + return null; +}; + +export const isContractError = (err: unknown): boolean => { + if ( + err instanceof AssetExistsError || + err instanceof AssetNotFoundError || + err instanceof TransactionNotFoundError + ) { + return true; } return false; @@ -80,56 +187,32 @@ export const isDuplicateTransactionError = (error: { * again it's the only option. The error message text is not even the same for * the Go, Java, and Javascript implementations of the chaincode! */ -export const handleError = (transactionId: string, err: Error): Error => { - // This regex needs to match the following error messages: - // "the asset %s already exists" - // "The asset ${id} already exists" - // "Asset %s already exists" - const assetAlreadyExistsRegex = /([tT]he )?[aA]sset \w* already exists/g; - const assetAlreadyExistsMatch = err.message.match(assetAlreadyExistsRegex); - logger.debug( - { message: err.message, result: assetAlreadyExistsMatch }, - 'Checking for asset already exists message' - ); - if (assetAlreadyExistsMatch) { - return new AssetExistsError(assetAlreadyExistsMatch[0], transactionId); - } +export const handleError = (transactionId: string, err: unknown): Error => { + logger.debug({ transactionId: transactionId, err }, 'Processing error'); - // This regex needs to match the following error messages: - // "the asset %s does not exist" - // "The asset ${id} does not exist" - // "Asset %s does not exist" - const assetDoesNotExistRegex = /([tT]he )?[aA]sset \w* does not exist/g; - const assetDoesNotExistMatch = err.message.match(assetDoesNotExistRegex); - logger.debug( - { message: err.message, result: assetDoesNotExistMatch }, - 'Checking for asset does not exist message' - ); - if (assetDoesNotExistMatch) { - return new AssetNotFoundError(assetDoesNotExistMatch[0], transactionId); - } + if (isErrorLike(err)) { + const assetAlreadyExistsMatch = matchAssetAlreadyExistsMessage(err.message); + if (assetAlreadyExistsMatch !== null) { + return new AssetExistsError(assetAlreadyExistsMatch, transactionId); + } - // This regex needs to match the following error messages: - // "Failed to get transaction with id %s, error Entry not found in index" - const transactionDoesNotExistRegex = - /Failed to get transaction with id [^,]*, error Entry not found in index/g; - const transactionDoesNotExistMatch = err.message.match( - transactionDoesNotExistRegex - ); - logger.debug( - { message: err.message, result: transactionDoesNotExistMatch }, - 'Checking for transaction does not exist message' - ); - if (transactionDoesNotExistMatch) { - return new TransactionNotFoundError( - transactionDoesNotExistMatch[0], - transactionId + const assetDoesNotExistMatch = matchAssetDoesNotExistMessage(err.message); + if (assetDoesNotExistMatch !== null) { + return new AssetNotFoundError(assetDoesNotExistMatch, transactionId); + } + + const transactionDoesNotExistMatch = matchTransactionDoesNotExistMessage( + err.message ); + if (transactionDoesNotExistMatch !== null) { + return new TransactionNotFoundError( + transactionDoesNotExistMatch, + transactionId + ); + } + + return err; } - logger.error( - { transactionId: transactionId, error: err }, - 'Unhandled transaction error' - ); - return new TransactionError('Transaction error', transactionId); + return new Error(`Unhandled error: ${err}`); }; diff --git a/asset-transfer-basic/rest-api-typescript/src/fabric.spec.ts b/asset-transfer-basic/rest-api-typescript/src/fabric.spec.ts index a551dac9..34e6f2f0 100644 --- a/asset-transfer-basic/rest-api-typescript/src/fabric.spec.ts +++ b/asset-transfer-basic/rest-api-typescript/src/fabric.spec.ts @@ -10,69 +10,49 @@ import { evatuateTransaction, submitTransaction, getBlockHeight, - startRetryLoop, - blockEventHandler, + getTransactionValidationCode, + processSubmitTransactionJob, } from './fabric'; import * as config from './config'; import { AssetExistsError, AssetNotFoundError, - TransactionError, TransactionNotFoundError, } from './errors'; import { - BlockEvent, Contract, Gateway, GatewayOptions, Network, Transaction, - TransactionEvent, Wallet, } from 'fabric-network'; import * as fabricProtos from 'fabric-protos'; import { MockProxy, mock } from 'jest-mock-extended'; -import IORedis, { Redis } from 'ioredis'; import Long from 'long'; +import { Job } from 'bullmq'; jest.mock('./config'); +jest.mock('fabric-network', () => { + type FabricNetworkModule = jest.Mocked; + const originalModule: FabricNetworkModule = + jest.requireActual('fabric-network'); + const mockModule: FabricNetworkModule = + jest.createMockFromModule('fabric-network'); + + return { + __esModule: true, + ...mockModule, + Wallets: originalModule.Wallets, + }; +}); jest.mock('ioredis', () => require('ioredis-mock/jest')); describe('Fabric', () => { - const mockTransactionId = - '0ae62c01e4c4b112c3f3954a2f11243da76778e46df9ad2783bcbafc79652b95'; - const mockKey = `txn:${mockTransactionId}`; - const mockMspId = 'Org1MSP'; - const mockState = Buffer.from( - `{"name":"CreateAsset","nonce":"damqinq8nrI4n4qY8lFVsZw7RwG2ufrv","transactionId":${mockTransactionId}` - ); - const mockArgs = '["test111","red",400,"Jean",101]'; - const mockTimestamp = 1628078044362; - - const addMockTransationDetails = async (redis: Redis) => { - await redis - .multi() - .hset( - mockKey, - 'mspId', - mockMspId, - 'state', - mockState, - 'args', - mockArgs, - 'timestamp', - mockTimestamp, - 'retries', - '0' - ) - .zadd('index:txn:timestamp', mockTimestamp, mockTransactionId) - .exec(); - }; - describe('createWallet', () => { it('creates a wallet containing identities for both orgs', async () => { const wallet = await createWallet(); @@ -137,162 +117,130 @@ describe('Fabric', () => { }); }); - describe('startRetryLoop', () => { - let redis: Redis; + describe('processSubmitTransactionJob', () => { + const mockContracts = new Map(); + const mockPayload = Buffer.from('MOCK PAYLOAD'); + const mockSavedState = Buffer.from('MOCK SAVED STATE'); let mockTransaction: MockProxy; let mockContract: MockProxy; - let mockContracts: Map; - - const flushPromises = () => { - jest.useRealTimers(); - return new Promise((resolve) => setImmediate(resolve)); - }; + let mockJob: MockProxy; beforeEach(() => { - const redisOptions = { - port: config.redisPort, - host: config.redisHost, - username: config.redisUsername, - password: config.redisPassword, + mockTransaction = mock(); + mockTransaction.getTransactionId.mockReturnValue('mockTransactionId'); + + mockContract = mock(); + mockContract.createTransaction + .calledWith('txn') + .mockReturnValue(mockTransaction); + mockContract.deserializeTransaction + .calledWith(mockSavedState) + .mockReturnValue(mockTransaction); + mockContracts.set('mockMspid', mockContract); + + mockJob = mock(); + }); + + it('gets job result with no error or payload if no contract is available for the required mspid', async () => { + mockJob.data = { + mspid: 'missingMspid', }; - redis = new IORedis(redisOptions) as unknown as Redis; + const jobResult = await processSubmitTransactionJob( + mockContracts, + mockJob + ); - mockTransaction = mock(); + expect(jobResult).toStrictEqual({ + transactionError: undefined, + transactionPayload: undefined, + }); + }); + + it('gets a job result containing a payload if the transaction was successful first time', async () => { + mockJob.data = { + mspid: 'mockMspid', + transactionName: 'txn', + transactionArgs: ['arg1', 'arg2'], + }; mockTransaction.submit - .mockResolvedValue(Buffer.from('MOCK PAYLOAD')) - .mockName('submit'); - mockContract = mock(); - mockContract.deserializeTransaction.mockReturnValue(mockTransaction); - mockContracts = new Map(); - mockContracts.set(mockMspId, mockContract); + .calledWith('arg1', 'arg2') + .mockResolvedValue(mockPayload); - jest.useFakeTimers(); - }); - - afterEach(() => { - jest.useRealTimers(); - }); - - it('starts a retry loop which does nothing if there are no saved transaction details', async () => { - const getContractSpy = jest.spyOn(mockContracts, 'get'); - - startRetryLoop(mockContracts, redis); - jest.runOnlyPendingTimers(); - await flushPromises(); - - expect(getContractSpy).not.toBeCalled(); - }); - - it('starts a retry loop which clears the saved details after succesfully retrying a transaction', async () => { - addMockTransationDetails(redis); - - startRetryLoop(mockContracts, redis); - jest.runOnlyPendingTimers(); - await flushPromises(); - - expect(mockContract.deserializeTransaction).toBeCalledWith(mockState); - expect(mockTransaction.submit).toBeCalledWith( - 'test111', - 'red', - 400, - 'Jean', - 101 + const jobResult = await processSubmitTransactionJob( + mockContracts, + mockJob ); - const index = await redis.zrange('index:txn:timestamp', 0, -1); - expect(index).toStrictEqual([]); + expect(jobResult).toStrictEqual({ + transactionError: undefined, + transactionPayload: Buffer.from('MOCK PAYLOAD'), + }); }); - it('starts a retry loop which increments the retry count when a transaction fails', async () => { - addMockTransationDetails(redis); - mockTransaction.submit.mockRejectedValue(new Error('MOCK ERROR')); + it('gets a job result containing a payload if the transaction was successfully rerun using saved transaction state', async () => { + mockJob.data = { + mspid: 'mockMspid', + transactionName: 'txn', + transactionArgs: ['arg1', 'arg2'], + transactionState: mockSavedState, + }; + mockTransaction.submit + .calledWith('arg1', 'arg2') + .mockResolvedValue(mockPayload); - startRetryLoop(mockContracts, redis); - jest.runOnlyPendingTimers(); - await flushPromises(); - - expect(mockContract.deserializeTransaction).toBeCalledWith(mockState); - expect(mockTransaction.submit).toBeCalledWith( - 'test111', - 'red', - 400, - 'Jean', - 101 + const jobResult = await processSubmitTransactionJob( + mockContracts, + mockJob ); - const index = await redis.zrange('index:txn:timestamp', 0, -1); - expect(index).toStrictEqual([ - '0ae62c01e4c4b112c3f3954a2f11243da76778e46df9ad2783bcbafc79652b95', - ]); - - const savedTransaction = await (redis as Redis).hgetall(mockKey); - expect(savedTransaction.retries).toBe('1'); + expect(jobResult).toStrictEqual({ + transactionError: undefined, + transactionPayload: Buffer.from('MOCK PAYLOAD'), + }); }); - it('starts a retry loop which clears the saved details when a transaction fails as a duplicate', async () => { - addMockTransationDetails(redis); - const mockDuplicateTransactionError = new Error('MOCK ERROR'); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (mockDuplicateTransactionError as any).errors = [ - { - endorsements: [ - { - details: 'duplicate transaction found', - }, - ], - }, - ]; - mockTransaction.submit.mockRejectedValue(mockDuplicateTransactionError); + it('gets a job result containing an error message if the transaction fails but cannot be retried', async () => { + mockJob.data = { + mspid: 'mockMspid', + transactionName: 'txn', + transactionArgs: ['arg1', 'arg2'], + transactionState: mockSavedState, + }; + mockTransaction.submit + .calledWith('arg1', 'arg2') + .mockRejectedValue( + new Error( + 'Failed to get transaction with id txn, error Entry not found in index' + ) + ); - startRetryLoop(mockContracts, redis); - jest.runOnlyPendingTimers(); - await flushPromises(); - - expect(mockContract.deserializeTransaction).toBeCalledWith(mockState); - expect(mockTransaction.submit).toBeCalledWith( - 'test111', - 'red', - 400, - 'Jean', - 101 + const jobResult = await processSubmitTransactionJob( + mockContracts, + mockJob ); - const index = await redis.zrange('index:txn:timestamp', 0, -1); - expect(index).toStrictEqual([]); + expect(jobResult).toStrictEqual({ + transactionError: + 'TransactionNotFoundError: Failed to get transaction with id txn, error Entry not found in index', + transactionPayload: undefined, + }); }); - it('starts a retry loop which clears the saved details when a transaction fails the final attempt', async () => { - addMockTransationDetails(redis); - await (redis as Redis).hincrby(mockKey, 'retries', 5); - mockTransaction.submit.mockRejectedValue(new Error('MOCK ERROR')); + it('throws an error if the transaction fails but can be retried', async () => { + mockJob.data = { + mspid: 'mockMspid', + transactionName: 'txn', + transactionArgs: ['arg1', 'arg2'], + transactionState: mockSavedState, + }; + mockTransaction.submit + .calledWith('arg1', 'arg2') + .mockRejectedValue(new Error('MOCK ERROR')); - startRetryLoop(mockContracts, redis); - jest.runOnlyPendingTimers(); - await flushPromises(); - - expect(mockContract.deserializeTransaction).toBeCalledWith(mockState); - expect(mockTransaction.submit).toBeCalledWith( - 'test111', - 'red', - 400, - 'Jean', - 101 - ); - - const index = await redis.zrange('index:txn:timestamp', 0, -1); - expect(index).toStrictEqual([]); - }); - - it('starts a retry loop which clears the saved details when no contract exist for the org', async () => { - addMockTransationDetails(redis); - mockContracts = new Map(); - startRetryLoop(mockContracts, redis); - jest.runOnlyPendingTimers(); - await flushPromises(); - - const index = await redis.zrange('index:txn:timestamp', 0, -1); - expect(index).toStrictEqual([]); + await expect(async () => { + await processSubmitTransactionJob(mockContracts, mockJob); + }).rejects.toThrow('MOCK ERROR'); }); }); @@ -352,96 +300,65 @@ describe('Fabric', () => { }).rejects.toThrow(TransactionNotFoundError); }); - it('throws a TransactionError for other errors', async () => { + it('throws an Error for other errors', async () => { mockTransaction.evaluate.mockRejectedValue(new Error('MOCK ERROR')); - await expect(async () => { await evatuateTransaction(mockContract, 'txn', 'arga', 'argb'); - }).rejects.toThrow(TransactionError); + }).rejects.toThrow(Error); }); }); describe('submitTransaction', () => { - let redis: Redis; - const mockPayload = Buffer.from('MOCK PAYLOAD'); let mockTransaction: MockProxy; - let mockContract: MockProxy; - - beforeEach(async () => { - const redisOptions = { - port: config.redisPort, - host: config.redisHost, - username: config.redisUsername, - password: config.redisPassword, - }; - - redis = new IORedis(redisOptions) as unknown as Redis; + beforeEach(() => { mockTransaction = mock(); - mockTransaction.submit.mockResolvedValue(mockPayload); - mockTransaction.getTransactionId.mockReturnValue('MOCK TXN ID'); - mockTransaction.serialize.mockReturnValue(Buffer.from('MOCK TXN STATE')); - mockContract = mock(); - mockContract.createTransaction - .calledWith('txn') - .mockReturnValue(mockTransaction); }); - it('gets the transaction ID of the submitted transaction', async () => { + it('gets the result of submitting a transaction', async () => { + const mockPayload = Buffer.from('MOCK PAYLOAD'); + mockTransaction.submit.mockResolvedValue(mockPayload); + const result = await submitTransaction( - mockContract, - redis, - 'mspid', + mockTransaction, 'txn', 'arga', 'argb' ); - expect(result).toBe('MOCK TXN ID'); + expect(result.toString()).toBe(mockPayload.toString()); }); - it.each([ - 'the asset GOCHAINCODE already exists', - 'Asset JAVACHAINCODE already exists', - 'The asset JSCHAINCODE already exists', - ])( - 'throws an AssetExistsError an asset already exists error occurs: %s', - async (msg) => { - mockTransaction.submit.mockRejectedValue(new Error(msg)); + it('throws an AssetExistsError an asset already exists error occurs', async () => { + mockTransaction.submit.mockRejectedValue( + new Error('The asset JSCHAINCODE already exists') + ); - await expect(async () => { - await submitTransaction( - mockContract, - redis, - 'mspid', - 'txn', - 'arga', - 'argb' - ); - }).rejects.toThrow(AssetExistsError); - } - ); + await expect(async () => { + await submitTransaction( + mockTransaction, + 'mspid', + 'txn', + 'arga', + 'argb' + ); + }).rejects.toThrow(AssetExistsError); + }); - it.each([ - 'the asset GOCHAINCODE does not exist', - 'Asset JAVACHAINCODE does not exist', - 'The asset JSCHAINCODE does not exist', - ])( - 'throws an AssetNotFoundError if an asset does not exist error occurs: %s', - async (msg) => { - mockTransaction.submit.mockRejectedValue(new Error(msg)); + it('throws an AssetNotFoundError if an asset does not exist error occurs', async () => { + mockTransaction.submit.mockRejectedValue( + new Error('The asset JSCHAINCODE does not exist') + ); - await expect(async () => { - await submitTransaction( - mockContract, - redis, - 'mspid', - 'txn', - 'arga', - 'argb' - ); - }).rejects.toThrow(AssetNotFoundError); - } - ); + await expect(async () => { + await submitTransaction( + mockTransaction, + 'mspid', + 'txn', + 'arga', + 'argb' + ); + }).rejects.toThrow(AssetNotFoundError); + }); it('throws a TransactionNotFoundError if a transaction not found error occurs', async () => { mockTransaction.submit.mockRejectedValue( @@ -452,8 +369,7 @@ describe('Fabric', () => { await expect(async () => { await submitTransaction( - mockContract, - redis, + mockTransaction, 'mspid', 'txn', 'arga', @@ -462,76 +378,42 @@ describe('Fabric', () => { }).rejects.toThrow(TransactionNotFoundError); }); - it('throws a TransactionError for other errors', async () => { + it('throws an Error for other errors', async () => { mockTransaction.submit.mockRejectedValue(new Error('MOCK ERROR')); await expect(async () => { await submitTransaction( - mockContract, - redis, + mockTransaction, 'mspid', 'txn', 'arga', 'argb' ); - }).rejects.toThrow(TransactionError); + }).rejects.toThrow(Error); }); }); - describe('blockEventHandler', () => { - let redis: Redis; - let mockIsValidGetter: jest.Mock; - let mockTransactionIdGetter: jest.Mock; - let mockTransactionEvent: MockProxy; - let mockBlockEvent: MockProxy; + describe('getTransactionValidationCode', () => { + it('gets the validation code from a processed transaction', async () => { + const processedTransactionProto = + fabricProtos.protos.ProcessedTransaction.create(); + processedTransactionProto.validationCode = + fabricProtos.protos.TxValidationCode.VALID; + const processedTransactionBuffer = Buffer.from( + fabricProtos.protos.ProcessedTransaction.encode( + processedTransactionProto + ).finish() + ); - beforeEach(async () => { - const redisOptions = { - port: config.redisPort, - host: config.redisHost, - username: config.redisUsername, - password: config.redisPassword, - }; - - redis = new IORedis(redisOptions) as unknown as Redis; - addMockTransationDetails(redis); - - const baseMock = {}; - mockTransactionEvent = mock(baseMock); - mockIsValidGetter = jest.fn(); - Object.defineProperty(baseMock, 'isValid', { get: mockIsValidGetter }); - mockTransactionIdGetter = jest.fn(); - Object.defineProperty(baseMock, 'transactionId', { - get: mockTransactionIdGetter, - }); - - mockBlockEvent = mock(); - mockBlockEvent.getTransactionEvents.mockReturnValue([ - mockTransactionEvent, - ]); - }); - - it('clears saved details for valid transactions', async () => { - const blockListener = blockEventHandler(redis); - mockIsValidGetter.mockReturnValue(true); - mockTransactionIdGetter.mockReturnValue(mockTransactionId); - - await blockListener(mockBlockEvent); - - const index = await redis.zrange('index:txn:timestamp', 0, -1); - expect(index).toStrictEqual([]); - }); - - it('does not clear saved details for invalid transactions', async () => { - const blockListener = blockEventHandler(redis); - mockIsValidGetter.mockReturnValue(false); - - await blockListener(mockBlockEvent); - - const index = await redis.zrange('index:txn:timestamp', 0, -1); - expect(index).toStrictEqual([ - '0ae62c01e4c4b112c3f3954a2f11243da76778e46df9ad2783bcbafc79652b95', - ]); + const mockTransaction = mock(); + mockTransaction.evaluate.mockResolvedValue(processedTransactionBuffer); + const mockContract = mock(); + mockContract.createTransaction + .calledWith('GetTransactionByID') + .mockReturnValue(mockTransaction); + expect(await getTransactionValidationCode(mockContract, 'txn1')).toBe( + 'VALID' + ); }); }); diff --git a/asset-transfer-basic/rest-api-typescript/src/fabric.ts b/asset-transfer-basic/rest-api-typescript/src/fabric.ts index aa8586ea..70a3a6f2 100644 --- a/asset-transfer-basic/rest-api-typescript/src/fabric.ts +++ b/asset-transfer-basic/rest-api-typescript/src/fabric.ts @@ -10,23 +10,20 @@ import { GatewayOptions, Wallets, Network, - BlockListener, - BlockEvent, - TransactionEvent, + TimeoutError, + Transaction, Wallet, } from 'fabric-network'; -import { Redis } from 'ioredis'; import * as config from './config'; import { logger } from './logger'; import { - storeTransactionDetails, - getRetryTransactionDetails, - clearTransactionDetails, - incrementRetryCount, - TransactionDetails, -} from './redis'; -import { handleError, isDuplicateTransactionError } from './errors'; -import protos from 'fabric-protos'; + handleError, + isContractError, + isDuplicateTransactionError, +} from './errors'; +import * as protos from 'fabric-protos'; +import { Job } from 'bullmq'; +import { JobData, JobResult, updateJobData } from './jobs'; /* * Creates an in memory wallet to hold credentials for an Org1 and Org2 user @@ -124,55 +121,119 @@ export const getContracts = async ( }; /* - * Starts a timer to retry transactions at regular intervals + * Process a submit transaction request from the job queue * - * Note: there is check for whether the transaction has successfully completed - * since it could succeed between any check and the retry, so the additional - * transaction to get the status is unlikely to be worthwhile + * For this sample transactions are retried if they fail with any error, + * except for errors from the smart contract, or duplicate transaction + * errors + * + * You might decide to retry transactions which fail with specific errors + * instead, for example: + * MVCC_READ_CONFLICT + * PHANTOM_READ_CONFLICT + * ENDORSEMENT_POLICY_FAILURE + * CHAINCODE_VERSION_CONFLICT + * EXPIRED_CHAINCODE */ -export const startRetryLoop = ( +export const processSubmitTransactionJob = async ( contracts: Map, - redis: Redis -): void => { - const retryInterval = setInterval( - async (contracts, redis) => { - if (logger.isLevelEnabled('debug')) { - try { - const pendingTransactionCount = await (redis as Redis).zcard( - 'index:txn:timestamp' - ); - logger.debug( - '%d transactions awaiting retry', - pendingTransactionCount - ); - } catch (err) { - logger.warn({ err }, 'Error getting pending transaction count'); - } + job: Job +): Promise => { + logger.debug({ jobId: job.id, jobName: job.name }, 'Processing job'); + + const contract = contracts.get(job.data.mspid); + if (contract === undefined) { + logger.error( + { jobId: job.id, jobName: job.name }, + 'Contract not found for MSP ID %s', + job.data.mspid + ); + + // Retrying will not work, so give up with an unsuccessful result + return { + transactionError: undefined, + transactionPayload: undefined, + }; + } + + let transaction: Transaction; + if (job.data.transactionState) { + const savedState = job.data.transactionState; + logger.debug( + { + jobId: job.id, + jobName: job.name, + savedState, + }, + 'Using previously saved transaction state' + ); + + transaction = contract.deserializeTransaction(savedState); + } else { + logger.debug( + { + jobId: job.id, + jobName: job.name, + }, + 'Using new transaction' + ); + + transaction = contract.createTransaction(job.data.transactionName); + await updateJobData(job, transaction); + } + + try { + logger.debug( + { + jobId: job.id, + jobName: job.name, + transactionId: transaction.getTransactionId(), + }, + 'Submitting transaction' + ); + const args = job.data.transactionArgs; + const payload = await submitTransaction(transaction, ...args); + + return { + transactionError: undefined, + transactionPayload: payload, + }; + } catch (err) { + if ( + err instanceof Error && + (isContractError(err) || isDuplicateTransactionError(err)) + ) { + logger.error( + { jobId: job.id, jobName: job.name, err }, + 'Fatal transaction error occurred' + ); + + // Return a job result to stop retrying + return { + transactionError: err.toString(), + transactionPayload: undefined, + }; + } else { + logger.warn( + { jobId: job.id, jobName: job.name, err }, + 'Retryable transaction error occurred' + ); + + // The original transaction may eventually get committed in the case of + // a timeout error, so keep the same transaction ID to protect against + // unintended duplicate transactions + if (!(err instanceof TimeoutError)) { + logger.debug( + { jobId: job.id, jobName: job.name }, + 'Clearing saved transaction state' + ); + await updateJobData(job, undefined); } - const savedTransaction = await getRetryTransactionDetails(redis); - - if (savedTransaction) { - const contract = contracts.get(savedTransaction.mspId); - - if (contract) { - await retryTransaction(contract, redis, savedTransaction); - } else { - clearTransactionDetails(redis, savedTransaction.transactionId); - logger.error( - 'No contract found for %s to retry transaction %s', - savedTransaction.mspId, - savedTransaction.transactionId - ); - } - } - }, - config.retryDelay, - contracts, - redis - ); - - retryInterval.unref(); + // Rethrow the error to keep retrying + throw err; + } + } }; /* @@ -183,146 +244,64 @@ export const evatuateTransaction = async ( transactionName: string, ...transactionArgs: string[] ): Promise => { - const txn = contract.createTransaction(transactionName); - const txnId = txn.getTransactionId(); + const transaction = contract.createTransaction(transactionName); + const transactionId = transaction.getTransactionId(); + logger.trace({ transaction }, 'Evaluating transaction'); try { - const payload = await txn.evaluate(...transactionArgs); - logger.debug( - { transactionId: txnId, payload: payload.toString() }, + const payload = await transaction.evaluate(...transactionArgs); + logger.trace( + { transactionId: transactionId, payload: payload.toString() }, 'Evaluate transaction response received' ); return payload; + } catch (err) { + throw handleError(transactionId, err); + } +}; + +/* + * Submit a transaction and handle any errors + */ +export const submitTransaction = async ( + transaction: Transaction, + ...transactionArgs: string[] +): Promise => { + logger.trace({ transaction }, 'Submitting transaction'); + const txnId = transaction.getTransactionId(); + + try { + const payload = await transaction.submit(...transactionArgs); + logger.trace( + { transactionId: txnId, payload: payload.toString() }, + 'Submit transaction response received' + ); + return payload; } catch (err) { throw handleError(txnId, err); } }; /* - * Submit a transaction and handle any errors - * - * Transaction details are saved before being submitted so that they can be - * retried if any errors occur + * Get the validation code of the specified transaction */ -export const submitTransaction = async ( - contract: Contract, - redis: Redis, - mspId: string, - transactionName: string, - ...transactionArgs: string[] +export const getTransactionValidationCode = async ( + qsccContract: Contract, + transactionId: string ): Promise => { - const txn = contract.createTransaction(transactionName); - const txnId = txn.getTransactionId(); - const txnState = txn.serialize(); - const txnArgs = JSON.stringify(transactionArgs); - const timestamp = Date.now(); + const data = await evatuateTransaction( + qsccContract, + 'GetTransactionByID', + config.channelName, + transactionId + ); - try { - // Store the transaction details and set the event handler in case there - // are problems later with commiting the transaction - await storeTransactionDetails( - redis, - txnId, - mspId, - txnState, - txnArgs, - timestamp - ); - txn.setEventHandler(DefaultEventHandlerStrategies.NONE); - await txn.submit(...transactionArgs); - } catch (err) { - // If the transaction failed to endorse, there is no point attempting - // to retry it later so clear the transaction details - // TODO will this always catch endorsement errors or can they - // arrive later? - await clearTransactionDetails(redis, txnId); - throw handleError(txnId, err); - } + const processedTransaction = protos.protos.ProcessedTransaction.decode(data); + const validationCode = + protos.protos.TxValidationCode[processedTransaction.validationCode]; - return txnId; -}; - -/* - * Retry a transaction - * - * The saved transaction details include a retry count which is used to ensure - * failing transactions are not retried indefinitely - */ -const retryTransaction = async ( - contract: Contract, - redis: Redis, - savedTransaction: TransactionDetails -): Promise => { - logger.debug('Retrying transaction %s', savedTransaction.transactionId); - - try { - const transaction = contract.deserializeTransaction( - savedTransaction.transactionState - ); - const args: string[] = JSON.parse(savedTransaction.transactionArgs); - - const payload = await transaction.submit(...args); - logger.debug( - { - transactionId: savedTransaction.transactionId, - payload: payload.toString(), - }, - 'Retry transaction response received' - ); - - await clearTransactionDetails(redis, savedTransaction.transactionId); - } catch (err) { - if (isDuplicateTransactionError(err)) { - logger.warn( - 'Transaction %s has already been committed', - savedTransaction.transactionId - ); - await clearTransactionDetails(redis, savedTransaction.transactionId); - } else { - logger.warn( - err, - 'Retry %d failed for transaction %s', - savedTransaction.retries, - savedTransaction.transactionId - ); - - if (savedTransaction.retries < config.maxRetryCount) { - await incrementRetryCount(redis, savedTransaction.transactionId); - } else { - await clearTransactionDetails(redis, savedTransaction.transactionId); - } - } - } -}; - -/* - * Block event listener to handle successful transactions - * - * Transaction details are saved before being submitted so that they can be - * retried, and this listener deletes those transaction details for any - * successful transactions - * - * Transactions can be submitted using one of two identities however one one - * of those identities is used to listen for block events - */ -export const blockEventHandler = (redis: Redis): BlockListener => { - const blockListener = async (event: BlockEvent) => { - logger.debug( - { blockNumber: event.blockNumber.toString() }, - 'Block event received' - ); - const transactionEvents: Array = - event.getTransactionEvents(); - - for (const event of transactionEvents) { - if (event && event.isValid) { - logger.debug('Remove transation with txnId %s', event.transactionId); - await clearTransactionDetails(redis, event.transactionId); - } - } - }; - - return blockListener; + logger.debug({ transactionId }, 'Validation code: %s', validationCode); + return validationCode; }; /* @@ -340,6 +319,7 @@ export const getBlockHeight = async ( ); const info = protos.common.BlockchainInfo.decode(data); const blockHeight = info.height; + logger.debug('Current block height: %d', blockHeight); return blockHeight; }; diff --git a/asset-transfer-basic/rest-api-typescript/src/health.router.ts b/asset-transfer-basic/rest-api-typescript/src/health.router.ts index 27b40c3d..463ee0e4 100644 --- a/asset-transfer-basic/rest-api-typescript/src/health.router.ts +++ b/asset-transfer-basic/rest-api-typescript/src/health.router.ts @@ -8,6 +8,8 @@ import { getReasonPhrase, StatusCodes } from 'http-status-codes'; import { getBlockHeight } from './fabric'; import { logger } from './logger'; import * as config from './config'; +import { Queue } from 'bullmq'; +import { getJobCounts } from './jobs'; const { SERVICE_UNAVAILABLE, OK } = StatusCodes; @@ -27,21 +29,26 @@ healthRouter.get('/ready', (_req, res: Response) => healthRouter.get('/live', async (req: Request, res: Response) => { logger.debug(req.body, 'Liveness request received'); - const qsccOrg1 = req.app.get(config.mspIdOrg1).qsccContract as Contract; - const qsccOrg2 = req.app.get(config.mspIdOrg2).qsccContract as Contract; - try { - await Promise.all([getBlockHeight(qsccOrg1), getBlockHeight(qsccOrg2)]); - } catch (err) { - logger.error(err, 'Error processing liveness request'); + const submitQueue = req.app.get('jobq') as Queue; + const qsccOrg1 = req.app.get(config.mspIdOrg1).qsccContract as Contract; + const qsccOrg2 = req.app.get(config.mspIdOrg2).qsccContract as Contract; - res.status(SERVICE_UNAVAILABLE).json({ + await Promise.all([ + getBlockHeight(qsccOrg1), + getBlockHeight(qsccOrg2), + getJobCounts(submitQueue), + ]); + } catch (err) { + logger.error({ err }, 'Error processing liveness request'); + + return res.status(SERVICE_UNAVAILABLE).json({ status: getReasonPhrase(SERVICE_UNAVAILABLE), timestamp: new Date().toISOString(), }); } - res.status(OK).json({ + return res.status(OK).json({ status: getReasonPhrase(OK), timestamp: new Date().toISOString(), }); diff --git a/asset-transfer-basic/rest-api-typescript/src/index.ts b/asset-transfer-basic/rest-api-typescript/src/index.ts index 98f91ebc..567d3cbc 100644 --- a/asset-transfer-basic/rest-api-typescript/src/index.ts +++ b/asset-transfer-basic/rest-api-typescript/src/index.ts @@ -2,19 +2,93 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { Contract } from 'fabric-network'; import * as config from './config'; +import { + createGateway, + createWallet, + getContracts, + getNetwork, +} from './fabric'; +import { + initJobQueue, + initJobQueueScheduler, + initJobQueueWorker, +} from './jobs'; import { logger } from './logger'; import { createServer } from './server'; +import { isMaxmemoryPolicyNoeviction } from './redis'; +import { Queue, QueueScheduler, Worker } from 'bullmq'; + +let jobQueue: Queue | undefined; +let jobQueueWorker: Worker | undefined; +let jobQueueScheduler: QueueScheduler | undefined; async function main() { + logger.info('Checking Redis config'); + if (!(await isMaxmemoryPolicyNoeviction())) { + throw new Error( + 'Invalid redis configuration: redis instance must have the setting maxmemory-policy=noeviction' + ); + } + + logger.info('Connecting to Fabric network'); + const wallet = await createWallet(); + + const gatewayOrg1 = await createGateway( + config.connectionProfileOrg1, + config.mspIdOrg1, + wallet + ); + const networkOrg1 = await getNetwork(gatewayOrg1); + const contractsOrg1 = await getContracts(networkOrg1); + + const gatewayOrg2 = await createGateway( + config.connectionProfileOrg2, + config.mspIdOrg2, + wallet + ); + const networkOrg2 = await getNetwork(gatewayOrg2); + const contractsOrg2 = await getContracts(networkOrg2); + + const assetContracts = new Map(); + assetContracts.set(config.mspIdOrg1, contractsOrg1.assetContract); + assetContracts.set(config.mspIdOrg2, contractsOrg2.assetContract); + + logger.info('Initialising submit job queue'); + jobQueue = initJobQueue(); + jobQueueWorker = initJobQueueWorker(assetContracts); + if (config.submitJobQueueScheduler === true) { + logger.info('Initialising submit job queue scheduler'); + jobQueueScheduler = initJobQueueScheduler(); + } + + logger.info('Creating REST server'); const app = await createServer(); + app.set(config.mspIdOrg1, contractsOrg1); + app.set(config.mspIdOrg2, contractsOrg2); + app.set('jobq', jobQueue); app.listen(config.port, () => { - logger.info('Express server started on port: %d', config.port); + logger.info('REST server started on port: %d', config.port); }); } -// TODO handle errors! E.g. try starting with the wrong cert and private key! -main().catch((err) => { - logger.error(err, 'Unxepected error'); +main().catch(async (err) => { + logger.error({ err }, 'Unxepected error'); + + if (jobQueueScheduler != undefined) { + logger.debug('Closing job queue scheduler'); + await jobQueueScheduler.close(); + } + + if (jobQueueWorker != undefined) { + logger.debug('Closing job queue worker'); + await jobQueueWorker.close(); + } + + if (jobQueue != undefined) { + logger.debug('Closing job queue'); + await jobQueue.close(); + } }); diff --git a/asset-transfer-basic/rest-api-typescript/src/jobs.router.ts b/asset-transfer-basic/rest-api-typescript/src/jobs.router.ts new file mode 100644 index 00000000..097d19a7 --- /dev/null +++ b/asset-transfer-basic/rest-api-typescript/src/jobs.router.ts @@ -0,0 +1,41 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Queue } from 'bullmq'; +import express, { Request, Response } from 'express'; +import { getReasonPhrase, StatusCodes } from 'http-status-codes'; +import { JobNotFoundError } from './errors'; +import { getJobSummary } from './jobs'; +import { logger } from './logger'; + +const { INTERNAL_SERVER_ERROR, NOT_FOUND, OK } = StatusCodes; + +export const jobsRouter = express.Router(); + +jobsRouter.get('/:jobId', async (req: Request, res: Response) => { + const jobId = req.params.jobId; + logger.debug('Read request received for job ID %s', jobId); + + try { + const submitQueue = req.app.get('jobq') as Queue; + + const jobSummary = await getJobSummary(submitQueue, jobId); + + return res.status(OK).json(jobSummary); + } catch (err) { + logger.error({ err }, 'Error processing read request for job ID %s', jobId); + + if (err instanceof JobNotFoundError) { + return res.status(NOT_FOUND).json({ + status: getReasonPhrase(NOT_FOUND), + timestamp: new Date().toISOString(), + }); + } + + return res.status(INTERNAL_SERVER_ERROR).json({ + status: getReasonPhrase(INTERNAL_SERVER_ERROR), + timestamp: new Date().toISOString(), + }); + } +}); diff --git a/asset-transfer-basic/rest-api-typescript/src/jobs.spec.ts b/asset-transfer-basic/rest-api-typescript/src/jobs.spec.ts new file mode 100644 index 00000000..05fe6af0 --- /dev/null +++ b/asset-transfer-basic/rest-api-typescript/src/jobs.spec.ts @@ -0,0 +1,155 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Job, Queue } from 'bullmq'; +import { getJobCounts, getJobSummary } from './jobs'; +import { mock, MockProxy } from 'jest-mock-extended'; +import { JobNotFoundError } from './errors'; + +describe('initJobQueue', () => { + it.todo('write tests'); +}); + +describe('initJobQueueWorker', () => { + it.todo('write tests'); +}); + +describe('initJobQueueScheduler', () => { + it.todo('write tests'); +}); + +describe('addSubmitTransactionJob', () => { + it.todo('write tests'); +}); + +describe('getJobSummary', () => { + let mockQueue: MockProxy; + let mockJob: MockProxy; + + beforeEach(() => { + mockQueue = mock(); + mockJob = mock(); + }); + + it('throws a JobNotFoundError if the Job is undefined', async () => { + mockQueue.getJob.calledWith('1').mockResolvedValue(undefined); + + await expect(async () => { + await getJobSummary(mockQueue, '1'); + }).rejects.toThrow(JobNotFoundError); + }); + + it('gets a job summary with transaction payload data', async () => { + mockQueue.getJob.calledWith('1').mockResolvedValue(mockJob); + mockJob.id = '1'; + mockJob.data = { + transactionIds: ['txn1'], + }; + mockJob.returnvalue = { + transactionPayload: Buffer.from('MOCK PAYLOAD'), + }; + + expect(await getJobSummary(mockQueue, '1')).toStrictEqual({ + jobId: '1', + transactionIds: ['txn1'], + transactionError: undefined, + transactionPayload: 'MOCK PAYLOAD', + }); + }); + + it('gets a job summary with empty transaction payload data', async () => { + mockQueue.getJob.calledWith('1').mockResolvedValue(mockJob); + mockJob.id = '1'; + mockJob.data = { + transactionIds: ['txn1'], + }; + mockJob.returnvalue = { + transactionPayload: Buffer.from(''), + }; + + expect(await getJobSummary(mockQueue, '1')).toStrictEqual({ + jobId: '1', + transactionIds: ['txn1'], + transactionError: undefined, + transactionPayload: '', + }); + }); + + it('gets a job summary with a transaction error', async () => { + mockQueue.getJob.calledWith('1').mockResolvedValue(mockJob); + mockJob.id = '1'; + mockJob.data = { + transactionIds: ['txn1'], + }; + mockJob.returnvalue = { + transactionError: 'MOCK ERROR', + }; + + expect(await getJobSummary(mockQueue, '1')).toStrictEqual({ + jobId: '1', + transactionIds: ['txn1'], + transactionError: 'MOCK ERROR', + transactionPayload: '', + }); + }); + + it('gets a job summary when there is no return value', async () => { + mockQueue.getJob.calledWith('1').mockResolvedValue(mockJob); + mockJob.id = '1'; + mockJob.returnvalue = undefined; + mockJob.data = { + transactionIds: ['txn1'], + }; + + expect(await getJobSummary(mockQueue, '1')).toStrictEqual({ + jobId: '1', + transactionIds: ['txn1'], + transactionError: undefined, + transactionPayload: undefined, + }); + }); + + it('gets a job summary when there is no job data', async () => { + mockQueue.getJob.calledWith('1').mockResolvedValue(mockJob); + mockJob.id = '1'; + mockJob.data = undefined; + mockJob.returnvalue = { + transactionPayload: Buffer.from('MOCK PAYLOAD'), + }; + + expect(await getJobSummary(mockQueue, '1')).toStrictEqual({ + jobId: '1', + transactionIds: [], + transactionError: undefined, + transactionPayload: 'MOCK PAYLOAD', + }); + }); +}); + +describe('updateSubmitTransactionJobStateData', () => { + it.todo('write tests'); +}); + +describe('getJobCounts', () => { + it('gets job counts from the specified queue', async () => { + const mockQueue = mock(); + mockQueue.getJobCounts + .calledWith('active', 'completed', 'delayed', 'failed', 'waiting') + .mockResolvedValue({ + active: 1, + completed: 2, + delayed: 3, + failed: 4, + waiting: 5, + }); + + expect(await getJobCounts(mockQueue)).toStrictEqual({ + active: 1, + completed: 2, + delayed: 3, + failed: 4, + waiting: 5, + }); + }); +}); diff --git a/asset-transfer-basic/rest-api-typescript/src/jobs.ts b/asset-transfer-basic/rest-api-typescript/src/jobs.ts new file mode 100644 index 00000000..da397a2d --- /dev/null +++ b/asset-transfer-basic/rest-api-typescript/src/jobs.ts @@ -0,0 +1,216 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * This sample uses BullMQ jobs to process submit transactions, which includes + * retry support for failing jobs + * + * Important: BullMQ requires the following setting in redis + * maxmemory-policy=noeviction + * For details, see: https://docs.bullmq.io/guide/connections + */ + +import { ConnectionOptions, Job, Queue, QueueScheduler, Worker } from 'bullmq'; +import { Contract, Transaction } from 'fabric-network'; +import * as config from './config'; +import { JobNotFoundError } from './errors'; +import { processSubmitTransactionJob } from './fabric'; +import { logger } from './logger'; + +export type JobData = { + mspid: string; + transactionName: string; + transactionArgs: string[]; + transactionState?: Buffer; + transactionIds: string[]; +}; + +export type JobResult = { + transactionPayload?: Buffer; + transactionError?: string; +}; + +// TODO include attempts made? +export type JobSummary = { + jobId: string; + transactionIds: string[]; + transactionPayload?: string; + transactionError?: string; +}; + +const connection: ConnectionOptions = { + port: config.redisPort, + host: config.redisHost, + username: config.redisUsername, + password: config.redisPassword, +}; + +export const initJobQueue = (): Queue => { + const submitQueue = new Queue(config.JOB_QUEUE_NAME, { + connection, + defaultJobOptions: { + attempts: config.submitJobAttempts, + backoff: { + type: config.submitJobBackoffType, + delay: config.submitJobBackoffDelay, + }, + removeOnComplete: config.maxCompletedSubmitJobs, + removeOnFail: config.maxFailedSubmitJobs, + }, + }); + + return submitQueue; +}; + +export const initJobQueueWorker = ( + contracts: Map +): Worker => { + const worker = new Worker( + config.JOB_QUEUE_NAME, + async (job): Promise => { + return await processSubmitTransactionJob(contracts, job); + }, + { connection, concurrency: config.submitJobConcurrency } + ); + + worker.on('failed', (job) => { + logger.error({ job }, 'Job failed'); // WHY?! + }); + + // Important: need to handle this error otherwise worker may stop + // processing jobs + worker.on('error', (err) => { + logger.error({ err }, 'Worker error'); + }); + + if (logger.isLevelEnabled('debug')) { + worker.on('completed', (job) => { + logger.debug({ job }, 'Job completed'); + }); + } + + return worker; +}; + +export const initJobQueueScheduler = (): QueueScheduler => { + const queueScheduler = new QueueScheduler(config.JOB_QUEUE_NAME, { + connection, + }); + + queueScheduler.on('failed', (jobId, failedReason) => { + // TODO when does this happen, and how should it be handled? + logger.error({ jobId, failedReason }, 'Queue sceduler failure'); + }); + + return queueScheduler; +}; + +export const addSubmitTransactionJob = async ( + submitQueue: Queue, + mspid: string, + transactionName: string, + ...transactionArgs: string[] +): Promise => { + const jobName = `submit ${transactionName} transaction`; + const job = await submitQueue.add(jobName, { + mspid, + transactionName, + transactionArgs: transactionArgs, + transactionIds: [], + }); + + if (job?.id === undefined) { + throw new Error('Submit transaction job ID not available'); + } + + return job.id; +}; + +/* + * Gets a summary for the jobs endpoint + */ +export const getJobSummary = async ( + queue: Queue, + jobId: string +): Promise => { + const job: Job | undefined = await queue.getJob(jobId); + logger.debug({ job }, 'Got job'); + + if (!(job && job.id != undefined)) { + throw new JobNotFoundError(`Job ${jobId} not found`, jobId); + } + + let transactionIds: string[]; + if (job.data && job.data.transactionIds) { + transactionIds = job.data.transactionIds; + } else { + transactionIds = []; + } + + let transactionError; + let transactionPayload; + const returnValue = job.returnvalue; + if (returnValue) { + if (returnValue.transactionError) { + transactionError = returnValue.transactionError; + } + + if ( + returnValue.transactionPayload && + returnValue.transactionPayload.length > 0 + ) { + transactionPayload = returnValue.transactionPayload.toString(); + } else { + transactionPayload = ''; + } + } + + const jobSummary: JobSummary = { + jobId: job.id, + transactionIds, + transactionError, + transactionPayload, + }; + + return jobSummary; +}; + +export const updateJobData = async ( + job: Job, + transaction: Transaction | undefined +): Promise => { + const newData = { ...job.data }; + + if (transaction != undefined) { + const transationIds = ([] as string[]).concat( + newData.transactionIds, + transaction.getTransactionId() + ); + newData.transactionIds = transationIds; + + newData.transactionState = transaction.serialize(); + } else { + newData.transactionState = undefined; + } + + await job.update(newData); +}; + +/* + * Get the current job counts + * + * This function is used for the liveness REST endpoint + */ +export const getJobCounts = async ( + queue: Queue +): Promise<{ [index: string]: number }> => { + const jobCounts = await queue.getJobCounts( + 'active', + 'completed', + 'delayed', + 'failed', + 'waiting' + ); + logger.debug({ jobCounts }, 'Current job counts'); + + return jobCounts; +}; diff --git a/asset-transfer-basic/rest-api-typescript/src/redis.spec.ts b/asset-transfer-basic/rest-api-typescript/src/redis.spec.ts index 8b4f291c..a73f3b8c 100644 --- a/asset-transfer-basic/rest-api-typescript/src/redis.spec.ts +++ b/asset-transfer-basic/rest-api-typescript/src/redis.spec.ts @@ -2,183 +2,33 @@ * SPDX-License-Identifier: Apache-2.0 */ -import * as config from './config'; -import IORedis, { Redis } from 'ioredis'; -import { - clearTransactionDetails, - incrementRetryCount, - storeTransactionDetails, - getTransactionDetails, - getRetryTransactionDetails, -} from './redis'; +import { isMaxmemoryPolicyNoeviction } from './redis'; -jest.mock('ioredis', () => require('ioredis-mock/jest')); +const mockRedisConfig = jest.fn(); +jest.mock('ioredis', () => { + return jest.fn().mockImplementation(() => { + return { + config: mockRedisConfig, + disconnect: jest.fn(), + }; + }); +}); jest.mock('./config'); describe('Redis', () => { - let redis: Redis; - - const mockTransactionId = - '0ae62c01e4c4b112c3f3954a2f11243da76778e46df9ad2783bcbafc79652b95'; - const mockKey = `txn:${mockTransactionId}`; - const mockMspId = 'Org1MSP'; - const mockState = Buffer.from( - `{"name":"CreateAsset","nonce":"damqinq8nrI4n4qY8lFVsZw7RwG2ufrv","transactionId":${mockTransactionId}` - ); - const mockArgs = '["test111","red",400,"Jean",101]'; - const mockTimestamp = 1628078044362; - - const addMockTransationDetails = async (redis: Redis) => { - await redis - .multi() - .hset( - mockKey, - 'mspId', - mockMspId, - 'state', - mockState, - 'args', - mockArgs, - 'timestamp', - mockTimestamp, - 'retries', - '0' - ) - .zadd('index:txn:timestamp', mockTimestamp, mockTransactionId) - .exec(); - }; - - beforeEach(async () => { - const redisOptions = { - port: config.redisPort, - host: config.redisHost, - username: config.redisUsername, - password: config.redisPassword, - }; - - redis = new IORedis(redisOptions) as unknown as Redis; - }); - describe('storeTransactionDetails', () => { - it('stores transaction details as a hash', async () => { - await storeTransactionDetails( - redis, - mockTransactionId, - mockMspId, - mockState, - mockArgs, - mockTimestamp - ); - - const storedTransaction = await redis.hgetall(mockKey); - const expectedTransaction = { - mspId: mockMspId, - state: mockState, - args: mockArgs, - retries: '0', - timestamp: mockTimestamp.toString(), - }; - expect(storedTransaction).toStrictEqual(expectedTransaction); - }); - - it('adds the transaction ID to the sorted set timestamp index', async () => { - await storeTransactionDetails( - redis, - mockTransactionId, - mockMspId, - mockState, - mockArgs, - mockTimestamp - ); - - const index = await redis.zrange('index:txn:timestamp', 0, -1); - expect(index).toStrictEqual([mockTransactionId]); - }); - - // TODO this seems to work for spying/mocking... - // jest.spyOn(redis, 'multi').mock... - // but haven't worked out how to spy on the hset, zadd, exec in that chain - // Ask Mark? - it.todo('handles an error from redis'); + beforeEach(() => { + mockRedisConfig.mockClear(); }); - describe('getTransactionDetails', () => { - it('gets the transaction details from a hash', async () => { - await addMockTransationDetails(redis); - - const details = await getTransactionDetails(redis, mockTransactionId); - - expect(details).toStrictEqual({ - transactionId: mockTransactionId, - mspId: mockMspId, - transactionState: mockState, - transactionArgs: mockArgs, - retries: 0, - timestamp: mockTimestamp, - }); + describe('isMaxmemoryPolicyNoeviction', () => { + it('returns true when the maxmemory-policy is noeviction', async () => { + mockRedisConfig.mockReturnValue(['maxmemory-policy', 'noeviction']); + expect(await isMaxmemoryPolicyNoeviction()).toBe(true); }); - it.todo('handles an error from redis'); - }); - - describe('getRetryTransactionDetails', () => { - it('gets the oldest transaction details from a hash', async () => { - await addMockTransationDetails(redis); - - const details = await getRetryTransactionDetails(redis); - - expect(details).toStrictEqual({ - transactionId: mockTransactionId, - mspId: mockMspId, - transactionState: mockState, - transactionArgs: mockArgs, - retries: 0, - timestamp: mockTimestamp, - }); + it('returns false when the maxmemory-policy is not noeviction', async () => { + mockRedisConfig.mockReturnValue(['maxmemory-policy', 'allkeys-lru']); + expect(await isMaxmemoryPolicyNoeviction()).toBe(false); }); - - it('gets undefined if there are no transactions to retry', async () => { - const details = await getRetryTransactionDetails(redis); - - expect(details).toBeUndefined(); - }); - - it.todo('handles an error from redis'); - }); - - describe('clearTransactionDetails', () => { - it('removes the transaction details hash', async () => { - await addMockTransationDetails(redis); - - await clearTransactionDetails(redis, mockTransactionId); - - const storedTransaction = await redis.hgetall(mockKey); - expect(storedTransaction).not.toHaveProperty('state'); - }); - - it('removes the transaction ID from the sorted set timestamp index', async () => { - await addMockTransationDetails(redis); - - await clearTransactionDetails(redis, mockTransactionId); - - const index = await redis.zrange('index:txn:timestamp', 0, -1); - expect(index).toStrictEqual([]); - }); - }); - - describe('incrementRetryCount', () => { - it('increments the retries value in the transction details hash', async () => { - await addMockTransationDetails(redis); - - await incrementRetryCount(redis, mockTransactionId); - - const retries = await redis.hget(mockKey, 'retries'); - expect(retries).toBe('1'); - }); - - it.todo( - 'updates the position of the transaction ID in the sorted set timestamp index' - ); - - it.todo('handles an error from redis'); }); }); diff --git a/asset-transfer-basic/rest-api-typescript/src/redis.ts b/asset-transfer-basic/rest-api-typescript/src/redis.ts index fc89fa9b..bb4246da 100644 --- a/asset-transfer-basic/rest-api-typescript/src/redis.ts +++ b/asset-transfer-basic/rest-api-typescript/src/redis.ts @@ -1,12 +1,7 @@ /* * SPDX-License-Identifier: Apache-2.0 * - * This sample includes basic retry logic so it needs somewhere to store - * transaction details in case the app restarts for any reason, and Redis is - * just one of the options available - * - * Note: This implementation is not designed with multiple instances of the - * REST app in mind, which is likely to be required in a production environment + * TBC */ import IORedis, { Redis, RedisOptions } from 'ioredis'; @@ -14,184 +9,43 @@ import IORedis, { Redis, RedisOptions } from 'ioredis'; import * as config from './config'; import { logger } from './logger'; -const redisOptions: RedisOptions = { - port: config.redisPort, - host: config.redisHost, - username: config.redisUsername, - password: config.redisPassword, -}; - -export const redis = new IORedis(redisOptions); - -export type TransactionDetails = { - transactionId: string; - mspId: string; - transactionState: Buffer; - transactionArgs: string; - timestamp: number; - retries: number; -}; - /* - * Store enough information in order to resubmit a transaction + * Check whether the maxmemory-policy config is set to noeviction + * + * BullMQ requires this setting in redis + * For details, see: https://docs.bullmq.io/guide/connections */ -export const storeTransactionDetails = async ( - redis: Redis, - transactionId: string, - mspId: string, - transactionState: Buffer, - transactionArgs: string, - timestamp: number -): Promise => { +export const isMaxmemoryPolicyNoeviction = async (): Promise => { + let redis: Redis | undefined; + + const redisOptions: RedisOptions = { + port: config.redisPort, + host: config.redisHost, + username: config.redisUsername, + password: config.redisPassword, + }; + try { - const key = `txn:${transactionId}`; - logger.debug( - { - key, - mspId, - transactionState, - transactionArgs, - timestamp, - }, - 'Storing transaction details' - ); - await redis - .multi() - .hset( - key, - 'mspId', - mspId, - 'state', - transactionState, - 'args', - transactionArgs, - 'timestamp', - timestamp, - 'retries', - '0' - ) - .zadd('index:txn:timestamp', timestamp, transactionId) - .exec(); - } catch (err) { - // TODO just log?! - logger.error( - { err }, - 'Error storing details for transaction ID %s', - transactionId - ); - } -}; + redis = new IORedis(redisOptions); -/* - * Get the information required to resubmit a transaction - */ -export const getTransactionDetails = async ( - redis: Redis, - transactionId: string -): Promise => { - try { - const savedTransaction = await (redis as Redis).hgetall( - `txn:${transactionId}` - ); - logger.debug( - { transactionId: transactionId, state: savedTransaction }, - 'Got transaction details' + const maxmemoryPolicyConfig = await (redis as Redis).config( + 'GET', + 'maxmemory-policy' ); + logger.debug({ maxmemoryPolicyConfig }, 'Got maxmemory-policy config'); - const transactionDetails = { - transactionId: transactionId, - mspId: savedTransaction.mspId, - transactionState: Buffer.from(savedTransaction.state), - transactionArgs: savedTransaction.args, - timestamp: parseInt(savedTransaction.timestamp), - retries: parseInt(savedTransaction.retries), - }; - return transactionDetails; - } catch (err) { - // TODO just log?! - logger.error( - { err }, - 'Error getting details for transaction ID %s', - transactionId - ); - } -}; - -/* - * Get the oldest transaction details - */ -export const getRetryTransactionDetails = async ( - redis: Redis -): Promise => { - try { - const transactionIds = await (redis as Redis).zrange( - 'index:txn:timestamp', - -1, - -1 - ); - - if (transactionIds.length > 0) { - const transactionId = transactionIds[0]; - - const savedTransaction = await getTransactionDetails( - redis, - transactionId - ); - return savedTransaction; + if ( + maxmemoryPolicyConfig.length == 2 && + 'maxmemory-policy' === maxmemoryPolicyConfig[0] && + 'noeviction' === maxmemoryPolicyConfig[1] + ) { + return true; + } + } finally { + if (redis != undefined) { + redis.disconnect(); } - } catch (err) { - // TODO just log?! - logger.error( - { err }, - 'Error getting details for next transaction to retry' - ); } -}; -/* - * Delete transaction details - */ -export const clearTransactionDetails = async ( - redis: Redis, - transactionId: string -): Promise => { - const key = `txn:${transactionId}`; - logger.debug('Removing transaction details. Key: %s', key); - try { - await redis - .multi() - .del(key) - .zrem('index:txn:timestamp', transactionId) - .exec(); - } catch (err) { - // TODO just log?! - logger.error( - { err }, - 'Error remove details for transaction ID %s', - transactionId - ); - } -}; - -/* - * Increment the number of times the transaction has been retried - - * TODO needs to update the timestamp and index as well - */ -export const incrementRetryCount = async ( - redis: Redis, - transactionId: string -): Promise => { - const key = `txn:${transactionId}`; - logger.debug('Incrementing retries fortransaction Key: %s', key); - try { - await (redis as Redis).hincrby(`txn:${transactionId}`, 'retries', 1); - } catch (err) { - // TODO just log?! - logger.error( - err, - 'Error incrementing retries for transaction ID %s', - transactionId - ); - } + return false; }; diff --git a/asset-transfer-basic/rest-api-typescript/src/server.ts b/asset-transfer-basic/rest-api-typescript/src/server.ts index e1f360aa..60ceb481 100644 --- a/asset-transfer-basic/rest-api-typescript/src/server.ts +++ b/asset-transfer-basic/rest-api-typescript/src/server.ts @@ -2,30 +2,19 @@ * SPDX-License-Identifier: Apache-2.0 */ -import helmet from 'helmet'; -import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import express, { Application, NextFunction, Request, Response } from 'express'; -import pinoMiddleware from 'pino-http'; -import { Contract } from 'fabric-network'; - -import { logger } from './logger'; -import { assetsRouter } from './assets.router'; -import { healthRouter } from './health.router'; -import { transactionsRouter } from './transactions.router'; -import { - getContracts, - getNetwork, - createGateway, - createWallet, - startRetryLoop, - blockEventHandler, -} from './fabric'; -import { redis } from './redis'; -import * as config from './config'; -const { BAD_REQUEST, INTERNAL_SERVER_ERROR, NOT_FOUND } = StatusCodes; - -import { authenticateApiKey, fabricAPIKeyStrategy } from './auth'; +import helmet from 'helmet'; +import { getReasonPhrase, StatusCodes } from 'http-status-codes'; import passport from 'passport'; +import pinoMiddleware from 'pino-http'; +import { assetsRouter } from './assets.router'; +import { authenticateApiKey, fabricAPIKeyStrategy } from './auth'; +import { healthRouter } from './health.router'; +import { jobsRouter } from './jobs.router'; +import { logger } from './logger'; +import { transactionsRouter } from './transactions.router'; + +const { BAD_REQUEST, INTERNAL_SERVER_ERROR, NOT_FOUND } = StatusCodes; export const createServer = async (): Promise => { const app = express(); @@ -71,42 +60,9 @@ export const createServer = async (): Promise => { app.use(helmet()); } - const wallet = await createWallet(); - - const gatewayOrg1 = await createGateway( - config.connectionProfileOrg1, - config.mspIdOrg1, - wallet - ); - const networkOrg1 = await getNetwork(gatewayOrg1); - const contractsOrg1 = await getContracts(networkOrg1); - app.set(config.mspIdOrg1, contractsOrg1); - - const gatewayOrg2 = await createGateway( - config.connectionProfileOrg2, - config.mspIdOrg2, - wallet - ); - const networkOrg2 = await getNetwork(gatewayOrg2); - const contractsOrg2 = await getContracts(networkOrg2); - app.set(config.mspIdOrg2, contractsOrg2); - - const assetContracts = new Map(); - assetContracts.set(config.mspIdOrg1, contractsOrg1.assetContract); - assetContracts.set(config.mspIdOrg2, contractsOrg2.assetContract); - startRetryLoop(assetContracts, redis); - - app.set('redis', redis); - - logger.debug('Adding block listener to %s network', config.blockListenerOrg); - if (config.blockListenerOrg === config.ORG1) { - await networkOrg1.addBlockListener(blockEventHandler(redis)); - } else { - await networkOrg2.addBlockListener(blockEventHandler(redis)); - } - app.use('/', healthRouter); app.use('/api/assets', authenticateApiKey, assetsRouter); + app.use('/api/jobs', authenticateApiKey, jobsRouter); app.use('/api/transactions', authenticateApiKey, transactionsRouter); // For everything else diff --git a/asset-transfer-basic/rest-api-typescript/src/transactions.router.ts b/asset-transfer-basic/rest-api-typescript/src/transactions.router.ts index a91c2fc5..4b729951 100644 --- a/asset-transfer-basic/rest-api-typescript/src/transactions.router.ts +++ b/asset-transfer-basic/rest-api-typescript/src/transactions.router.ts @@ -4,81 +4,44 @@ import express, { Request, Response } from 'express'; import { Contract } from 'fabric-network'; -import { protos } from 'fabric-protos'; import { getReasonPhrase, StatusCodes } from 'http-status-codes'; -import { Redis } from 'ioredis'; -import { getTransactionDetails } from './redis'; -import { evatuateTransaction } from './fabric'; +import { getTransactionValidationCode } from './fabric'; import { logger } from './logger'; -import * as config from './config'; import { TransactionNotFoundError } from './errors'; const { INTERNAL_SERVER_ERROR, NOT_FOUND, OK } = StatusCodes; export const transactionsRouter = express.Router(); -type Progress = 'ACCEPTED' | 'RETRYING' | 'DONE'; - transactionsRouter.get( '/:transactionId', async (req: Request, res: Response) => { + const mspId = req.user as string; const transactionId = req.params.transactionId; logger.debug('Read request received for transaction ID %s', transactionId); - let foundTransaction = false; - let progress: Progress = 'DONE'; - let validationCode = ''; - - const mspId = req.user as string; - const qscc = req.app.get(mspId).qsccContract as Contract; - const redis = req.app.get('redis') as Redis; - try { - const savedTransaction = await getTransactionDetails( - redis, + const qsccContract = req.app.get(mspId).qsccContract as Contract; + + const validationCode = await getTransactionValidationCode( + qsccContract, transactionId ); - if (savedTransaction?.transactionState) { - foundTransaction = true; - if (savedTransaction.retries > 0) { - progress = 'RETRYING'; - } else { - progress = 'ACCEPTED'; - } - } - } catch (err) { - logger.error( - err, - 'Redis error processing read request for transaction ID %s', - transactionId - ); - - return res.status(INTERNAL_SERVER_ERROR).json({ - status: getReasonPhrase(INTERNAL_SERVER_ERROR), - timestamp: new Date().toISOString(), + return res.status(OK).json({ + transactionId, + validationCode, }); - } - - try { - const data = await evatuateTransaction( - qscc, - 'GetTransactionByID', - config.channelName, - transactionId - ); - - foundTransaction = true; - // TODO is it possible to use the BlockDecoder decodeTransaction - // function in fabric-common? - const processedTransaction = protos.ProcessedTransaction.decode(data); - validationCode = - protos.TxValidationCode[processedTransaction.validationCode]; } catch (err) { - if (!(err instanceof TransactionNotFoundError)) { + if (err instanceof TransactionNotFoundError) { + return res.status(NOT_FOUND).json({ + status: getReasonPhrase(NOT_FOUND), + timestamp: new Date().toISOString(), + }); + } else { logger.error( - err, - 'Fabric error processing read request for transaction ID %s', + { err }, + 'Error processing read request for transaction ID %s', transactionId ); @@ -88,19 +51,5 @@ transactionsRouter.get( }); } } - - if (foundTransaction) { - return res.status(OK).json({ - status: getReasonPhrase(OK), - progress: progress, - validationCode: validationCode, - timestamp: new Date().toISOString(), - }); - } else { - return res.status(NOT_FOUND).json({ - status: getReasonPhrase(NOT_FOUND), - timestamp: new Date().toISOString(), - }); - } } ); From b1bc6663b83dbfb1fd8138b696b5552e2836925e Mon Sep 17 00:00:00 2001 From: James Taylor Date: Fri, 3 Dec 2021 12:23:06 +0000 Subject: [PATCH 49/59] Update readme Minor changes for Fabric 2.4 and the main branch of fabric-samples Signed-off-by: James Taylor --- asset-transfer-basic/rest-api-typescript/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/asset-transfer-basic/rest-api-typescript/README.md b/asset-transfer-basic/rest-api-typescript/README.md index b5191620..abfed749 100644 --- a/asset-transfer-basic/rest-api-typescript/README.md +++ b/asset-transfer-basic/rest-api-typescript/README.md @@ -6,7 +6,7 @@ The primary aim of this sample is to show how to write a long running client app The REST API is intended to work with the [basic asset transfer example](https://github.com/hyperledger/fabric-samples/tree/main/asset-transfer-basic) -To install the basic asset transfer chaincode on a local Fabric network, follow the [Using the Fabric test network](https://hyperledger-fabric.readthedocs.io/en/release-2.2/test_network.html) tutorial +To install the basic asset transfer chaincode on a local Fabric network, follow the [Using the Fabric test network](https://hyperledger-fabric.readthedocs.io/en/release-2.4/test_network.html) tutorial ## Overview @@ -25,7 +25,7 @@ Alternatively you might prefer to modify the sample to only retry transactions w ## Usage -**Note:** these instructions should work with the release-2.2 branch of `fabric-samples` but later versions require some changes +**Note:** these instructions should work with the main branch of `fabric-samples` To build and start the sample REST server, you'll need to [download and install an LTS version of node](https://nodejs.org/en/download/) From 2d7da062d839c5f1fe77bfbbbd0a8f8504a701ec Mon Sep 17 00:00:00 2001 From: James Taylor Date: Fri, 3 Dec 2021 12:29:13 +0000 Subject: [PATCH 50/59] Update Dockerfile Fabric is on alpine 3.14 so update the sample to match Signed-off-by: James Taylor --- asset-transfer-basic/rest-api-typescript/Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/asset-transfer-basic/rest-api-typescript/Dockerfile b/asset-transfer-basic/rest-api-typescript/Dockerfile index 073c68f1..d6e87bd0 100644 --- a/asset-transfer-basic/rest-api-typescript/Dockerfile +++ b/asset-transfer-basic/rest-api-typescript/Dockerfile @@ -1,4 +1,4 @@ -FROM node:14-alpine3.12 AS build +FROM node:14-alpine3.14 AS build RUN apk add --no-cache g++ make python3 dumb-init @@ -10,7 +10,7 @@ RUN npm ci RUN npm run build RUN npm prune --production -FROM node:14-alpine3.12 +FROM node:14-alpine3.14 ENV NODE_ENV production WORKDIR /app From 5a32d803c9eda62e8f9e5c01a474c78c4891640e Mon Sep 17 00:00:00 2001 From: James Taylor Date: Fri, 3 Dec 2021 15:35:30 +0000 Subject: [PATCH 51/59] Use user credentials Use User1 certificate and private key for org1 and org2 instead of Admin credentials Signed-off-by: James Taylor --- .../rest-api-typescript/scripts/generateEnv.sh | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/asset-transfer-basic/rest-api-typescript/scripts/generateEnv.sh b/asset-transfer-basic/rest-api-typescript/scripts/generateEnv.sh index c910651e..ebdcf073 100755 --- a/asset-transfer-basic/rest-api-typescript/scripts/generateEnv.sh +++ b/asset-transfer-basic/rest-api-typescript/scripts/generateEnv.sh @@ -8,12 +8,12 @@ ${AS_LOCAL_HOST:=true} : "${TEST_NETWORK_HOME:=../..}" : "${CONNECTION_PROFILE_FILE_ORG1:=${TEST_NETWORK_HOME}/organizations/peerOrganizations/org1.example.com/connection-org1.json}" -: "${CERTIFICATE_FILE_ORG1:=${TEST_NETWORK_HOME}/organizations/peerOrganizations/org1.example.com/users/Admin@org1.example.com/msp/signcerts/Admin@org1.example.com-cert.pem}" -: "${PRIVATE_KEY_FILE_ORG1:=${TEST_NETWORK_HOME}/organizations/peerOrganizations/org1.example.com/users/Admin@org1.example.com/msp/keystore/priv_sk}" +: "${CERTIFICATE_FILE_ORG1:=${TEST_NETWORK_HOME}/organizations/peerOrganizations/org1.example.com/users/User1@org1.example.com/msp/signcerts/User1@org1.example.com-cert.pem}" +: "${PRIVATE_KEY_FILE_ORG1:=${TEST_NETWORK_HOME}/organizations/peerOrganizations/org1.example.com/users/User1@org1.example.com/msp/keystore/priv_sk}" : "${CONNECTION_PROFILE_FILE_ORG2:=${TEST_NETWORK_HOME}/organizations/peerOrganizations/org2.example.com/connection-org2.json}" -: "${CERTIFICATE_FILE_ORG2:=${TEST_NETWORK_HOME}/organizations/peerOrganizations/org2.example.com/users/Admin@org2.example.com/msp/signcerts/Admin@org2.example.com-cert.pem}" -: "${PRIVATE_KEY_FILE_ORG2:=${TEST_NETWORK_HOME}/organizations/peerOrganizations/org2.example.com/users/Admin@org2.example.com/msp/keystore/priv_sk}" +: "${CERTIFICATE_FILE_ORG2:=${TEST_NETWORK_HOME}/organizations/peerOrganizations/org2.example.com/users/User1@org2.example.com/msp/signcerts/User1@org2.example.com-cert.pem}" +: "${PRIVATE_KEY_FILE_ORG2:=${TEST_NETWORK_HOME}/organizations/peerOrganizations/org2.example.com/users/User1@org2.example.com/msp/keystore/priv_sk}" cat << ENV_END > .env From b0256a57b54263d5eb0c9a6023803655108d92ce Mon Sep 17 00:00:00 2001 From: James Taylor Date: Mon, 6 Dec 2021 10:56:43 +0000 Subject: [PATCH 52/59] Signpost configuration details Signed-off-by: James Taylor --- .../rest-api-typescript/.env.sample | 46 ++++++------------- .../rest-api-typescript/README.md | 2 + .../scripts/generateEnv.sh | 14 ++---- .../rest-api-typescript/src/config.ts | 23 +++++++--- .../rest-api-typescript/src/index.ts | 9 ++++ 5 files changed, 47 insertions(+), 47 deletions(-) diff --git a/asset-transfer-basic/rest-api-typescript/.env.sample b/asset-transfer-basic/rest-api-typescript/.env.sample index dc052b68..ab5554ad 100644 --- a/asset-transfer-basic/rest-api-typescript/.env.sample +++ b/asset-transfer-basic/rest-api-typescript/.env.sample @@ -1,39 +1,23 @@ -LOG_LEVEL=info +# Sample .env file +# +# These are the minimum configuration variables required to start the sample +# +# See src/config.ts for details and for all the available configuration +# variables +# -PORT=3000 +HLF_CONNECTION_PROFILE_ORG1= -RETRY_DELAY=3000 +HLF_CERTIFICATE_ORG1= -MAX_RETRY_COUNT=5 +HLF_PRIVATE_KEY_ORG1= -AS_LOCAL_HOST=true +HLF_CONNECTION_PROFILE_ORG2= -HLF_CONNECTION_PROFILE_ORG1={"name":"test-network-org1","version":"1.0.0","client":{"organization":"Org1" ... } +HLF_CERTIFICATE_ORG2= -HLF_CERTIFICATE_ORG1="-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----\n" +HLF_PRIVATE_KEY_ORG2= -HLF_PRIVATE_KEY_ORG1="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n" +ORG1_APIKEY= -HLF_CONNECTION_PROFILE_ORG2={"name":"test-network-org2","version":"1.0.0","client":{"organization":"Org2" ... } - -HLF_CERTIFICATE_ORG2="-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----\n" - -HLF_PRIVATE_KEY_ORG2="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n" - -HLF_COMMIT_TIMEOUT=3000 - -HLF_ENDORSE_TIMEOUT=30 - -HLF_QUERY_TIMEOUT=3 - -REDIS_HOST=localhost - -REDIS_PORT=6379 - -#REDIS_USERNAME= - -#REDIS_PASSWORD= - -ORG1_APIKEY=D2F66BFF-D68B-458D-8FA6-285F172D5B03 - -ORG2_APIKEY=92042C1F-8E58-48F9-9EAF-91A98A2B764 +ORG2_APIKEY= diff --git a/asset-transfer-basic/rest-api-typescript/README.md b/asset-transfer-basic/rest-api-typescript/README.md index abfed749..ceb689a9 100644 --- a/asset-transfer-basic/rest-api-typescript/README.md +++ b/asset-transfer-basic/rest-api-typescript/README.md @@ -23,6 +23,8 @@ Alternatively you might prefer to modify the sample to only retry transactions w - CHAINCODE_VERSION_CONFLICT - EXPIRED_CHAINCODE +See [src/index.ts](src/index.ts) for a description of the sample code structure, and [src/config.ts](src/config.ts) for details of configuring the sample using environment variables. + ## Usage **Note:** these instructions should work with the main branch of `fabric-samples` diff --git a/asset-transfer-basic/rest-api-typescript/scripts/generateEnv.sh b/asset-transfer-basic/rest-api-typescript/scripts/generateEnv.sh index ebdcf073..626f84b9 100755 --- a/asset-transfer-basic/rest-api-typescript/scripts/generateEnv.sh +++ b/asset-transfer-basic/rest-api-typescript/scripts/generateEnv.sh @@ -17,14 +17,14 @@ ${AS_LOCAL_HOST:=true} cat << ENV_END > .env +# Generated .env file +# See src/config.ts for details of all the available configuration variables +# + LOG_LEVEL=info PORT=3000 -RETRY_DELAY=3000 - -MAX_RETRY_COUNT=5 - HLF_CERTIFICATE_ORG1="$(cat ${CERTIFICATE_FILE_ORG1} | sed -e 's/$/\\n/' | tr -d '\r\n')" HLF_PRIVATE_KEY_ORG1="$(cat ${PRIVATE_KEY_FILE_ORG1} | sed -e 's/$/\\n/' | tr -d '\r\n')" @@ -33,12 +33,6 @@ HLF_CERTIFICATE_ORG2="$(cat ${CERTIFICATE_FILE_ORG2} | sed -e 's/$/\\n/' | tr -d HLF_PRIVATE_KEY_ORG2="$(cat ${PRIVATE_KEY_FILE_ORG2} | sed -e 's/$/\\n/' | tr -d '\r\n')" -HLF_COMMIT_TIMEOUT=3000 - -HLF_ENDORSE_TIMEOUT=30 - -HLF_QUERY_TIMEOUT=3 - REDIS_PORT=6379 ORG1_APIKEY=$(uuidgen) diff --git a/asset-transfer-basic/rest-api-typescript/src/config.ts b/asset-transfer-basic/rest-api-typescript/src/config.ts index a15c0cec..46933719 100644 --- a/asset-transfer-basic/rest-api-typescript/src/config.ts +++ b/asset-transfer-basic/rest-api-typescript/src/config.ts @@ -1,5 +1,16 @@ /* * SPDX-License-Identifier: Apache-2.0 + * + * The sample REST server can be configured using the environment variables + * documented below + * + * In a local development environment, these variables can be loaded from a + * .env file by starting the server with the following command: + * + * npm start:dev + * + * The scripts/generateEnv.sh script can be used to generate a suitable .env + * file for the Fabric Test Network */ import * as env from 'env-var'; @@ -142,8 +153,8 @@ export const chaincodeName = env */ export const commitTimeout = env .get('HLF_COMMIT_TIMEOUT') - .default('3000') - .example('3000') + .default('300') + .example('300') .asIntPositive(); /* @@ -176,7 +187,7 @@ export const connectionProfileOrg1 = env .asJsonObject() as Record; /* - * Certificate for the Org1 identity + * Certificate for an Org1 identity to evaluate and submit transactions */ export const certificateOrg1 = env .get('HLF_CERTIFICATE_ORG1') @@ -185,7 +196,7 @@ export const certificateOrg1 = env .asString(); /* - * Private key for the Org1 identity + * Private key for an Org1 identity to evaluate and submit transactions */ export const privateKeyOrg1 = env .get('HLF_PRIVATE_KEY_ORG1') @@ -205,7 +216,7 @@ export const connectionProfileOrg2 = env .asJsonObject() as Record; /* - * Certificate for the Org2 identity + * Certificate for an Org2 identity to evaluate and submit transactions */ export const certificateOrg2 = env .get('HLF_CERTIFICATE_ORG2') @@ -214,7 +225,7 @@ export const certificateOrg2 = env .asString(); /* - * Private key for the Org2 identity + * Private key for an Org2 identity to evaluate and submit transactions */ export const privateKeyOrg2 = env .get('HLF_PRIVATE_KEY_ORG2') diff --git a/asset-transfer-basic/rest-api-typescript/src/index.ts b/asset-transfer-basic/rest-api-typescript/src/index.ts index 567d3cbc..6b81651c 100644 --- a/asset-transfer-basic/rest-api-typescript/src/index.ts +++ b/asset-transfer-basic/rest-api-typescript/src/index.ts @@ -1,5 +1,14 @@ /* * SPDX-License-Identifier: Apache-2.0 + * + * This is the main entrypoint for the sample REST server, which is responsible + * for connecting to the Fabric network and setting up a job queue for + * processing submit transactions + * + * You can find details of other aspects of the sample in the following files: + * + * - config.ts + * descriptions of all the available configuration environment variables */ import { Contract } from 'fabric-network'; From 5d92abc52d9a4467f09336fda229df8ede396f68 Mon Sep 17 00:00:00 2001 From: James Taylor Date: Wed, 8 Dec 2021 16:58:27 +0000 Subject: [PATCH 53/59] Clarify retry logic Improve the separation between Fabric logic and the job queue implementation details Signed-off-by: James Taylor --- .../rest-api-typescript/README.md | 2 +- .../rest-api-typescript/src/config.spec.ts | 6 +- .../rest-api-typescript/src/config.ts | 6 +- .../rest-api-typescript/src/errors.spec.ts | 116 +++++++++++++- .../rest-api-typescript/src/errors.ts | 141 ++++++++++++------ .../rest-api-typescript/src/fabric.spec.ts | 129 ---------------- .../rest-api-typescript/src/fabric.ts | 125 +--------------- .../rest-api-typescript/src/index.ts | 23 ++- .../rest-api-typescript/src/jobs.router.ts | 3 +- .../rest-api-typescript/src/jobs.spec.ts | 131 +++++++++++++++- .../rest-api-typescript/src/jobs.ts | 125 +++++++++++++++- .../rest-api-typescript/src/redis.ts | 2 +- 12 files changed, 486 insertions(+), 323 deletions(-) diff --git a/asset-transfer-basic/rest-api-typescript/README.md b/asset-transfer-basic/rest-api-typescript/README.md index ceb689a9..fda1e708 100644 --- a/asset-transfer-basic/rest-api-typescript/README.md +++ b/asset-transfer-basic/rest-api-typescript/README.md @@ -51,7 +51,7 @@ Create a `.env` file to configure the server for the test network (make sure TES TEST_NETWORK_HOME=$HOME/fabric-samples/test-network npm run generateEnv ``` -Start a Redis server +Start a Redis server (Redis is used to store the queue of submit transactions) ```shell npm run start:redis diff --git a/asset-transfer-basic/rest-api-typescript/src/config.spec.ts b/asset-transfer-basic/rest-api-typescript/src/config.spec.ts index f095c1b5..96ffaad5 100644 --- a/asset-transfer-basic/rest-api-typescript/src/config.spec.ts +++ b/asset-transfer-basic/rest-api-typescript/src/config.spec.ts @@ -289,9 +289,9 @@ describe('Config values', () => { }); describe('commitTimeout', () => { - it('defaults to "3000"', () => { + it('defaults to "300"', () => { const config = require('./config'); - expect(config.commitTimeout).toBe(3000); + expect(config.commitTimeout).toBe(300); }); it('can be configured using the "HLF_COMMIT_TIMEOUT" environment variable', () => { @@ -305,7 +305,7 @@ describe('Config values', () => { expect(() => { require('./config'); }).toThrow( - 'env-var: "HLF_COMMIT_TIMEOUT" should be a valid integer. An example of a valid value would be: 3000' + 'env-var: "HLF_COMMIT_TIMEOUT" should be a valid integer. An example of a valid value would be: 300' ); }); }); diff --git a/asset-transfer-basic/rest-api-typescript/src/config.ts b/asset-transfer-basic/rest-api-typescript/src/config.ts index 46933719..87b64719 100644 --- a/asset-transfer-basic/rest-api-typescript/src/config.ts +++ b/asset-transfer-basic/rest-api-typescript/src/config.ts @@ -3,12 +3,12 @@ * * The sample REST server can be configured using the environment variables * documented below - * + * * In a local development environment, these variables can be loaded from a * .env file by starting the server with the following command: - * + * * npm start:dev - * + * * The scripts/generateEnv.sh script can be used to generate a suitable .env * file for the Fabric Test Network */ diff --git a/asset-transfer-basic/rest-api-typescript/src/errors.spec.ts b/asset-transfer-basic/rest-api-typescript/src/errors.spec.ts index 6ef490f6..90f37a1c 100644 --- a/asset-transfer-basic/rest-api-typescript/src/errors.spec.ts +++ b/asset-transfer-basic/rest-api-typescript/src/errors.spec.ts @@ -2,15 +2,20 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { TimeoutError, TransactionError } from 'fabric-network'; import { AssetExistsError, AssetNotFoundError, TransactionNotFoundError, + getRetryAction, handleError, isDuplicateTransactionError, isErrorLike, + RetryAction, } from './errors'; +import { mock } from 'jest-mock-extended'; + describe('Errors', () => { describe('isErrorLike', () => { it('returns false for null', () => { @@ -53,6 +58,24 @@ describe('Errors', () => { }); describe('isDuplicateTransactionError', () => { + it('returns true for a TransactionError with a transaction code of DUPLICATE_TXID', () => { + const mockDuplicateTransactionError = mock(); + mockDuplicateTransactionError.transactionCode = 'DUPLICATE_TXID'; + + expect(isDuplicateTransactionError(mockDuplicateTransactionError)).toBe( + true + ); + }); + + it('returns false for a TransactionError without a transaction code of MVCC_READ_CONFLICT', () => { + const mockDuplicateTransactionError = mock(); + mockDuplicateTransactionError.transactionCode = 'MVCC_READ_CONFLICT'; + + expect(isDuplicateTransactionError(mockDuplicateTransactionError)).toBe( + false + ); + }); + it('returns true for an error when all endorsement details are duplicate transaction found', () => { const mockDuplicateTransactionError = { errors: [ @@ -155,13 +178,96 @@ describe('Errors', () => { }); }); + describe('getRetryAction', () => { + it('returns RetryAction.None for duplicate transaction errors', () => { + const mockDuplicateTransactionError = { + errors: [ + { + endorsements: [ + { + details: 'duplicate transaction found', + }, + { + details: 'duplicate transaction found', + }, + { + details: 'duplicate transaction found', + }, + ], + }, + ], + }; + + expect(getRetryAction(mockDuplicateTransactionError)).toBe( + RetryAction.None + ); + }); + + it('returns RetryAction.None for a TransactionNotFoundError', () => { + const mockTransactionNotFoundError = new TransactionNotFoundError('Failed to get transaction with id txn, error Entry not found in index', 'txn1'); + + expect(getRetryAction(mockTransactionNotFoundError)).toBe( + RetryAction.None + ); + }); + + it('returns RetryAction.None for an AssetExistsError', () => { + const mockAssetExistsError = new AssetExistsError('The asset MOCK_ASSET already exists', 'txn1'); + + expect(getRetryAction(mockAssetExistsError)).toBe( + RetryAction.None + ); + }); + + it('returns RetryAction.None for an AssetNotFoundError', () => { + const mockAssetNotFoundError = new AssetNotFoundError('the asset MOCK_ASSET does not exist', 'txn1'); + + expect(getRetryAction(mockAssetNotFoundError)).toBe( + RetryAction.None + ); + }); + + it('returns RetryAction.WithExistingTransactionId for a TimeoutError', () => { + const mockTimeoutError = new TimeoutError('MOCK TIMEOUT ERROR'); + + expect(getRetryAction(mockTimeoutError)).toBe( + RetryAction.WithExistingTransactionId + ); + }); + + it('returns RetryAction.WithNewTransactionId for an MVCC_READ_CONFLICT TransactionError', () => { + const mockTransactionError = mock(); + mockTransactionError.transactionCode = 'MVCC_READ_CONFLICT'; + + expect(getRetryAction(mockTransactionError)).toBe( + RetryAction.WithNewTransactionId + ); + }); + + it('returns RetryAction.WithNewTransactionId for an Error', () => { + const mockError = new Error('MOCK ERROR'); + + expect(getRetryAction(mockError)).toBe( + RetryAction.WithNewTransactionId + ); + }); + + it('returns RetryAction.WithNewTransactionId for a string error', () => { + const mockError = 'MOCK ERROR'; + + expect(getRetryAction(mockError)).toBe( + RetryAction.WithNewTransactionId + ); + }); + }); + describe('handleError', () => { it.each([ 'the asset GOCHAINCODE already exists', 'Asset JAVACHAINCODE already exists', 'The asset JSCHAINCODE already exists', ])( - 'returns an AssetExistsError for errors with an asset already exists message: %s', + 'returns a AssetExistsError for errors with an asset already exists message: %s', (msg) => { expect(handleError('txn1', new Error(msg))).toStrictEqual( new AssetExistsError(msg, 'txn1') @@ -174,7 +280,7 @@ describe('Errors', () => { 'Asset JAVACHAINCODE does not exist', 'The asset JSCHAINCODE does not exist', ])( - 'returns an AssetNotFoundError for errors with an asset does not exist message: %s', + 'returns a AssetNotFoundError for errors with an asset does not exist message: %s', (msg) => { expect(handleError('txn1', new Error(msg))).toStrictEqual( new AssetNotFoundError(msg, 'txn1') @@ -200,10 +306,8 @@ describe('Errors', () => { ); }); - it('returns a new Error object for errors of other types', () => { - expect(handleError('txn1', 42)).toStrictEqual( - new Error('Unhandled error: 42') - ); + it('returns the original error for errors of other types', () => { + expect(handleError('txn1', 42)).toEqual(42); }); }); }); diff --git a/asset-transfer-basic/rest-api-typescript/src/errors.ts b/asset-transfer-basic/rest-api-typescript/src/errors.ts index 89eaaa6b..66272e87 100644 --- a/asset-transfer-basic/rest-api-typescript/src/errors.ts +++ b/asset-transfer-basic/rest-api-typescript/src/errors.ts @@ -1,9 +1,18 @@ /* * SPDX-License-Identifier: Apache-2.0 + * + * This file contains all the error handling for Fabric transactions, including + * whether a transaction should be retried. */ +import { TimeoutError, TransactionError } from 'fabric-network'; import { logger } from './logger'; +/* + * Base type for errors from the smart contract. + * + * These errors will not be retried. + */ export class ContractError extends Error { transactionId: string; @@ -16,18 +25,23 @@ export class ContractError extends Error { } } +/* + * Represents the error which occurs when the transaction being submitted or + * evaluated is not implemented in a smart contract. + */ export class TransactionNotFoundError extends ContractError { - transactionId: string; - constructor(message: string, transactionId: string) { super(message, transactionId); Object.setPrototypeOf(this, TransactionNotFoundError.prototype); this.name = 'TransactionNotFoundError'; - this.transactionId = transactionId; } } +/* + * Represents the error which occurs in the basic asset transfer smart contract + * implementation when an asset already exists. + */ export class AssetExistsError extends ContractError { constructor(message: string, transactionId: string) { super(message, transactionId); @@ -37,6 +51,10 @@ export class AssetExistsError extends ContractError { } } +/* + * Represents the error which occurs in the basic asset transfer smart contract + * implementation when an asset does not exist. + */ export class AssetNotFoundError extends ContractError { constructor(message: string, transactionId: string) { super(message, transactionId); @@ -46,18 +64,53 @@ export class AssetNotFoundError extends ContractError { } } -export class JobNotFoundError extends Error { - jobId: string; - - constructor(message: string, jobId: string) { - super(message); - Object.setPrototypeOf(this, JobNotFoundError.prototype); - - this.name = 'JobNotFoundError'; - this.jobId = jobId; - } +/* + * Enumeration of possible retry actions. + * + * WithExistingTransactionId - transactions should be retried using the same + * transaction ID to protect against duplicate transactions being committed if + * a timeout error occurs + * + * WithNewTransactionId - transactions which could not be committed due to + * other errors require a new transaction ID when retrying + * + * None - transactions that failed due to a duplicate transaction error, or + * errors from the smart contract, should not be retried + */ +export enum RetryAction { + WithExistingTransactionId, + WithNewTransactionId, + None, } +/* + * Get the required transaction retry action for an error. + * + * For this sample transactions are considered retriable if they fail with any + * error, *except* for duplicate transaction errors, or errors from the smart + * contract. + * + * You might decide to retry transactions which fail with specific errors + * instead, for example: + * MVCC_READ_CONFLICT + * PHANTOM_READ_CONFLICT + * ENDORSEMENT_POLICY_FAILURE + * CHAINCODE_VERSION_CONFLICT + * EXPIRED_CHAINCODE + */ +export const getRetryAction = (err: unknown): RetryAction => { + if (isDuplicateTransactionError(err) || err instanceof ContractError) { + return RetryAction.None; + } else if (err instanceof TimeoutError) { + return RetryAction.WithExistingTransactionId; + } + + return RetryAction.WithNewTransactionId; +}; + +/* + * Type guard to make catching unknown errors easier + */ export const isErrorLike = (err: unknown): err is Error => { return ( err != undefined && @@ -72,23 +125,32 @@ export const isErrorLike = (err: unknown): err is Error => { /* * Checks whether an error was caused by a duplicate transaction. * - * Checking error strings like this is not ideal, unfortunately it appears to - * be the only option. In this case it would be better to check for the - * DUPLICATE_TXID TxValidationCode somehow but that does not seem to be - * possible. + * This is ...painful. */ export const isDuplicateTransactionError = (err: unknown): boolean => { + logger.debug({ err }, 'Checking for duplicate transaction error'); + if (err === undefined || err === null) return false; - const endorsementError = err as { - errors: { endorsements: { details: string }[] }[]; - }; + let isDuplicate; + if (typeof (err as TransactionError).transactionCode === 'string') { + // Checking whether a commit failure is caused by a duplicate transaction + // is straightforward because the transaction code should be available + isDuplicate = + (err as TransactionError).transactionCode === 'DUPLICATE_TXID'; + } else { + // Checking whether an endorsement failure is caused by a duplicate + // transaction is only possible by processing error strings, which is not ideal. + const endorsementError = err as { + errors: { endorsements: { details: string }[] }[]; + }; - const isDuplicate = endorsementError?.errors?.some((err) => - err?.endorsements?.some((endorsement) => - endorsement?.details?.startsWith('duplicate transaction found') - ) - ); + isDuplicate = endorsementError?.errors?.some((err) => + err?.endorsements?.some((endorsement) => + endorsement?.details?.startsWith('duplicate transaction found') + ) + ); + } return isDuplicate === true; }; @@ -167,27 +229,18 @@ const matchTransactionDoesNotExistMessage = ( return null; }; -export const isContractError = (err: unknown): boolean => { - if ( - err instanceof AssetExistsError || - err instanceof AssetNotFoundError || - err instanceof TransactionNotFoundError - ) { - return true; - } - - return false; -}; - /* * Handles errors from evaluating and submitting transactions. * - * As with duplicate transaction errors, checking error strings like this is - * not ideal. Unfortunately the chaincode samples do not use error codes so - * again it's the only option. The error message text is not even the same for - * the Go, Java, and Javascript implementations of the chaincode! + * Smart contract errors from the the basic asset transfer samples do not use + * error codes so matching strings is the only option, which is not ideal. + * Note: the error message text is not the same for the Go, Java, and + * Javascript implementations of the chaincode! */ -export const handleError = (transactionId: string, err: unknown): Error => { +export const handleError = ( + transactionId: string, + err: unknown +): Error | unknown => { logger.debug({ transactionId: transactionId, err }, 'Processing error'); if (isErrorLike(err)) { @@ -210,9 +263,7 @@ export const handleError = (transactionId: string, err: unknown): Error => { transactionId ); } - - return err; } - return new Error(`Unhandled error: ${err}`); + return err; }; diff --git a/asset-transfer-basic/rest-api-typescript/src/fabric.spec.ts b/asset-transfer-basic/rest-api-typescript/src/fabric.spec.ts index 34e6f2f0..b7ba2805 100644 --- a/asset-transfer-basic/rest-api-typescript/src/fabric.spec.ts +++ b/asset-transfer-basic/rest-api-typescript/src/fabric.spec.ts @@ -11,7 +11,6 @@ import { submitTransaction, getBlockHeight, getTransactionValidationCode, - processSubmitTransactionJob, } from './fabric'; import * as config from './config'; @@ -34,7 +33,6 @@ import * as fabricProtos from 'fabric-protos'; import { MockProxy, mock } from 'jest-mock-extended'; import Long from 'long'; -import { Job } from 'bullmq'; jest.mock('./config'); jest.mock('fabric-network', () => { @@ -117,133 +115,6 @@ describe('Fabric', () => { }); }); - describe('processSubmitTransactionJob', () => { - const mockContracts = new Map(); - const mockPayload = Buffer.from('MOCK PAYLOAD'); - const mockSavedState = Buffer.from('MOCK SAVED STATE'); - let mockTransaction: MockProxy; - let mockContract: MockProxy; - let mockJob: MockProxy; - - beforeEach(() => { - mockTransaction = mock(); - mockTransaction.getTransactionId.mockReturnValue('mockTransactionId'); - - mockContract = mock(); - mockContract.createTransaction - .calledWith('txn') - .mockReturnValue(mockTransaction); - mockContract.deserializeTransaction - .calledWith(mockSavedState) - .mockReturnValue(mockTransaction); - mockContracts.set('mockMspid', mockContract); - - mockJob = mock(); - }); - - it('gets job result with no error or payload if no contract is available for the required mspid', async () => { - mockJob.data = { - mspid: 'missingMspid', - }; - - const jobResult = await processSubmitTransactionJob( - mockContracts, - mockJob - ); - - expect(jobResult).toStrictEqual({ - transactionError: undefined, - transactionPayload: undefined, - }); - }); - - it('gets a job result containing a payload if the transaction was successful first time', async () => { - mockJob.data = { - mspid: 'mockMspid', - transactionName: 'txn', - transactionArgs: ['arg1', 'arg2'], - }; - mockTransaction.submit - .calledWith('arg1', 'arg2') - .mockResolvedValue(mockPayload); - - const jobResult = await processSubmitTransactionJob( - mockContracts, - mockJob - ); - - expect(jobResult).toStrictEqual({ - transactionError: undefined, - transactionPayload: Buffer.from('MOCK PAYLOAD'), - }); - }); - - it('gets a job result containing a payload if the transaction was successfully rerun using saved transaction state', async () => { - mockJob.data = { - mspid: 'mockMspid', - transactionName: 'txn', - transactionArgs: ['arg1', 'arg2'], - transactionState: mockSavedState, - }; - mockTransaction.submit - .calledWith('arg1', 'arg2') - .mockResolvedValue(mockPayload); - - const jobResult = await processSubmitTransactionJob( - mockContracts, - mockJob - ); - - expect(jobResult).toStrictEqual({ - transactionError: undefined, - transactionPayload: Buffer.from('MOCK PAYLOAD'), - }); - }); - - it('gets a job result containing an error message if the transaction fails but cannot be retried', async () => { - mockJob.data = { - mspid: 'mockMspid', - transactionName: 'txn', - transactionArgs: ['arg1', 'arg2'], - transactionState: mockSavedState, - }; - mockTransaction.submit - .calledWith('arg1', 'arg2') - .mockRejectedValue( - new Error( - 'Failed to get transaction with id txn, error Entry not found in index' - ) - ); - - const jobResult = await processSubmitTransactionJob( - mockContracts, - mockJob - ); - - expect(jobResult).toStrictEqual({ - transactionError: - 'TransactionNotFoundError: Failed to get transaction with id txn, error Entry not found in index', - transactionPayload: undefined, - }); - }); - - it('throws an error if the transaction fails but can be retried', async () => { - mockJob.data = { - mspid: 'mockMspid', - transactionName: 'txn', - transactionArgs: ['arg1', 'arg2'], - transactionState: mockSavedState, - }; - mockTransaction.submit - .calledWith('arg1', 'arg2') - .mockRejectedValue(new Error('MOCK ERROR')); - - await expect(async () => { - await processSubmitTransactionJob(mockContracts, mockJob); - }).rejects.toThrow('MOCK ERROR'); - }); - }); - describe('evatuateTransaction', () => { const mockPayload = Buffer.from('MOCK PAYLOAD'); let mockTransaction: MockProxy; diff --git a/asset-transfer-basic/rest-api-typescript/src/fabric.ts b/asset-transfer-basic/rest-api-typescript/src/fabric.ts index 70a3a6f2..1a9f8b6e 100644 --- a/asset-transfer-basic/rest-api-typescript/src/fabric.ts +++ b/asset-transfer-basic/rest-api-typescript/src/fabric.ts @@ -10,20 +10,13 @@ import { GatewayOptions, Wallets, Network, - TimeoutError, Transaction, Wallet, } from 'fabric-network'; import * as config from './config'; import { logger } from './logger'; -import { - handleError, - isContractError, - isDuplicateTransactionError, -} from './errors'; +import { handleError } from './errors'; import * as protos from 'fabric-protos'; -import { Job } from 'bullmq'; -import { JobData, JobResult, updateJobData } from './jobs'; /* * Creates an in memory wallet to hold credentials for an Org1 and Org2 user @@ -120,122 +113,6 @@ export const getContracts = async ( return { assetContract, qsccContract }; }; -/* - * Process a submit transaction request from the job queue - * - * For this sample transactions are retried if they fail with any error, - * except for errors from the smart contract, or duplicate transaction - * errors - * - * You might decide to retry transactions which fail with specific errors - * instead, for example: - * MVCC_READ_CONFLICT - * PHANTOM_READ_CONFLICT - * ENDORSEMENT_POLICY_FAILURE - * CHAINCODE_VERSION_CONFLICT - * EXPIRED_CHAINCODE - */ -export const processSubmitTransactionJob = async ( - contracts: Map, - job: Job -): Promise => { - logger.debug({ jobId: job.id, jobName: job.name }, 'Processing job'); - - const contract = contracts.get(job.data.mspid); - if (contract === undefined) { - logger.error( - { jobId: job.id, jobName: job.name }, - 'Contract not found for MSP ID %s', - job.data.mspid - ); - - // Retrying will not work, so give up with an unsuccessful result - return { - transactionError: undefined, - transactionPayload: undefined, - }; - } - - let transaction: Transaction; - if (job.data.transactionState) { - const savedState = job.data.transactionState; - logger.debug( - { - jobId: job.id, - jobName: job.name, - savedState, - }, - 'Using previously saved transaction state' - ); - - transaction = contract.deserializeTransaction(savedState); - } else { - logger.debug( - { - jobId: job.id, - jobName: job.name, - }, - 'Using new transaction' - ); - - transaction = contract.createTransaction(job.data.transactionName); - await updateJobData(job, transaction); - } - - try { - logger.debug( - { - jobId: job.id, - jobName: job.name, - transactionId: transaction.getTransactionId(), - }, - 'Submitting transaction' - ); - const args = job.data.transactionArgs; - const payload = await submitTransaction(transaction, ...args); - - return { - transactionError: undefined, - transactionPayload: payload, - }; - } catch (err) { - if ( - err instanceof Error && - (isContractError(err) || isDuplicateTransactionError(err)) - ) { - logger.error( - { jobId: job.id, jobName: job.name, err }, - 'Fatal transaction error occurred' - ); - - // Return a job result to stop retrying - return { - transactionError: err.toString(), - transactionPayload: undefined, - }; - } else { - logger.warn( - { jobId: job.id, jobName: job.name, err }, - 'Retryable transaction error occurred' - ); - - // The original transaction may eventually get committed in the case of - // a timeout error, so keep the same transaction ID to protect against - // unintended duplicate transactions - if (!(err instanceof TimeoutError)) { - logger.debug( - { jobId: job.id, jobName: job.name }, - 'Clearing saved transaction state' - ); - await updateJobData(job, undefined); - } - - // Rethrow the error to keep retrying - throw err; - } - } -}; - /* * Evaluate a transaction and handle any errors */ diff --git a/asset-transfer-basic/rest-api-typescript/src/index.ts b/asset-transfer-basic/rest-api-typescript/src/index.ts index 6b81651c..1e222713 100644 --- a/asset-transfer-basic/rest-api-typescript/src/index.ts +++ b/asset-transfer-basic/rest-api-typescript/src/index.ts @@ -4,11 +4,32 @@ * This is the main entrypoint for the sample REST server, which is responsible * for connecting to the Fabric network and setting up a job queue for * processing submit transactions + * + * You can find more details related to the Fabric aspects of the sample in the + * following files: + * + * - errors.ts + * Fabric transaction error handling and retry logic + * - fabric.ts + * all the sample code which interacts with the Fabric SDK * - * You can find details of other aspects of the sample in the following files: + * The remaining files are related to the REST server aspects of the sample, + * rather than Fabric itself: * + * - *.router.ts + * details of the REST endpoints provided by the sample + * - auth.ts + * basic API key authentication strategy used for the sample * - config.ts * descriptions of all the available configuration environment variables + * - jobs.ts + * job queue implementation details + * - logger.ts + * logging implementation details + * - redis.ts + * redis implementation details + * - server.ts + * express server implementation details */ import { Contract } from 'fabric-network'; diff --git a/asset-transfer-basic/rest-api-typescript/src/jobs.router.ts b/asset-transfer-basic/rest-api-typescript/src/jobs.router.ts index 097d19a7..77dc5fe9 100644 --- a/asset-transfer-basic/rest-api-typescript/src/jobs.router.ts +++ b/asset-transfer-basic/rest-api-typescript/src/jobs.router.ts @@ -5,8 +5,7 @@ import { Queue } from 'bullmq'; import express, { Request, Response } from 'express'; import { getReasonPhrase, StatusCodes } from 'http-status-codes'; -import { JobNotFoundError } from './errors'; -import { getJobSummary } from './jobs'; +import { getJobSummary, JobNotFoundError } from './jobs'; import { logger } from './logger'; const { INTERNAL_SERVER_ERROR, NOT_FOUND, OK } = StatusCodes; diff --git a/asset-transfer-basic/rest-api-typescript/src/jobs.spec.ts b/asset-transfer-basic/rest-api-typescript/src/jobs.spec.ts index 05fe6af0..43ba4d6d 100644 --- a/asset-transfer-basic/rest-api-typescript/src/jobs.spec.ts +++ b/asset-transfer-basic/rest-api-typescript/src/jobs.spec.ts @@ -3,9 +3,9 @@ */ import { Job, Queue } from 'bullmq'; -import { getJobCounts, getJobSummary } from './jobs'; +import { getJobCounts, getJobSummary, processSubmitTransactionJob, JobNotFoundError } from './jobs'; +import { Contract, Transaction } from 'fabric-network'; import { mock, MockProxy } from 'jest-mock-extended'; -import { JobNotFoundError } from './errors'; describe('initJobQueue', () => { it.todo('write tests'); @@ -152,4 +152,131 @@ describe('getJobCounts', () => { waiting: 5, }); }); + + describe('processSubmitTransactionJob', () => { + const mockContracts = new Map(); + const mockPayload = Buffer.from('MOCK PAYLOAD'); + const mockSavedState = Buffer.from('MOCK SAVED STATE'); + let mockTransaction: MockProxy; + let mockContract: MockProxy; + let mockJob: MockProxy; + + beforeEach(() => { + mockTransaction = mock(); + mockTransaction.getTransactionId.mockReturnValue('mockTransactionId'); + + mockContract = mock(); + mockContract.createTransaction + .calledWith('txn') + .mockReturnValue(mockTransaction); + mockContract.deserializeTransaction + .calledWith(mockSavedState) + .mockReturnValue(mockTransaction); + mockContracts.set('mockMspid', mockContract); + + mockJob = mock(); + }); + + it('gets job result with no error or payload if no contract is available for the required mspid', async () => { + mockJob.data = { + mspid: 'missingMspid', + }; + + const jobResult = await processSubmitTransactionJob( + mockContracts, + mockJob + ); + + expect(jobResult).toStrictEqual({ + transactionError: undefined, + transactionPayload: undefined, + }); + }); + + it('gets a job result containing a payload if the transaction was successful first time', async () => { + mockJob.data = { + mspid: 'mockMspid', + transactionName: 'txn', + transactionArgs: ['arg1', 'arg2'], + }; + mockTransaction.submit + .calledWith('arg1', 'arg2') + .mockResolvedValue(mockPayload); + + const jobResult = await processSubmitTransactionJob( + mockContracts, + mockJob + ); + + expect(jobResult).toStrictEqual({ + transactionError: undefined, + transactionPayload: Buffer.from('MOCK PAYLOAD'), + }); + }); + + it('gets a job result containing a payload if the transaction was successfully rerun using saved transaction state', async () => { + mockJob.data = { + mspid: 'mockMspid', + transactionName: 'txn', + transactionArgs: ['arg1', 'arg2'], + transactionState: mockSavedState, + }; + mockTransaction.submit + .calledWith('arg1', 'arg2') + .mockResolvedValue(mockPayload); + + const jobResult = await processSubmitTransactionJob( + mockContracts, + mockJob + ); + + expect(jobResult).toStrictEqual({ + transactionError: undefined, + transactionPayload: Buffer.from('MOCK PAYLOAD'), + }); + }); + + it('gets a job result containing an error message if the transaction fails but cannot be retried', async () => { + mockJob.data = { + mspid: 'mockMspid', + transactionName: 'txn', + transactionArgs: ['arg1', 'arg2'], + transactionState: mockSavedState, + }; + mockTransaction.submit + .calledWith('arg1', 'arg2') + .mockRejectedValue( + new Error( + 'Failed to get transaction with id txn, error Entry not found in index' + ) + ); + + const jobResult = await processSubmitTransactionJob( + mockContracts, + mockJob + ); + + expect(jobResult).toStrictEqual({ + transactionError: + 'TransactionNotFoundError: Failed to get transaction with id txn, error Entry not found in index', + transactionPayload: undefined, + }); + }); + + it('throws an error if the transaction fails but can be retried', async () => { + mockJob.data = { + mspid: 'mockMspid', + transactionName: 'txn', + transactionArgs: ['arg1', 'arg2'], + transactionState: mockSavedState, + }; + mockTransaction.submit + .calledWith('arg1', 'arg2') + .mockRejectedValue(new Error('MOCK ERROR')); + + await expect(async () => { + await processSubmitTransactionJob(mockContracts, mockJob); + }).rejects.toThrow('MOCK ERROR'); + }); + }); }); diff --git a/asset-transfer-basic/rest-api-typescript/src/jobs.ts b/asset-transfer-basic/rest-api-typescript/src/jobs.ts index da397a2d..2b4f2399 100644 --- a/asset-transfer-basic/rest-api-typescript/src/jobs.ts +++ b/asset-transfer-basic/rest-api-typescript/src/jobs.ts @@ -3,17 +3,13 @@ * * This sample uses BullMQ jobs to process submit transactions, which includes * retry support for failing jobs - * - * Important: BullMQ requires the following setting in redis - * maxmemory-policy=noeviction - * For details, see: https://docs.bullmq.io/guide/connections */ import { ConnectionOptions, Job, Queue, QueueScheduler, Worker } from 'bullmq'; import { Contract, Transaction } from 'fabric-network'; import * as config from './config'; -import { JobNotFoundError } from './errors'; -import { processSubmitTransactionJob } from './fabric'; +import { getRetryAction, RetryAction } from './errors'; +import { submitTransaction } from './fabric'; import { logger } from './logger'; export type JobData = { @@ -37,6 +33,18 @@ export type JobSummary = { transactionError?: string; }; +export class JobNotFoundError extends Error { + jobId: string; + + constructor(message: string, jobId: string) { + super(message); + Object.setPrototypeOf(this, JobNotFoundError.prototype); + + this.name = 'JobNotFoundError'; + this.jobId = jobId; + } +} + const connection: ConnectionOptions = { port: config.redisPort, host: config.redisHost, @@ -214,3 +222,108 @@ export const getJobCounts = async ( return jobCounts; }; + +/* + * Process a submit transaction request from the job queue + * + * The job will be retried if this function throws an error + */ +export const processSubmitTransactionJob = async ( + contracts: Map, + job: Job +): Promise => { + logger.debug({ jobId: job.id, jobName: job.name }, 'Processing job'); + + const contract = contracts.get(job.data.mspid); + if (contract === undefined) { + logger.error( + { jobId: job.id, jobName: job.name }, + 'Contract not found for MSP ID %s', + job.data.mspid + ); + + // Retrying will never work without a contract, so give up with an + // empty job result + return { + transactionError: undefined, + transactionPayload: undefined, + }; + } + + const args = job.data.transactionArgs; + let transaction: Transaction; + + if (job.data.transactionState) { + const savedState = job.data.transactionState; + logger.debug( + { + jobId: job.id, + jobName: job.name, + savedState, + }, + 'Reusing previously saved transaction state' + ); + + transaction = contract.deserializeTransaction(savedState); + } else { + logger.debug( + { + jobId: job.id, + jobName: job.name, + }, + 'Using new transaction' + ); + + transaction = contract.createTransaction(job.data.transactionName); + await updateJobData(job, transaction); + } + + logger.debug( + { + jobId: job.id, + jobName: job.name, + transactionId: transaction.getTransactionId(), + }, + 'Submitting transaction' + ); + + try { + const payload = await submitTransaction(transaction, ...args); + + return { + transactionError: undefined, + transactionPayload: payload, + }; + } catch (err) { + const retryAction = getRetryAction(err); + + if (retryAction === RetryAction.None) { + logger.error( + { jobId: job.id, jobName: job.name, err }, + 'Fatal transaction error occurred' + ); + + // Not retriable so return a job result with the error details + return { + transactionError: `${err}`, + transactionPayload: undefined, + }; + } + + logger.warn( + { jobId: job.id, jobName: job.name, err }, + 'Retryable transaction error occurred' + ); + + if (retryAction === RetryAction.WithNewTransactionId) { + logger.debug( + { jobId: job.id, jobName: job.name }, + 'Clearing saved transaction state' + ); + await updateJobData(job, undefined); + } + + // Rethrow the error to keep retrying + throw err; + } +}; diff --git a/asset-transfer-basic/rest-api-typescript/src/redis.ts b/asset-transfer-basic/rest-api-typescript/src/redis.ts index bb4246da..35865545 100644 --- a/asset-transfer-basic/rest-api-typescript/src/redis.ts +++ b/asset-transfer-basic/rest-api-typescript/src/redis.ts @@ -1,7 +1,7 @@ /* * SPDX-License-Identifier: Apache-2.0 * - * TBC + * This sample uses the BullMQ queue system, which is built on top of Redis */ import IORedis, { Redis, RedisOptions } from 'ioredis'; From 66199000caf600fdd3c6110b89a42bb68c49cc57 Mon Sep 17 00:00:00 2001 From: James Taylor Date: Mon, 13 Dec 2021 12:11:59 +0000 Subject: [PATCH 54/59] Fix lint errors Signed-off-by: James Taylor --- .../rest-api-typescript/src/errors.spec.ts | 31 ++++++++++--------- .../rest-api-typescript/src/index.ts | 4 +-- .../rest-api-typescript/src/jobs.spec.ts | 7 ++++- 3 files changed, 24 insertions(+), 18 deletions(-) diff --git a/asset-transfer-basic/rest-api-typescript/src/errors.spec.ts b/asset-transfer-basic/rest-api-typescript/src/errors.spec.ts index 90f37a1c..ddf4c9f0 100644 --- a/asset-transfer-basic/rest-api-typescript/src/errors.spec.ts +++ b/asset-transfer-basic/rest-api-typescript/src/errors.spec.ts @@ -204,7 +204,10 @@ describe('Errors', () => { }); it('returns RetryAction.None for a TransactionNotFoundError', () => { - const mockTransactionNotFoundError = new TransactionNotFoundError('Failed to get transaction with id txn, error Entry not found in index', 'txn1'); + const mockTransactionNotFoundError = new TransactionNotFoundError( + 'Failed to get transaction with id txn, error Entry not found in index', + 'txn1' + ); expect(getRetryAction(mockTransactionNotFoundError)).toBe( RetryAction.None @@ -212,19 +215,21 @@ describe('Errors', () => { }); it('returns RetryAction.None for an AssetExistsError', () => { - const mockAssetExistsError = new AssetExistsError('The asset MOCK_ASSET already exists', 'txn1'); - - expect(getRetryAction(mockAssetExistsError)).toBe( - RetryAction.None + const mockAssetExistsError = new AssetExistsError( + 'The asset MOCK_ASSET already exists', + 'txn1' ); + + expect(getRetryAction(mockAssetExistsError)).toBe(RetryAction.None); }); it('returns RetryAction.None for an AssetNotFoundError', () => { - const mockAssetNotFoundError = new AssetNotFoundError('the asset MOCK_ASSET does not exist', 'txn1'); - - expect(getRetryAction(mockAssetNotFoundError)).toBe( - RetryAction.None + const mockAssetNotFoundError = new AssetNotFoundError( + 'the asset MOCK_ASSET does not exist', + 'txn1' ); + + expect(getRetryAction(mockAssetNotFoundError)).toBe(RetryAction.None); }); it('returns RetryAction.WithExistingTransactionId for a TimeoutError', () => { @@ -247,17 +252,13 @@ describe('Errors', () => { it('returns RetryAction.WithNewTransactionId for an Error', () => { const mockError = new Error('MOCK ERROR'); - expect(getRetryAction(mockError)).toBe( - RetryAction.WithNewTransactionId - ); + expect(getRetryAction(mockError)).toBe(RetryAction.WithNewTransactionId); }); it('returns RetryAction.WithNewTransactionId for a string error', () => { const mockError = 'MOCK ERROR'; - expect(getRetryAction(mockError)).toBe( - RetryAction.WithNewTransactionId - ); + expect(getRetryAction(mockError)).toBe(RetryAction.WithNewTransactionId); }); }); diff --git a/asset-transfer-basic/rest-api-typescript/src/index.ts b/asset-transfer-basic/rest-api-typescript/src/index.ts index 1e222713..e033db4c 100644 --- a/asset-transfer-basic/rest-api-typescript/src/index.ts +++ b/asset-transfer-basic/rest-api-typescript/src/index.ts @@ -12,10 +12,10 @@ * Fabric transaction error handling and retry logic * - fabric.ts * all the sample code which interacts with the Fabric SDK - * + * * The remaining files are related to the REST server aspects of the sample, * rather than Fabric itself: - * + * * - *.router.ts * details of the REST endpoints provided by the sample * - auth.ts diff --git a/asset-transfer-basic/rest-api-typescript/src/jobs.spec.ts b/asset-transfer-basic/rest-api-typescript/src/jobs.spec.ts index 43ba4d6d..e0a60fa7 100644 --- a/asset-transfer-basic/rest-api-typescript/src/jobs.spec.ts +++ b/asset-transfer-basic/rest-api-typescript/src/jobs.spec.ts @@ -3,7 +3,12 @@ */ import { Job, Queue } from 'bullmq'; -import { getJobCounts, getJobSummary, processSubmitTransactionJob, JobNotFoundError } from './jobs'; +import { + getJobCounts, + getJobSummary, + processSubmitTransactionJob, + JobNotFoundError, +} from './jobs'; import { Contract, Transaction } from 'fabric-network'; import { mock, MockProxy } from 'jest-mock-extended'; From 711f1f560b28028a3e8f11a05d1c481691f559e4 Mon Sep 17 00:00:00 2001 From: James Taylor Date: Mon, 13 Dec 2021 17:06:31 +0000 Subject: [PATCH 55/59] Use app.locals to store contracts and jobq MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit “The app.locals object has properties that are local variables within the application.” …which looks like a better option than app.get and app.set for app settings. Also passes app to the initJobQueueWorker function for consistency. Signed-off-by: James Taylor --- .../src/__tests__/api.test.ts | 22 +++++++++--------- .../rest-api-typescript/src/assets.router.ts | 14 +++++------ .../rest-api-typescript/src/health.router.ts | 6 ++--- .../rest-api-typescript/src/index.ts | 23 +++++++++---------- .../rest-api-typescript/src/jobs.router.ts | 2 +- .../rest-api-typescript/src/jobs.spec.ts | 15 ++++++++---- .../rest-api-typescript/src/jobs.ts | 11 ++++----- .../src/transactions.router.ts | 2 +- 8 files changed, 49 insertions(+), 46 deletions(-) diff --git a/asset-transfer-basic/rest-api-typescript/src/__tests__/api.test.ts b/asset-transfer-basic/rest-api-typescript/src/__tests__/api.test.ts index 06d29dba..b1097bc0 100644 --- a/asset-transfer-basic/rest-api-typescript/src/__tests__/api.test.ts +++ b/asset-transfer-basic/rest-api-typescript/src/__tests__/api.test.ts @@ -48,7 +48,7 @@ describe('Asset Transfer Besic REST API', () => { mockJob.id = '1'; mockJobQueue = mock(); mockJobQueue.add.mockResolvedValue(mockJob); - app.set('jobq', mockJobQueue); + app.locals.jobq = mockJobQueue; }); describe('/ready', () => { @@ -81,17 +81,17 @@ describe('Asset Transfer Besic REST API', () => { mockOrg1QsccContract.evaluateTransaction .calledWith('GetChainInfo') .mockResolvedValue(mockBlockchainInfoBuffer); - app.set(config.mspIdOrg1, { + app.locals[config.mspIdOrg1] = { qsccContract: mockOrg1QsccContract, - }); + }; const mockOrg2QsccContract = mock(); mockOrg2QsccContract.evaluateTransaction .calledWith('GetChainInfo') .mockResolvedValue(mockBlockchainInfoBuffer); - app.set(config.mspIdOrg2, { + app.locals[config.mspIdOrg2] = { qsccContract: mockOrg2QsccContract, - }); + }; const response = await request(app).get('/live'); expect(response.statusCode).toEqual(200); @@ -115,9 +115,9 @@ describe('Asset Transfer Besic REST API', () => { mockBasicContract.createTransaction .calledWith('GetAllAssets') .mockReturnValue(mockGetAllAssetsTransaction); - app.set(config.mspIdOrg1, { + app.locals[config.mspIdOrg1] = { assetContract: mockBasicContract, - }); + }; }); it('GET should respond with 401 unauthorized json when an invalid API key is specified', async () => { @@ -276,9 +276,9 @@ describe('Asset Transfer Besic REST API', () => { .calledWith('ReadAsset') .mockReturnValue(mockReadAssetTransaction); - app.set(config.mspIdOrg1, { + app.locals[config.mspIdOrg1] = { assetContract: mockBasicContract, - }); + }; }); it('OPTIONS should respond with 401 unauthorized json when an invalid API key is specified', async () => { @@ -663,9 +663,9 @@ describe('Asset Transfer Besic REST API', () => { mockQsccContract.createTransaction .calledWith('GetTransactionByID') .mockReturnValue(mockGetTransactionByIDTransaction); - app.set(config.mspIdOrg1, { + app.locals[config.mspIdOrg1] = { qsccContract: mockQsccContract, - }); + }; }); it('GET should respond with 401 unauthorized json when an invalid API key is specified', async () => { diff --git a/asset-transfer-basic/rest-api-typescript/src/assets.router.ts b/asset-transfer-basic/rest-api-typescript/src/assets.router.ts index 5d6c96f5..0af215d1 100644 --- a/asset-transfer-basic/rest-api-typescript/src/assets.router.ts +++ b/asset-transfer-basic/rest-api-typescript/src/assets.router.ts @@ -38,7 +38,7 @@ assetsRouter.get('/', async (req: Request, res: Response) => { try { const mspId = req.user as string; - const contract = req.app.get(mspId).assetContract as Contract; + const contract = req.app.locals[mspId]?.assetContract as Contract; const data = await evatuateTransaction(contract, 'GetAllAssets'); let assets = []; @@ -82,7 +82,7 @@ assetsRouter.post( const assetId = req.body.id; try { - const submitQueue = req.app.get('jobq') as Queue; + const submitQueue = req.app.locals.jobq as Queue; const jobId = await addSubmitTransactionJob( submitQueue, mspId, @@ -120,7 +120,7 @@ assetsRouter.options('/:assetId', async (req: Request, res: Response) => { try { const mspId = req.user as string; - const contract = req.app.get(mspId).assetContract as Contract; + const contract = req.app.locals[mspId]?.assetContract as Contract; const data = await evatuateTransaction(contract, 'AssetExists', assetId); const exists = data.toString() === 'true'; @@ -160,7 +160,7 @@ assetsRouter.get('/:assetId', async (req: Request, res: Response) => { try { const mspId = req.user as string; - const contract = req.app.get(mspId).assetContract as Contract; + const contract = req.app.locals[mspId]?.assetContract as Contract; const data = await evatuateTransaction(contract, 'ReadAsset', assetId); const asset = JSON.parse(data.toString()); @@ -222,7 +222,7 @@ assetsRouter.put( const assetId = req.params.assetId; try { - const submitQueue = req.app.get('jobq') as Queue; + const submitQueue = req.app.locals.jobq as Queue; const jobId = await addSubmitTransactionJob( submitQueue, mspId, @@ -284,7 +284,7 @@ assetsRouter.patch( const newOwner = req.body[0].value; try { - const submitQueue = req.app.get('jobq') as Queue; + const submitQueue = req.app.locals.jobq as Queue; const jobId = await addSubmitTransactionJob( submitQueue, mspId, @@ -320,7 +320,7 @@ assetsRouter.delete('/:assetId', async (req: Request, res: Response) => { const assetId = req.params.assetId; try { - const submitQueue = req.app.get('jobq') as Queue; + const submitQueue = req.app.locals.jobq as Queue; const jobId = await addSubmitTransactionJob( submitQueue, mspId, diff --git a/asset-transfer-basic/rest-api-typescript/src/health.router.ts b/asset-transfer-basic/rest-api-typescript/src/health.router.ts index 463ee0e4..e0e32588 100644 --- a/asset-transfer-basic/rest-api-typescript/src/health.router.ts +++ b/asset-transfer-basic/rest-api-typescript/src/health.router.ts @@ -30,9 +30,9 @@ healthRouter.get('/live', async (req: Request, res: Response) => { logger.debug(req.body, 'Liveness request received'); try { - const submitQueue = req.app.get('jobq') as Queue; - const qsccOrg1 = req.app.get(config.mspIdOrg1).qsccContract as Contract; - const qsccOrg2 = req.app.get(config.mspIdOrg2).qsccContract as Contract; + const submitQueue = req.app.locals.jobq as Queue; + const qsccOrg1 = req.app.locals[config.mspIdOrg1]?.qsccContract as Contract; + const qsccOrg2 = req.app.locals[config.mspIdOrg2]?.qsccContract as Contract; await Promise.all([ getBlockHeight(qsccOrg1), diff --git a/asset-transfer-basic/rest-api-typescript/src/index.ts b/asset-transfer-basic/rest-api-typescript/src/index.ts index e033db4c..c9a08574 100644 --- a/asset-transfer-basic/rest-api-typescript/src/index.ts +++ b/asset-transfer-basic/rest-api-typescript/src/index.ts @@ -32,7 +32,6 @@ * express server implementation details */ -import { Contract } from 'fabric-network'; import * as config from './config'; import { createGateway, @@ -62,7 +61,10 @@ async function main() { ); } - logger.info('Connecting to Fabric network'); + logger.info('Creating REST server'); + const app = await createServer(); + + logger.info('Connecting to Fabric network with org1 mspid'); const wallet = await createWallet(); const gatewayOrg1 = await createGateway( @@ -73,6 +75,9 @@ async function main() { const networkOrg1 = await getNetwork(gatewayOrg1); const contractsOrg1 = await getContracts(networkOrg1); + app.locals[config.mspIdOrg1] = contractsOrg1; + + logger.info('Connecting to Fabric network with org2 mspid'); const gatewayOrg2 = await createGateway( config.connectionProfileOrg2, config.mspIdOrg2, @@ -81,24 +86,18 @@ async function main() { const networkOrg2 = await getNetwork(gatewayOrg2); const contractsOrg2 = await getContracts(networkOrg2); - const assetContracts = new Map(); - assetContracts.set(config.mspIdOrg1, contractsOrg1.assetContract); - assetContracts.set(config.mspIdOrg2, contractsOrg2.assetContract); + app.locals[config.mspIdOrg2] = contractsOrg2; logger.info('Initialising submit job queue'); jobQueue = initJobQueue(); - jobQueueWorker = initJobQueueWorker(assetContracts); + jobQueueWorker = initJobQueueWorker(app); if (config.submitJobQueueScheduler === true) { logger.info('Initialising submit job queue scheduler'); jobQueueScheduler = initJobQueueScheduler(); } + app.locals.jobq = jobQueue; - logger.info('Creating REST server'); - const app = await createServer(); - app.set(config.mspIdOrg1, contractsOrg1); - app.set(config.mspIdOrg2, contractsOrg2); - app.set('jobq', jobQueue); - + logger.info('Starting REST server'); app.listen(config.port, () => { logger.info('REST server started on port: %d', config.port); }); diff --git a/asset-transfer-basic/rest-api-typescript/src/jobs.router.ts b/asset-transfer-basic/rest-api-typescript/src/jobs.router.ts index 77dc5fe9..2c021a29 100644 --- a/asset-transfer-basic/rest-api-typescript/src/jobs.router.ts +++ b/asset-transfer-basic/rest-api-typescript/src/jobs.router.ts @@ -17,7 +17,7 @@ jobsRouter.get('/:jobId', async (req: Request, res: Response) => { logger.debug('Read request received for job ID %s', jobId); try { - const submitQueue = req.app.get('jobq') as Queue; + const submitQueue = req.app.locals.jobq as Queue; const jobSummary = await getJobSummary(submitQueue, jobId); diff --git a/asset-transfer-basic/rest-api-typescript/src/jobs.spec.ts b/asset-transfer-basic/rest-api-typescript/src/jobs.spec.ts index e0a60fa7..93e83f8b 100644 --- a/asset-transfer-basic/rest-api-typescript/src/jobs.spec.ts +++ b/asset-transfer-basic/rest-api-typescript/src/jobs.spec.ts @@ -11,6 +11,7 @@ import { } from './jobs'; import { Contract, Transaction } from 'fabric-network'; import { mock, MockProxy } from 'jest-mock-extended'; +import { Application } from 'express'; describe('initJobQueue', () => { it.todo('write tests'); @@ -164,6 +165,7 @@ describe('getJobCounts', () => { const mockSavedState = Buffer.from('MOCK SAVED STATE'); let mockTransaction: MockProxy; let mockContract: MockProxy; + let mockApplication: MockProxy; let mockJob: MockProxy; beforeEach(() => { @@ -179,6 +181,9 @@ describe('getJobCounts', () => { .mockReturnValue(mockTransaction); mockContracts.set('mockMspid', mockContract); + mockApplication = mock(); + mockApplication.locals.mockMspid = { assetContract: mockContract }; + mockJob = mock(); }); @@ -188,7 +193,7 @@ describe('getJobCounts', () => { }; const jobResult = await processSubmitTransactionJob( - mockContracts, + mockApplication, mockJob ); @@ -209,7 +214,7 @@ describe('getJobCounts', () => { .mockResolvedValue(mockPayload); const jobResult = await processSubmitTransactionJob( - mockContracts, + mockApplication, mockJob ); @@ -231,7 +236,7 @@ describe('getJobCounts', () => { .mockResolvedValue(mockPayload); const jobResult = await processSubmitTransactionJob( - mockContracts, + mockApplication, mockJob ); @@ -257,7 +262,7 @@ describe('getJobCounts', () => { ); const jobResult = await processSubmitTransactionJob( - mockContracts, + mockApplication, mockJob ); @@ -280,7 +285,7 @@ describe('getJobCounts', () => { .mockRejectedValue(new Error('MOCK ERROR')); await expect(async () => { - await processSubmitTransactionJob(mockContracts, mockJob); + await processSubmitTransactionJob(mockApplication, mockJob); }).rejects.toThrow('MOCK ERROR'); }); }); diff --git a/asset-transfer-basic/rest-api-typescript/src/jobs.ts b/asset-transfer-basic/rest-api-typescript/src/jobs.ts index 2b4f2399..de2ab458 100644 --- a/asset-transfer-basic/rest-api-typescript/src/jobs.ts +++ b/asset-transfer-basic/rest-api-typescript/src/jobs.ts @@ -6,6 +6,7 @@ */ import { ConnectionOptions, Job, Queue, QueueScheduler, Worker } from 'bullmq'; +import { Application } from 'express'; import { Contract, Transaction } from 'fabric-network'; import * as config from './config'; import { getRetryAction, RetryAction } from './errors'; @@ -69,13 +70,11 @@ export const initJobQueue = (): Queue => { return submitQueue; }; -export const initJobQueueWorker = ( - contracts: Map -): Worker => { +export const initJobQueueWorker = (app: Application): Worker => { const worker = new Worker( config.JOB_QUEUE_NAME, async (job): Promise => { - return await processSubmitTransactionJob(contracts, job); + return await processSubmitTransactionJob(app, job); }, { connection, concurrency: config.submitJobConcurrency } ); @@ -229,12 +228,12 @@ export const getJobCounts = async ( * The job will be retried if this function throws an error */ export const processSubmitTransactionJob = async ( - contracts: Map, + app: Application, job: Job ): Promise => { logger.debug({ jobId: job.id, jobName: job.name }, 'Processing job'); - const contract = contracts.get(job.data.mspid); + const contract = app.locals[job.data.mspid]?.assetContract as Contract; if (contract === undefined) { logger.error( { jobId: job.id, jobName: job.name }, diff --git a/asset-transfer-basic/rest-api-typescript/src/transactions.router.ts b/asset-transfer-basic/rest-api-typescript/src/transactions.router.ts index 4b729951..d092337d 100644 --- a/asset-transfer-basic/rest-api-typescript/src/transactions.router.ts +++ b/asset-transfer-basic/rest-api-typescript/src/transactions.router.ts @@ -21,7 +21,7 @@ transactionsRouter.get( logger.debug('Read request received for transaction ID %s', transactionId); try { - const qsccContract = req.app.get(mspId).qsccContract as Contract; + const qsccContract = req.app.locals[mspId]?.qsccContract as Contract; const validationCode = await getTransactionValidationCode( qsccContract, From 5447e3534d783ccf47a59a1d3271edf0978663e2 Mon Sep 17 00:00:00 2001 From: James Taylor Date: Tue, 14 Dec 2021 13:42:46 +0000 Subject: [PATCH 56/59] Add node, and npm version requirements Match the versions required in asset-transfer-basic/application-typescript Also update to latest version of fabric-network Signed-off-by: James Taylor --- .../rest-api-typescript/package-lock.json | 107 +++++++++--------- .../rest-api-typescript/package.json | 6 +- 2 files changed, 59 insertions(+), 54 deletions(-) diff --git a/asset-transfer-basic/rest-api-typescript/package-lock.json b/asset-transfer-basic/rest-api-typescript/package-lock.json index c56206e9..c7d078ae 100644 --- a/asset-transfer-basic/rest-api-typescript/package-lock.json +++ b/asset-transfer-basic/rest-api-typescript/package-lock.json @@ -547,17 +547,18 @@ } }, "@grpc/grpc-js": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.3.4.tgz", - "integrity": "sha512-AxtZcm0mArQhY9z8T3TynCYVEaSKxNCa9mVhVwBCUnsuUEe8Zn94bPYYKVQSLt+hJJ1y0ukr3mUvtWfcATL/IQ==", + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.4.4.tgz", + "integrity": "sha512-a6222b7Dl6fIlMgzVl7e+NiRoLiZFbpcwvBH2Oli56Bn7W4/3Ld+86hK4ffPn5rx2DlDidmIcvIJiOQXyhv9gA==", "requires": { + "@grpc/proto-loader": "^0.6.4", "@types/node": ">=12.12.47" } }, "@grpc/proto-loader": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.6.3.tgz", - "integrity": "sha512-AtMWwb7kY8DdtwIQh2hC4YFM1MzZ22lMA+gjbnCYDgICt14vX2tCa59bDrEjFyOI4LvORjpvT/UhHUdKvsX8og==", + "version": "0.6.7", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.6.7.tgz", + "integrity": "sha512-QzTPIyJxU0u+r2qGe8VMl3j/W2ryhEvBv7hc42OjYfthSj370fUrb7na65rG6w3YLZS/fb8p89iTBobfWGDgdw==", "requires": { "@types/long": "^4.0.1", "lodash.camelcase": "^4.3.0", @@ -1390,9 +1391,9 @@ } }, "@types/tough-cookie": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.0.tgz", - "integrity": "sha512-I99sngh224D0M7XgW1s120zxCt3VYQ3IQsuw3P3jbq5GG4yc79+ZjyKznyOGIQrflfylLgcfekeZW/vk0yng6A==" + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.1.tgz", + "integrity": "sha512-Y0K95ThC3esLEYD6ZuqNek29lNX2EM1qxV8y2FTLUB0ff5wWrk7az+mLrnNFUnaXcgKye22+sFBRXOgpPILZNg==" }, "@types/yargs": { "version": "15.0.14", @@ -1647,9 +1648,9 @@ } }, "ansi-regex": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz", - "integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==" + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==" }, "ansi-styles": { "version": "3.2.1", @@ -1750,11 +1751,11 @@ "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==" }, "axios": { - "version": "0.21.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.1.tgz", - "integrity": "sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA==", + "version": "0.21.4", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz", + "integrity": "sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==", "requires": { - "follow-redirects": "^1.10.0" + "follow-redirects": "^1.14.0" } }, "axios-cookiejar-support": { @@ -2899,15 +2900,15 @@ "integrity": "sha1-Ys8SAjTGg3hdkCNIqADvPgzCC8A=" }, "fabric-common": { - "version": "2.2.8", - "resolved": "https://registry.npmjs.org/fabric-common/-/fabric-common-2.2.8.tgz", - "integrity": "sha512-lUOb2Sq645XcfIrtH6jMBaPiPUmFaHqMjGEK7uix1al0ITsNUUvtYD17MJfj/Pr0yhj0KjTI0FF1Ep3ZSL7kXg==", + "version": "2.2.10", + "resolved": "https://registry.npmjs.org/fabric-common/-/fabric-common-2.2.10.tgz", + "integrity": "sha512-0FRY8M906D0B/NGyPKDbLoorhHCaxlus5lv9X7+sf+M/UnzBCQIps5XDhO2KJVyq4hP4XUwgJV8zBBpxSmN3iQ==", "requires": { "callsite": "^1.0.0", "elliptic": "^6.5.4", - "fabric-protos": "2.2.8", + "fabric-protos": "2.2.10", "js-sha3": "^0.8.0", - "jsrsasign": "^8.0.24", + "jsrsasign": "^10.4.1", "long": "^4.0.0", "nconf": "^0.11.2", "pkcs11js": "^1.0.6", @@ -2918,20 +2919,20 @@ } }, "fabric-network": { - "version": "2.2.8", - "resolved": "https://registry.npmjs.org/fabric-network/-/fabric-network-2.2.8.tgz", - "integrity": "sha512-/kFgTtNA2jqY26HeEpti56G7dPAEef2fX3ebNfL/mAtJxA0Z0YXK3Jwd1N7wGCRRu+lriPd3a0wi7RPIgwAcCw==", + "version": "2.2.10", + "resolved": "https://registry.npmjs.org/fabric-network/-/fabric-network-2.2.10.tgz", + "integrity": "sha512-S6ITwBoLTfR9mokWqmAoHW2++VL1F5NS4LMJKz/tXbCPJcg6JyQGn07/OOHtqESchVKbadzvwrahy93s3CBFmQ==", "requires": { - "fabric-common": "2.2.8", - "fabric-protos": "2.2.8", + "fabric-common": "2.2.10", + "fabric-protos": "2.2.10", "long": "^4.0.0", "nano": "^9.0.3" } }, "fabric-protos": { - "version": "2.2.8", - "resolved": "https://registry.npmjs.org/fabric-protos/-/fabric-protos-2.2.8.tgz", - "integrity": "sha512-5e3MDLtXdsZpXs92kfTGRirIomaaQ3MaKQ59kp0y9QtYZGced4k9Donl1G3nREoBi0yy1bp45lkDnjRIOG9v+g==", + "version": "2.2.10", + "resolved": "https://registry.npmjs.org/fabric-protos/-/fabric-protos-2.2.10.tgz", + "integrity": "sha512-6ApPgneH/UxsB9QbwPzHEucsCVMnwacyuyHTYxpfj0/ZydWIoNThNsSJEfBdmwhupLG5w5vVup/q/CvhVw3Vmg==", "requires": { "@grpc/grpc-js": "^1.3.4", "@grpc/proto-loader": "^0.6.2", @@ -3101,9 +3102,9 @@ "dev": true }, "follow-redirects": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.1.tgz", - "integrity": "sha512-HWqDgT7ZEkqRzBvc2s64vSZ/hfOceEol3ac/7tKwzuvEyWx3/4UegXh5oBOIotkGsObyk3xznnSRVADBgWSQVg==" + "version": "1.14.6", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.6.tgz", + "integrity": "sha512-fhUl5EwSJbbl8AR+uYL2KQDxLkdSjZGR36xy46AO7cOMTrCMON6Sa28FmAnC2tRTDbd/Uuzz3aJBv7EBN7JH8A==" }, "form-data": { "version": "3.0.1", @@ -5029,9 +5030,9 @@ } }, "jsrsasign": { - "version": "8.0.24", - "resolved": "https://registry.npmjs.org/jsrsasign/-/jsrsasign-8.0.24.tgz", - "integrity": "sha512-u45jAyusqUpyGbFc2IbHoeE4rSkoBWQgLe/w99temHenX+GyCz4nflU5sjK7ajU1ffZTezl6le7u43Yjr/lkQg==" + "version": "10.5.1", + "resolved": "https://registry.npmjs.org/jsrsasign/-/jsrsasign-10.5.1.tgz", + "integrity": "sha512-yW0fq87KNZFw4Pn5ySllXs3ztZAROQZczEheKZTqmiNpCe/Xj9r5NhuAQ7MXTOyEZGJ/+MPHGTsfbgPFaLpwHQ==" }, "kleur": { "version": "3.0.3", @@ -5270,15 +5271,15 @@ "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" }, "nan": { - "version": "2.14.2", - "resolved": "https://registry.npmjs.org/nan/-/nan-2.14.2.tgz", - "integrity": "sha512-M2ufzIiINKCuDfBSAUr1vWQ+vuVcA9kqx8JJUsbQi6yf1uGRyb7HfpdfUr5qLXf3B/t8dPvcjhKMmlfnP47EzQ==", + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.15.0.tgz", + "integrity": "sha512-8ZtvEnA2c5aYCZYd1cvgdnU6cqwixRoYg70xPLWUws5ORTa/lnw+u4amixRS/Ac5U5mQVgp9pnlSUnbNWFaWZQ==", "optional": true }, "nano": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/nano/-/nano-9.0.3.tgz", - "integrity": "sha512-NFI8+6q5ihnozH6qK+BJ+ilnPfZzBhlUswaFgqUvSp2EN5eJ2BMxbzkYiBsN+waa+N95FculCdbneDmzLWfXaQ==", + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/nano/-/nano-9.0.5.tgz", + "integrity": "sha512-fEAhwAdXh4hDDnC8cYJtW6D8ivOmpvFAqT90+zEuQREpRkzA/mJPcI4EKv15JUdajaqiLTXNoKK6PaRF+/06DQ==", "requires": { "@types/tough-cookie": "^4.0.0", "axios": "^0.21.1", @@ -5288,9 +5289,9 @@ }, "dependencies": { "qs": { - "version": "6.10.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.10.1.tgz", - "integrity": "sha512-M528Hph6wsSVOBiYUnGf+K/7w0hNshs/duGsNXPUCLH5XAqjEtiPGwNONLV0tBH8NoGb0mvD5JubnUTrujKDTg==", + "version": "6.10.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.10.2.tgz", + "integrity": "sha512-mSIdjzqznWgfd4pMii7sHtaYF8rx8861hBO80SraY5GT0XQibWZWJSid0avzHGkDIZLImux2S5mXO0Hfct2QCw==", "requires": { "side-channel": "^1.0.4" } @@ -5609,9 +5610,9 @@ } }, "pkcs11js": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/pkcs11js/-/pkcs11js-1.2.5.tgz", - "integrity": "sha512-ZOCi2ZqKV6LprMmODsQKxgxnwGyy5nQ+nbI6QeS1M5B7gaH09xIcz8BomukrtyLHs/z3eQvvzy1SAFYXrYOG4w==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/pkcs11js/-/pkcs11js-1.2.6.tgz", + "integrity": "sha512-G3mgp0jcTO2A0fcqPdHEU4xYsmZgztMH10RmtUzTjf3pWxWaX7K3wTWVZInE6OaBucUS3d6St+8eUkqO44Hi3Q==", "optional": true, "requires": { "nan": "^2.14.2" @@ -6382,9 +6383,9 @@ } }, "tmpl": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.4.tgz", - "integrity": "sha1-I2QN17QtAEM5ERQIIOXPRA5SHdE=", + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", "dev": true }, "to-fast-properties": { @@ -6619,9 +6620,9 @@ } }, "validator": { - "version": "13.6.0", - "resolved": "https://registry.npmjs.org/validator/-/validator-13.6.0.tgz", - "integrity": "sha512-gVgKbdbHgtxpRyR8K0O6oFZPhhB5tT1jeEHZR0Znr9Svg03U0+r9DXWMrnRAB+HtCStDQKlaIZm42tVsVjqtjg==" + "version": "13.7.0", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.7.0.tgz", + "integrity": "sha512-nYXQLCBkpJ8X6ltALua9dRrZDHVYxjJ1wgskNt1lH9fzGjs3tgojGSCBjmEPwkWS1y29+DrizMTW19Pr9uB2nw==" }, "vary": { "version": "1.1.2", diff --git a/asset-transfer-basic/rest-api-typescript/package.json b/asset-transfer-basic/rest-api-typescript/package.json index 6a9317ff..c8350cd0 100644 --- a/asset-transfer-basic/rest-api-typescript/package.json +++ b/asset-transfer-basic/rest-api-typescript/package.json @@ -3,13 +3,17 @@ "version": "1.0.0", "description": "Asset Transfer Basic REST API implemented in TypeScript", "main": "dist/index.js", + "engines": { + "node": ">=12", + "npm": ">=5" + }, "dependencies": { "bullmq": "^1.47.2", "dotenv": "^10.0.0", "env-var": "^7.0.1", "express": "^4.17.1", "express-validator": "^6.12.0", - "fabric-network": "^2.2.8", + "fabric-network": "^2.2.10", "helmet": "^4.6.0", "http-status-codes": "^2.1.4", "ioredis": "^4.27.8", From dfe79f7fb48c1033511a5ad02bd2cb7e18ee77dc Mon Sep 17 00:00:00 2001 From: James Taylor Date: Wed, 15 Dec 2021 11:50:05 +0000 Subject: [PATCH 57/59] Add comments to jobs.ts Signed-off-by: James Taylor --- .../rest-api-typescript/src/jobs.ts | 270 ++++++++++-------- 1 file changed, 144 insertions(+), 126 deletions(-) diff --git a/asset-transfer-basic/rest-api-typescript/src/jobs.ts b/asset-transfer-basic/rest-api-typescript/src/jobs.ts index de2ab458..c46af0e5 100644 --- a/asset-transfer-basic/rest-api-typescript/src/jobs.ts +++ b/asset-transfer-basic/rest-api-typescript/src/jobs.ts @@ -26,7 +26,6 @@ export type JobResult = { transactionError?: string; }; -// TODO include attempts made? export type JobSummary = { jobId: string; transactionIds: string[]; @@ -53,6 +52,9 @@ const connection: ConnectionOptions = { password: config.redisPassword, }; +/* + * Set up the queue for submit jobs + */ export const initJobQueue = (): Queue => { const submitQueue = new Queue(config.JOB_QUEUE_NAME, { connection, @@ -70,6 +72,10 @@ export const initJobQueue = (): Queue => { return submitQueue; }; +/* + * Set up a worker to process submit jobs on the queue, using the + * processSubmitTransactionJob function below + */ export const initJobQueueWorker = (app: Application): Worker => { const worker = new Worker( config.JOB_QUEUE_NAME, @@ -80,7 +86,7 @@ export const initJobQueueWorker = (app: Application): Worker => { ); worker.on('failed', (job) => { - logger.error({ job }, 'Job failed'); // WHY?! + logger.warn({ job }, 'Job failed'); }); // Important: need to handle this error otherwise worker may stop @@ -98,130 +104,6 @@ export const initJobQueueWorker = (app: Application): Worker => { return worker; }; -export const initJobQueueScheduler = (): QueueScheduler => { - const queueScheduler = new QueueScheduler(config.JOB_QUEUE_NAME, { - connection, - }); - - queueScheduler.on('failed', (jobId, failedReason) => { - // TODO when does this happen, and how should it be handled? - logger.error({ jobId, failedReason }, 'Queue sceduler failure'); - }); - - return queueScheduler; -}; - -export const addSubmitTransactionJob = async ( - submitQueue: Queue, - mspid: string, - transactionName: string, - ...transactionArgs: string[] -): Promise => { - const jobName = `submit ${transactionName} transaction`; - const job = await submitQueue.add(jobName, { - mspid, - transactionName, - transactionArgs: transactionArgs, - transactionIds: [], - }); - - if (job?.id === undefined) { - throw new Error('Submit transaction job ID not available'); - } - - return job.id; -}; - -/* - * Gets a summary for the jobs endpoint - */ -export const getJobSummary = async ( - queue: Queue, - jobId: string -): Promise => { - const job: Job | undefined = await queue.getJob(jobId); - logger.debug({ job }, 'Got job'); - - if (!(job && job.id != undefined)) { - throw new JobNotFoundError(`Job ${jobId} not found`, jobId); - } - - let transactionIds: string[]; - if (job.data && job.data.transactionIds) { - transactionIds = job.data.transactionIds; - } else { - transactionIds = []; - } - - let transactionError; - let transactionPayload; - const returnValue = job.returnvalue; - if (returnValue) { - if (returnValue.transactionError) { - transactionError = returnValue.transactionError; - } - - if ( - returnValue.transactionPayload && - returnValue.transactionPayload.length > 0 - ) { - transactionPayload = returnValue.transactionPayload.toString(); - } else { - transactionPayload = ''; - } - } - - const jobSummary: JobSummary = { - jobId: job.id, - transactionIds, - transactionError, - transactionPayload, - }; - - return jobSummary; -}; - -export const updateJobData = async ( - job: Job, - transaction: Transaction | undefined -): Promise => { - const newData = { ...job.data }; - - if (transaction != undefined) { - const transationIds = ([] as string[]).concat( - newData.transactionIds, - transaction.getTransactionId() - ); - newData.transactionIds = transationIds; - - newData.transactionState = transaction.serialize(); - } else { - newData.transactionState = undefined; - } - - await job.update(newData); -}; - -/* - * Get the current job counts - * - * This function is used for the liveness REST endpoint - */ -export const getJobCounts = async ( - queue: Queue -): Promise<{ [index: string]: number }> => { - const jobCounts = await queue.getJobCounts( - 'active', - 'completed', - 'delayed', - 'failed', - 'waiting' - ); - logger.debug({ jobCounts }, 'Current job counts'); - - return jobCounts; -}; - /* * Process a submit transaction request from the job queue * @@ -326,3 +208,139 @@ export const processSubmitTransactionJob = async ( throw err; } }; + +/* + * Set up a scheduler for the submit job queue + * + * This manages stalled and delayed jobs and is required for retries with backoff + */ +export const initJobQueueScheduler = (): QueueScheduler => { + const queueScheduler = new QueueScheduler(config.JOB_QUEUE_NAME, { + connection, + }); + + queueScheduler.on('failed', (jobId, failedReason) => { + logger.error({ jobId, failedReason }, 'Queue sceduler failure'); + }); + + return queueScheduler; +}; + +/* + * Helper to add a new submit transaction job to the queue + */ +export const addSubmitTransactionJob = async ( + submitQueue: Queue, + mspid: string, + transactionName: string, + ...transactionArgs: string[] +): Promise => { + const jobName = `submit ${transactionName} transaction`; + const job = await submitQueue.add(jobName, { + mspid, + transactionName, + transactionArgs: transactionArgs, + transactionIds: [], + }); + + if (job?.id === undefined) { + throw new Error('Submit transaction job ID not available'); + } + + return job.id; +}; + +/* + * Helper to update the data for an existing job + */ +export const updateJobData = async ( + job: Job, + transaction: Transaction | undefined +): Promise => { + const newData = { ...job.data }; + + if (transaction != undefined) { + const transationIds = ([] as string[]).concat( + newData.transactionIds, + transaction.getTransactionId() + ); + newData.transactionIds = transationIds; + + newData.transactionState = transaction.serialize(); + } else { + newData.transactionState = undefined; + } + + await job.update(newData); +}; + +/* + * Gets a job summary + * + * This function is used for the jobs REST endpoint + */ +export const getJobSummary = async ( + queue: Queue, + jobId: string +): Promise => { + const job: Job | undefined = await queue.getJob(jobId); + logger.debug({ job }, 'Got job'); + + if (!(job && job.id != undefined)) { + throw new JobNotFoundError(`Job ${jobId} not found`, jobId); + } + + let transactionIds: string[]; + if (job.data && job.data.transactionIds) { + transactionIds = job.data.transactionIds; + } else { + transactionIds = []; + } + + let transactionError; + let transactionPayload; + const returnValue = job.returnvalue; + if (returnValue) { + if (returnValue.transactionError) { + transactionError = returnValue.transactionError; + } + + if ( + returnValue.transactionPayload && + returnValue.transactionPayload.length > 0 + ) { + transactionPayload = returnValue.transactionPayload.toString(); + } else { + transactionPayload = ''; + } + } + + const jobSummary: JobSummary = { + jobId: job.id, + transactionIds, + transactionError, + transactionPayload, + }; + + return jobSummary; +}; + +/* + * Get the current job counts + * + * This function is used for the liveness REST endpoint + */ +export const getJobCounts = async ( + queue: Queue +): Promise<{ [index: string]: number }> => { + const jobCounts = await queue.getJobCounts( + 'active', + 'completed', + 'delayed', + 'failed', + 'waiting' + ); + logger.debug({ jobCounts }, 'Current job counts'); + + return jobCounts; +}; From fce803af246a9c6ce2e95bb26221a9042c6a0138 Mon Sep 17 00:00:00 2001 From: James Taylor Date: Wed, 15 Dec 2021 12:36:18 +0000 Subject: [PATCH 58/59] Add jobs spec tests Signed-off-by: James Taylor --- .../rest-api-typescript/src/jobs.spec.ts | 87 +++++++++++++++---- .../rest-api-typescript/src/jobs.ts | 4 +- 2 files changed, 74 insertions(+), 17 deletions(-) diff --git a/asset-transfer-basic/rest-api-typescript/src/jobs.spec.ts b/asset-transfer-basic/rest-api-typescript/src/jobs.spec.ts index 93e83f8b..1d8b7fe2 100644 --- a/asset-transfer-basic/rest-api-typescript/src/jobs.spec.ts +++ b/asset-transfer-basic/rest-api-typescript/src/jobs.spec.ts @@ -4,29 +4,54 @@ import { Job, Queue } from 'bullmq'; import { + addSubmitTransactionJob, getJobCounts, getJobSummary, processSubmitTransactionJob, JobNotFoundError, + updateJobData, } from './jobs'; import { Contract, Transaction } from 'fabric-network'; import { mock, MockProxy } from 'jest-mock-extended'; import { Application } from 'express'; -describe('initJobQueue', () => { - it.todo('write tests'); -}); - -describe('initJobQueueWorker', () => { - it.todo('write tests'); -}); - -describe('initJobQueueScheduler', () => { - it.todo('write tests'); -}); - describe('addSubmitTransactionJob', () => { - it.todo('write tests'); + let mockJob: MockProxy; + let mockQueue: MockProxy; + + beforeEach(() => { + mockJob = mock(); + mockQueue = mock(); + mockQueue.add.mockResolvedValue(mockJob); + }); + + it('returns the new job ID', async () => { + mockJob.id = 'mockJobId'; + + const jobid = await addSubmitTransactionJob( + mockQueue, + 'mockMspId', + 'txn', + 'arg1', + 'arg2' + ); + + expect(jobid).toBe('mockJobId'); + }); + + it('throws an error if there is no job ID', async () => { + mockJob.id = undefined; + + await expect(async () => { + await addSubmitTransactionJob( + mockQueue, + 'mockMspId', + 'txn', + 'arg1', + 'arg2' + ); + }).rejects.toThrowError('Submit transaction job ID not available'); + }); }); describe('getJobSummary', () => { @@ -133,8 +158,40 @@ describe('getJobSummary', () => { }); }); -describe('updateSubmitTransactionJobStateData', () => { - it.todo('write tests'); +describe('updateJobData', () => { + let mockJob: MockProxy; + + beforeEach(() => { + mockJob = mock(); + mockJob.data = { + transactionIds: ['txn1'], + }; + }); + + it('stores the serialized state in the job data if a transaction is specified', async () => { + const mockSavedState = Buffer.from('MOCK SAVED STATE'); + const mockTransaction = mock(); + mockTransaction.getTransactionId.mockReturnValue('txn2'); + mockTransaction.serialize.mockReturnValue(mockSavedState); + + await updateJobData(mockJob, mockTransaction); + + expect(mockJob.update).toBeCalledTimes(1); + expect(mockJob.update).toBeCalledWith({ + transactionIds: ['txn1', 'txn2'], + transactionState: mockSavedState, + }); + }); + + it('removes the serialized state from the job data if a transaction is not specified', async () => { + await updateJobData(mockJob, undefined); + + expect(mockJob.update).toBeCalledTimes(1); + expect(mockJob.update).toBeCalledWith({ + transactionIds: ['txn1'], + transactionState: undefined, + }); + }); }); describe('getJobCounts', () => { diff --git a/asset-transfer-basic/rest-api-typescript/src/jobs.ts b/asset-transfer-basic/rest-api-typescript/src/jobs.ts index c46af0e5..64307982 100644 --- a/asset-transfer-basic/rest-api-typescript/src/jobs.ts +++ b/asset-transfer-basic/rest-api-typescript/src/jobs.ts @@ -53,7 +53,7 @@ const connection: ConnectionOptions = { }; /* - * Set up the queue for submit jobs + * Set up the queue for submit jobs */ export const initJobQueue = (): Queue => { const submitQueue = new Queue(config.JOB_QUEUE_NAME, { @@ -276,7 +276,7 @@ export const updateJobData = async ( /* * Gets a job summary - * + * * This function is used for the jobs REST endpoint */ export const getJobSummary = async ( From af2f6e005c3a3380a21795d99c38139bad088bc2 Mon Sep 17 00:00:00 2001 From: James Taylor Date: Tue, 14 Dec 2021 12:20:49 +0000 Subject: [PATCH 59/59] Prepare REST sample for fabric-samples repository Signed-off-by: James Taylor --- .github/workflows/publish.yaml | 54 ----- .gitignore | 1 - LICENSE | 201 ------------------ README.md | 52 ----- .../rest-api-typescript/README.md | 6 +- 5 files changed, 3 insertions(+), 311 deletions(-) delete mode 100644 .github/workflows/publish.yaml delete mode 100644 .gitignore delete mode 100644 LICENSE delete mode 100644 README.md diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml deleted file mode 100644 index d24714b2..00000000 --- a/.github/workflows/publish.yaml +++ /dev/null @@ -1,54 +0,0 @@ -name: fabric-rest-sample - -on: - push: - # Publish `main` as Docker `latest` image. - branches: - - main - - # Publish `v1.2.3` tags as releases. - tags: - - v* - - # Run tests for any PRs. - pull_request: - -env: - IMAGE_NAME: fabric-rest-sample - SOURCE_FOLDER: asset-transfer-basic/rest-api-typescript - -jobs: - # Push image to GitHub Packages. - # See also https://docs.docker.com/docker-hub/builds/ - push: - runs-on: ubuntu-latest - permissions: - packages: write - contents: read - - steps: - - uses: actions/checkout@v2 - - - name: Build image - run: docker build --tag $IMAGE_NAME --label "runnumber=${GITHUB_RUN_ID}" $SOURCE_FOLDER - - - name: Log in to registry - # This is where you will update the PAT to GITHUB_TOKEN - run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin - - - name: Push image - run: | - IMAGE_ID=ghcr.io/${{ github.repository_owner }}/$IMAGE_NAME - - # Change all uppercase to lowercase - IMAGE_ID=$(echo $IMAGE_ID | tr '[A-Z]' '[a-z]') - # Strip git ref prefix from version - VERSION=$(echo "${{ github.ref }}" | sed -e 's,.*/\(.*\),\1,') - # Strip "v" prefix from tag name - [[ "${{ github.ref }}" == "refs/tags/"* ]] && VERSION=$(echo $VERSION | sed -e 's/^v//') - # Use Docker `latest` tag convention - [ "$VERSION" == "main" ] && VERSION=latest - echo IMAGE_ID=$IMAGE_ID - echo VERSION=$VERSION - docker tag $IMAGE_NAME $IMAGE_ID:$VERSION - docker push $IMAGE_ID:$VERSION \ No newline at end of file diff --git a/.gitignore b/.gitignore deleted file mode 100644 index b0b21603..00000000 --- a/.gitignore +++ /dev/null @@ -1 +0,0 @@ -local.http diff --git a/LICENSE b/LICENSE deleted file mode 100644 index 261eeb9e..00000000 --- a/LICENSE +++ /dev/null @@ -1,201 +0,0 @@ - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. diff --git a/README.md b/README.md deleted file mode 100644 index 076cf6c8..00000000 --- a/README.md +++ /dev/null @@ -1,52 +0,0 @@ -# Fabric REST sample - -Prototype sample REST server to demonstrate good Fabric Node SDK practices for parts of [FAB-18511](https://jira.hyperledger.org/browse/FAB-18511) - -The intention is to deliver the sample to the [asset-transfer-basic/rest-api-typescript directory of the fabric-samples repository](https://github.com/hyperledger/fabric-samples/tree/main/asset-transfer-basic) - -See the [sample readme for usage intructions](asset-transfer-basic/rest-api-typescript/README.md) - -## Overview - -The primary aim of this sample is to show how to write a long running client application using the Fabric Node SDK, i.e. without reconnecting for each transaction - -It should also show: - -- basic transaction retries -- long running event handling -- requests from multiple users - -## Next steps - -### Handling transaction errors - -Should transactions be retried _unless they fail_ with specific errors, e.g. duplicate transaction (the current implementation)? - -**or** - -Should transactions be retried _when they fail_ with specific errors? - -Also, transactions are currently only retried if they are successfully endorsed- does that seem reasonable? - -If the transaction failed because of MVCC_READ_CONFLICT, is a chance that it could pass when retrying? (Is MVCC_READ_CONFLICT an endorsement error?) - -### Handling other errors - -Need to make sure it's clear what went wrong and fail properly it necessary, for example when starting without a redis instance - -### Finish off unit tests - -Coverage is looking much better now but there are a few more todos - -### More comments - -Need to document what's going on and why, especially in the fabric.ts file! - -### Feedback - -- More people trying out the sample (and ideally trying to break it a bit!) -- Code review to merge sample into fabric-samples - -### Known problems - -See [issues](https://github.com/hyperledgendary/fabric-rest-sample/issues) diff --git a/asset-transfer-basic/rest-api-typescript/README.md b/asset-transfer-basic/rest-api-typescript/README.md index fda1e708..5bcb1f17 100644 --- a/asset-transfer-basic/rest-api-typescript/README.md +++ b/asset-transfer-basic/rest-api-typescript/README.md @@ -27,11 +27,11 @@ See [src/index.ts](src/index.ts) for a description of the sample code structure, ## Usage -**Note:** these instructions should work with the main branch of `fabric-samples` - To build and start the sample REST server, you'll need to [download and install an LTS version of node](https://nodejs.org/en/download/) -Clone this repository and change to the `fabric-rest-sample/asset-transfer-basic/rest-api-typescript` directory before running the following commands +Clone the `fabric-samples` repository and change to the `fabric-samples/asset-transfer-basic/rest-api-typescript` directory before running the following commands + +**Note:** these instructions should work with the main branch of `fabric-samples` Install dependencies