From 3b50404763cb484e1988fd75e64cdc1457b2167b Mon Sep 17 00:00:00 2001 From: James Taylor Date: Fri, 2 Jul 2021 12:13:15 +0100 Subject: [PATCH] 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) =>