Merge remote-tracking branch 'fabric-rest-sample/prepare-samples-pr' into rest-sample

This commit is contained in:
James Taylor 2021-12-15 14:26:49 +00:00
commit dd59991b57
33 changed files with 11904 additions and 0 deletions

View file

@ -0,0 +1,4 @@
node_modules
npm-debug.log
Dockerfile
.gitignore

View file

@ -0,0 +1,23 @@
# 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
#
HLF_CONNECTION_PROFILE_ORG1=
HLF_CERTIFICATE_ORG1=
HLF_PRIVATE_KEY_ORG1=
HLF_CONNECTION_PROFILE_ORG2=
HLF_CERTIFICATE_ORG2=
HLF_PRIVATE_KEY_ORG2=
ORG1_APIKEY=
ORG2_APIKEY=

View file

@ -0,0 +1,34 @@
{
"env": {
"node": true,
"es2021": true
},
"extends": [
"plugin:@typescript-eslint/recommended",
"plugin:prettier/recommended"
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": 12,
"sourceType": "module"
},
"plugins": [
"@typescript-eslint"
],
"rules": {
"@typescript-eslint/no-unused-vars": [
"error",
{
"argsIgnorePattern": "^_"
}
]
},
"overrides": [
{
"files": ["src/**/*.ts"],
"parserOptions": {
"project": ["./tsconfig.json"]
}
}
]
}

View file

@ -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

View file

@ -0,0 +1,3 @@
{
"singleQuote": true
}

View file

@ -0,0 +1,25 @@
FROM node:14-alpine3.14 AS build
RUN apk add --no-cache g++ make python3 dumb-init
WORKDIR /app
COPY --chown=node:node . /app
RUN npm ci
RUN npm run build
RUN npm prune --production
FROM node:14-alpine3.14
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
ENTRYPOINT [ "dumb-init", "--", "npm", "run"]
CMD ["start"]

View file

@ -0,0 +1,186 @@
# 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.4/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
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
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 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
```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 (Redis is used to store the queue of submit transactions)
```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 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.
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
```
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
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
```
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
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
```

View file

@ -0,0 +1,89 @@
// 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 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
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}}

View file

@ -0,0 +1,24 @@
version: '3'
# Replace network name with the fabric test-network name
services:
redis:
image: 'redis'
ports:
- 6379:6379
networks:
- fabric_test
nodeapp:
image: 'fabric-rest-sample'
command: ['start:dotenv']
ports:
- 3000:3000
env_file:
- ./.env
networks:
- fabric_test
networks:
fabric_test:
external: true

View file

@ -0,0 +1,208 @@
/*
* 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: ['<rootDir>/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,
};
// 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',
});

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,67 @@
{
"name": "asset-transfer-basic",
"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.10",
"helmet": "^4.6.0",
"http-status-codes": "^2.1.4",
"ioredis": "^4.27.8",
"passport": "^0.4.1",
"passport-headerapikey": "^1.2.2",
"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/jest": "^26.0.24",
"@types/node": "^15.14.7",
"@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",
"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",
"supertest": "^6.1.4",
"ts-jest": "^27.0.4",
"ts-node": "^10.1.0",
"typescript": "^4.3.5"
},
"scripts": {
"prebuild": "npm run lint",
"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: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 --maxmemory-policy noeviction",
"test": "jest"
},
"author": "Hyperledger",
"license": "Apache-2.0",
"private": true
}

View file

@ -0,0 +1,70 @@
#!/usr/bin/env bash
#
# 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/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/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
# Generated .env file
# See src/config.ts for details of all the available configuration variables
#
LOG_LEVEL=info
PORT=3000
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_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')"
REDIS_PORT=6379
ORG1_APIKEY=$(uuidgen)
ORG2_APIKEY=$(uuidgen)
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

View file

@ -0,0 +1,738 @@
/*
* SPDX-License-Identifier: Apache-2.0
*/
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
describe('Asset Transfer Besic REST API', () => {
let app: Application;
let mockJobQueue: MockProxy<Queue>;
beforeEach(async () => {
app = await createServer();
const mockJob = mock<Job>();
mockJob.id = '1';
mockJobQueue = mock<Queue>();
mockJobQueue.add.mockResolvedValue(mockJob);
app.locals.jobq = mockJobQueue;
});
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).toEqual({
status: 'OK',
timestamp: expect.any(String),
});
});
});
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<Contract>();
mockOrg1QsccContract.evaluateTransaction
.calledWith('GetChainInfo')
.mockResolvedValue(mockBlockchainInfoBuffer);
app.locals[config.mspIdOrg1] = {
qsccContract: mockOrg1QsccContract,
};
const mockOrg2QsccContract = mock<Contract>();
mockOrg2QsccContract.evaluateTransaction
.calledWith('GetChainInfo')
.mockResolvedValue(mockBlockchainInfoBuffer);
app.locals[config.mspIdOrg2] = {
qsccContract: mockOrg2QsccContract,
};
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', () => {
let mockGetAllAssetsTransaction: MockProxy<Transaction>;
beforeEach(() => {
mockGetAllAssetsTransaction = mock<Transaction>();
const mockBasicContract = mock<Contract>();
mockBasicContract.createTransaction
.calledWith('GetAllAssets')
.mockReturnValue(mockGetAllAssetsTransaction);
app.locals[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')
.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 () => {
mockGetAllAssetsTransaction.evaluate.mockResolvedValue(Buffer.from(''));
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 () => {
mockGetAllAssetsTransaction.evaluate.mockResolvedValue(
mockAllAssetsBuffer
);
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',
jobId: '1',
timestamp: expect.any(String),
});
});
});
describe('/api/assets/:id', () => {
let mockAssetExistsTransaction: MockProxy<Transaction>;
let mockReadAssetTransaction: MockProxy<Transaction>;
beforeEach(() => {
const mockBasicContract = mock<Contract>();
mockAssetExistsTransaction = mock<Transaction>();
mockBasicContract.createTransaction
.calledWith('AssetExists')
.mockReturnValue(mockAssetExistsTransaction);
mockReadAssetTransaction = mock<Transaction>();
mockBasicContract.createTransaction
.calledWith('ReadAsset')
.mockReturnValue(mockReadAssetTransaction);
app.locals[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')
.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 () => {
mockAssetExistsTransaction.evaluate
.calledWith('asset3')
.mockResolvedValue(Buffer.from('false'));
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 () => {
mockAssetExistsTransaction.evaluate
.calledWith('asset1')
.mockResolvedValue(Buffer.from('true'));
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 () => {
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');
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 () => {
mockReadAssetTransaction.evaluate
.calledWith('asset1')
.mockResolvedValue(mockAsset1Buffer);
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 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',
jobId: '1',
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 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',
jobId: '1',
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 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',
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(
'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 job ID', async () => {
const mockJob = mock<Job>();
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)
.get('/api/jobs/2')
.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({
jobId: '2',
transactionIds: ['txn1', 'txn2'],
transactionError: 'Mock error',
transactionPayload: 'Mock payload',
});
});
});
describe('/api/transactions/:id', () => {
let mockGetTransactionByIDTransaction: MockProxy<Transaction>;
beforeEach(() => {
mockGetTransactionByIDTransaction = mock<Transaction>();
const mockQsccContract = mock<Contract>();
mockQsccContract.createTransaction
.calledWith('GetTransactionByID')
.mockReturnValue(mockGetTransactionByIDTransaction);
app.locals[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')
.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 () => {
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');
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 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');
expect(response.statusCode).toEqual(200);
expect(response.header).toHaveProperty(
'content-type',
'application/json; charset=utf-8'
);
expect(response.body).toEqual({
transactionId: 'txn2',
validationCode: 'VALID',
});
});
});
});

View file

@ -0,0 +1,348 @@
/*
* SPDX-License-Identifier: Apache-2.0
*
* 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 { Queue } from 'bullmq';
import { AssetNotFoundError } from './errors';
import { evatuateTransaction } from './fabric';
import { addSubmitTransactionJob } from './jobs';
import { logger } from './logger';
const { ACCEPTED, BAD_REQUEST, INTERNAL_SERVER_ERROR, NOT_FOUND, OK } =
StatusCodes;
export const assetsRouter = express.Router();
assetsRouter.get('/', async (req: Request, res: Response) => {
logger.debug('Get all assets request received');
try {
const mspId = req.user as string;
const contract = req.app.locals[mspId]?.assetContract as Contract;
const data = await evatuateTransaction(contract, 'GetAllAssets');
let assets = [];
if (data.length > 0) {
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'),
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, 'Create asset request received');
const errors = validationResult(req);
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 mspId = req.user as string;
const assetId = req.body.id;
try {
const submitQueue = req.app.locals.jobq as Queue;
const jobId = await addSubmitTransactionJob(
submitQueue,
mspId,
'CreateAsset',
assetId,
req.body.color,
req.body.size,
req.body.owner,
req.body.appraisedValue
);
return res.status(ACCEPTED).json({
status: getReasonPhrase(ACCEPTED),
jobId: jobId,
timestamp: new Date().toISOString(),
});
} catch (err) {
logger.error(
{ err },
'Error processing create asset request for asset ID %s',
assetId
);
return res.status(INTERNAL_SERVER_ERROR).json({
status: getReasonPhrase(INTERNAL_SERVER_ERROR),
timestamp: new Date().toISOString(),
});
}
}
);
assetsRouter.options('/:assetId', async (req: Request, res: Response) => {
const assetId = req.params.assetId;
logger.debug('Asset options request received for asset ID %s', assetId);
try {
const mspId = req.user as string;
const contract = req.app.locals[mspId]?.assetContract as Contract;
const data = await evatuateTransaction(contract, 'AssetExists', assetId);
const exists = data.toString() === 'true';
if (exists) {
return res
.status(OK)
.set({
Allow: 'DELETE,GET,OPTIONS,PATCH,PUT',
})
.json({
status: getReasonPhrase(OK),
timestamp: new Date().toISOString(),
});
} else {
return res.status(NOT_FOUND).json({
status: getReasonPhrase(NOT_FOUND),
timestamp: new Date().toISOString(),
});
}
} catch (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(),
});
}
});
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 mspId = req.user as string;
const contract = req.app.locals[mspId]?.assetContract as Contract;
const data = await evatuateTransaction(contract, 'ReadAsset', assetId);
const asset = JSON.parse(data.toString());
return res.status(OK).json(asset);
} catch (err) {
logger.error(
{ err },
'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(),
});
}
});
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(),
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),
reason: 'VALIDATION_ERROR',
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),
reason: 'ASSET_ID_MISMATCH',
message: 'Asset IDs must match',
timestamp: new Date().toISOString(),
});
}
const mspId = req.user as string;
const assetId = req.params.assetId;
try {
const submitQueue = req.app.locals.jobq as Queue;
const jobId = await addSubmitTransactionJob(
submitQueue,
mspId,
'UpdateAsset',
assetId,
req.body.color,
req.body.size,
req.body.owner,
req.body.appraisedValue
);
return res.status(ACCEPTED).json({
status: getReasonPhrase(ACCEPTED),
jobId: jobId,
timestamp: new Date().toISOString(),
});
} catch (err) {
logger.error(
{ err },
'Error processing update asset request for asset ID %s',
assetId
);
return res.status(INTERNAL_SERVER_ERROR).json({
status: getReasonPhrase(INTERNAL_SERVER_ERROR),
timestamp: new Date().toISOString(),
});
}
}
);
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),
reason: 'VALIDATION_ERROR',
message: 'Invalid request body',
timestamp: new Date().toISOString(),
errors: errors.array(),
});
}
const mspId = req.user as string;
const assetId = req.params.assetId;
const newOwner = req.body[0].value;
try {
const submitQueue = req.app.locals.jobq as Queue;
const jobId = await addSubmitTransactionJob(
submitQueue,
mspId,
'TransferAsset',
assetId,
newOwner
);
return res.status(ACCEPTED).json({
status: getReasonPhrase(ACCEPTED),
jobId: jobId,
timestamp: new Date().toISOString(),
});
} catch (err) {
logger.error(
{ err },
'Error processing update asset request for asset ID %s',
req.params.assetId
);
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 mspId = req.user as string;
const assetId = req.params.assetId;
try {
const submitQueue = req.app.locals.jobq as Queue;
const jobId = await addSubmitTransactionJob(
submitQueue,
mspId,
'DeleteAsset',
assetId
);
return res.status(ACCEPTED).json({
status: getReasonPhrase(ACCEPTED),
jobId: jobId,
timestamp: new Date().toISOString(),
});
} catch (err) {
logger.error(
{ err },
'Error processing delete asset request for asset ID %s',
assetId
);
return res.status(INTERNAL_SERVER_ERROR).json({
status: getReasonPhrase(INTERNAL_SERVER_ERROR),
timestamp: new Date().toISOString(),
});
}
});

View file

@ -0,0 +1,60 @@
/*
* 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: 'X-API-Key', prefix: '' },
false,
function (apikey, done) {
logger.debug({ apikey }, 'Checking X-API-Key');
if (apikey === config.org1ApiKey) {
const user = config.mspIdOrg1;
logger.debug('User set to %s', user);
done(null, user);
} else if (apikey === config.org2ApiKey) {
const user = config.mspIdOrg2;
logger.debug('User set to %s', user);
done(null, user);
} else {
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 },
(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 }, async (err) => {
if (err) {
return next(err);
}
return next();
});
}
)(req, res, next);
};

View file

@ -0,0 +1,575 @@
/*
* 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('submitJobBackoffType', () => {
it('defaults to "fixed"', () => {
const config = require('./config');
expect(config.submitJobBackoffType).toBe('fixed');
});
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.submitJobBackoffType).toBe('exponential');
});
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: "SUBMIT_JOB_BACKOFF_TYPE" should be one of [fixed, exponential]'
);
});
});
describe('submitJobBackoffDelay', () => {
it('defaults to "3000"', () => {
const config = require('./config');
expect(config.submitJobBackoffDelay).toBe(3000);
});
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.submitJobBackoffDelay).toBe(9999);
});
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: "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'
);
});
});
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 "300"', () => {
const config = require('./config');
expect(config.commitTimeout).toBe(300);
});
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: 300'
);
});
});
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');
});
});
});

View file

@ -0,0 +1,285 @@
/*
* 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';
export const ORG1 = 'Org1';
export const ORG2 = 'Org2';
export const JOB_QUEUE_NAME = 'submit';
/*
* 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 type of backoff to use for retrying failed submit jobs
*/
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 total number of attempts to try a submit job until it completes
*/
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
* 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();
/*
* The Org1 MSP ID
*/
export const mspIdOrg1 = env
.get('HLF_MSP_ID_ORG1')
.default(`${ORG1}MSP`)
.example(`${ORG1}MSP`)
.asString();
/*
* The Org2 MSP ID
*/
export const mspIdOrg2 = env
.get('HLF_MSP_ID_ORG2')
.default(`${ORG2}MSP`)
.example(`${ORG2}MSP`)
.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('300')
.example('300')
.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()
.example(
'{"name":"test-network-org1","version":"1.0.0","client":{"organization":"Org1" ... }'
)
.asJsonObject() as Record<string, unknown>;
/*
* Certificate for an Org1 identity to evaluate and submit transactions
*/
export const certificateOrg1 = env
.get('HLF_CERTIFICATE_ORG1')
.required()
.example('"-----BEGIN CERTIFICATE-----\\n...\\n-----END CERTIFICATE-----\\n"')
.asString();
/*
* Private key for an Org1 identity to evaluate and submit transactions
*/
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()
.example(
'{"name":"test-network-org2","version":"1.0.0","client":{"organization":"Org2" ... }'
)
.asJsonObject() as Record<string, unknown>;
/*
* Certificate for an Org2 identity to evaluate and submit transactions
*/
export const certificateOrg2 = env
.get('HLF_CERTIFICATE_ORG2')
.required()
.example('"-----BEGIN CERTIFICATE-----\\n...\\n-----END CERTIFICATE-----\\n"')
.asString();
/*
* Private key for an Org2 identity to evaluate and submit transactions
*/
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('fabric')
.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()
.example('456')
.asString();

View file

@ -0,0 +1,314 @@
/*
* 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', () => {
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 a TransactionError with a transaction code of DUPLICATE_TXID', () => {
const mockDuplicateTransactionError = mock<TransactionError>();
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<TransactionError>();
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: [
{
endorsements: [
{
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',
},
],
},
],
};
expect(isDuplicateTransactionError(mockDuplicateTransactionError)).toBe(
true
);
});
it('returns false for an error without duplicate transaction endorsement details', () => {
const mockDuplicateTransactionError = {
errors: [
{
endorsements: [
{
details: 'mock endorsement details',
},
{
details: 'mock endorsement details',
},
],
},
],
};
expect(isDuplicateTransactionError(mockDuplicateTransactionError)).toBe(
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('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<TransactionError>();
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 a 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 a AssetNotFoundError for errors with an asset does not exist message: %s',
(msg) => {
expect(handleError('txn1', new Error(msg))).toStrictEqual(
new AssetNotFoundError(msg, '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 the original error for errors of other types', () => {
expect(handleError('txn1', 42)).toEqual(42);
});
});
});

View file

@ -0,0 +1,269 @@
/*
* 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;
constructor(message: string, transactionId: string) {
super(message);
Object.setPrototypeOf(this, ContractError.prototype);
this.name = 'TransactionError';
this.transactionId = transactionId;
}
}
/*
* Represents the error which occurs when the transaction being submitted or
* evaluated is not implemented in a smart contract.
*/
export class TransactionNotFoundError extends ContractError {
constructor(message: string, transactionId: string) {
super(message, transactionId);
Object.setPrototypeOf(this, TransactionNotFoundError.prototype);
this.name = 'TransactionNotFoundError';
}
}
/*
* 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);
Object.setPrototypeOf(this, AssetExistsError.prototype);
this.name = 'AssetExistsError';
}
}
/*
* 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);
Object.setPrototypeOf(this, AssetNotFoundError.prototype);
this.name = 'AssetNotFoundError';
}
}
/*
* 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 &&
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.
*
* This is ...painful.
*/
export const isDuplicateTransactionError = (err: unknown): boolean => {
logger.debug({ err }, 'Checking for duplicate transaction error');
if (err === undefined || err === null) return false;
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 }[] }[];
};
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;
};
/*
* Handles errors from evaluating and submitting transactions.
*
* 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 | unknown => {
logger.debug({ transactionId: transactionId, err }, 'Processing error');
if (isErrorLike(err)) {
const assetAlreadyExistsMatch = matchAssetAlreadyExistsMessage(err.message);
if (assetAlreadyExistsMatch !== null) {
return new AssetExistsError(assetAlreadyExistsMatch, 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;
};

View file

@ -0,0 +1,310 @@
/*
* SPDX-License-Identifier: Apache-2.0
*/
import {
createGateway,
createWallet,
getContracts,
getNetwork,
evatuateTransaction,
submitTransaction,
getBlockHeight,
getTransactionValidationCode,
} from './fabric';
import * as config from './config';
import {
AssetExistsError,
AssetNotFoundError,
TransactionNotFoundError,
} from './errors';
import {
Contract,
Gateway,
GatewayOptions,
Network,
Transaction,
Wallet,
} from 'fabric-network';
import * as fabricProtos from 'fabric-protos';
import { MockProxy, mock } from 'jest-mock-extended';
import Long from 'long';
jest.mock('./config');
jest.mock('fabric-network', () => {
type FabricNetworkModule = jest.Mocked<typeof import('fabric-network')>;
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', () => {
describe('createWallet', () => {
it('creates a wallet containing identities for both orgs', async () => {
const wallet = await createWallet();
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<Wallet>();
const gateway = await createGateway(
connectionProfile,
identity,
mockWallet
);
expect(gateway.connect).toBeCalledWith(
connectionProfile,
expect.objectContaining<GatewayOptions>({
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<Gateway>();
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<Contract>();
const mockSystemContract = mock<Contract>();
const mockNetwork = mock<Network>();
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('evatuateTransaction', () => {
const mockPayload = Buffer.from('MOCK PAYLOAD');
let mockTransaction: MockProxy<Transaction>;
let mockContract: MockProxy<Contract>;
beforeEach(() => {
mockTransaction = mock<Transaction>();
mockTransaction.evaluate.mockResolvedValue(mockPayload);
mockContract = mock<Contract>();
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('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);
});
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);
});
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 an Error for other errors', async () => {
mockTransaction.evaluate.mockRejectedValue(new Error('MOCK ERROR'));
await expect(async () => {
await evatuateTransaction(mockContract, 'txn', 'arga', 'argb');
}).rejects.toThrow(Error);
});
});
describe('submitTransaction', () => {
let mockTransaction: MockProxy<Transaction>;
beforeEach(() => {
mockTransaction = mock<Transaction>();
});
it('gets the result of submitting a transaction', async () => {
const mockPayload = Buffer.from('MOCK PAYLOAD');
mockTransaction.submit.mockResolvedValue(mockPayload);
const result = await submitTransaction(
mockTransaction,
'txn',
'arga',
'argb'
);
expect(result.toString()).toBe(mockPayload.toString());
});
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(
mockTransaction,
'mspid',
'txn',
'arga',
'argb'
);
}).rejects.toThrow(AssetExistsError);
});
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(
mockTransaction,
'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(
mockTransaction,
'mspid',
'txn',
'arga',
'argb'
);
}).rejects.toThrow(TransactionNotFoundError);
});
it('throws an Error for other errors', async () => {
mockTransaction.submit.mockRejectedValue(new Error('MOCK ERROR'));
await expect(async () => {
await submitTransaction(
mockTransaction,
'mspid',
'txn',
'arga',
'argb'
);
}).rejects.toThrow(Error);
});
});
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()
);
const mockTransaction = mock<Transaction>();
mockTransaction.evaluate.mockResolvedValue(processedTransactionBuffer);
const mockContract = mock<Contract>();
mockContract.createTransaction
.calledWith('GetTransactionByID')
.mockReturnValue(mockTransaction);
expect(await getTransactionValidationCode(mockContract, 'txn1')).toBe(
'VALID'
);
});
});
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<Contract>();
mockContract.evaluateTransaction
.calledWith('GetChainInfo', 'mychannel')
.mockResolvedValue(mockBlockchainInfoBuffer);
const result = (await getBlockHeight(mockContract)) as Long;
expect(result.toInt()).toStrictEqual(42);
});
});
});

View file

@ -0,0 +1,202 @@
/*
* SPDX-License-Identifier: Apache-2.0
*/
import {
Contract,
DefaultEventHandlerStrategies,
DefaultQueryHandlerStrategies,
Gateway,
GatewayOptions,
Wallets,
Network,
Transaction,
Wallet,
} from 'fabric-network';
import * as config from './config';
import { logger } from './logger';
import { handleError } from './errors';
import * as protos from 'fabric-protos';
/*
* 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<Wallet> => {
const wallet = await Wallets.newInMemoryWallet();
const org1Identity = {
credentials: {
certificate: config.certificateOrg1,
privateKey: config.privateKeyOrg1,
},
mspId: config.mspIdOrg1,
type: 'X.509',
};
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<string, unknown>,
identity: string,
wallet: Wallet
): Promise<Gateway> => {
logger.debug({ connectionProfile, identity }, 'Configuring gateway');
const gateway = new Gateway();
const options: GatewayOptions = {
wallet,
identity,
discovery: { enabled: true, asLocalhost: config.asLocalhost },
eventHandlerOptions: {
commitTimeout: config.commitTimeout,
endorseTimeout: config.endorseTimeout,
strategy: DefaultEventHandlerStrategies.PREFER_MSPID_SCOPE_ANYFORTX,
},
queryHandlerOptions: {
timeout: config.queryTimeout,
strategy: DefaultQueryHandlerStrategies.PREFER_MSPID_SCOPE_ROUND_ROBIN,
},
};
await gateway.connect(connectionProfile, options);
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<Network> => {
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 }> => {
const assetContract = network.getContract(config.chaincodeName);
const qsccContract = network.getContract('qscc');
return { assetContract, qsccContract };
};
/*
* Evaluate a transaction and handle any errors
*/
export const evatuateTransaction = async (
contract: Contract,
transactionName: string,
...transactionArgs: string[]
): Promise<Buffer> => {
const transaction = contract.createTransaction(transactionName);
const transactionId = transaction.getTransactionId();
logger.trace({ transaction }, 'Evaluating transaction');
try {
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<Buffer> => {
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);
}
};
/*
* Get the validation code of the specified transaction
*/
export const getTransactionValidationCode = async (
qsccContract: Contract,
transactionId: string
): Promise<string> => {
const data = await evatuateTransaction(
qsccContract,
'GetTransactionByID',
config.channelName,
transactionId
);
const processedTransaction = protos.protos.ProcessedTransaction.decode(data);
const validationCode =
protos.protos.TxValidationCode[processedTransaction.validationCode];
logger.debug({ transactionId }, 'Validation code: %s', validationCode);
return validationCode;
};
/*
* 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<number | Long.Long> => {
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;
};

View file

@ -0,0 +1,55 @@
/*
* 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';
import { Queue } from 'bullmq';
import { getJobCounts } from './jobs';
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');
try {
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),
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(),
});
}
return res.status(OK).json({
status: getReasonPhrase(OK),
timestamp: new Date().toISOString(),
});
});

View file

@ -0,0 +1,123 @@
/*
* 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 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
*
* 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 * 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('Creating REST server');
const app = await createServer();
logger.info('Connecting to Fabric network with org1 mspid');
const wallet = await createWallet();
const gatewayOrg1 = await createGateway(
config.connectionProfileOrg1,
config.mspIdOrg1,
wallet
);
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,
wallet
);
const networkOrg2 = await getNetwork(gatewayOrg2);
const contractsOrg2 = await getContracts(networkOrg2);
app.locals[config.mspIdOrg2] = contractsOrg2;
logger.info('Initialising submit job queue');
jobQueue = initJobQueue();
jobQueueWorker = initJobQueueWorker(app);
if (config.submitJobQueueScheduler === true) {
logger.info('Initialising submit job queue scheduler');
jobQueueScheduler = initJobQueueScheduler();
}
app.locals.jobq = jobQueue;
logger.info('Starting REST server');
app.listen(config.port, () => {
logger.info('REST server started on port: %d', config.port);
});
}
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();
}
});

View file

@ -0,0 +1,40 @@
/*
* SPDX-License-Identifier: Apache-2.0
*/
import { Queue } from 'bullmq';
import express, { Request, Response } from 'express';
import { getReasonPhrase, StatusCodes } from 'http-status-codes';
import { getJobSummary, JobNotFoundError } 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.locals.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(),
});
}
});

View file

@ -0,0 +1,349 @@
/*
* SPDX-License-Identifier: Apache-2.0
*/
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('addSubmitTransactionJob', () => {
let mockJob: MockProxy<Job>;
let mockQueue: MockProxy<Queue>;
beforeEach(() => {
mockJob = mock<Job>();
mockQueue = mock<Queue>();
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', () => {
let mockQueue: MockProxy<Queue>;
let mockJob: MockProxy<Job>;
beforeEach(() => {
mockQueue = mock<Queue>();
mockJob = mock<Job>();
});
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('updateJobData', () => {
let mockJob: MockProxy<Job>;
beforeEach(() => {
mockJob = mock<Job>();
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<Transaction>();
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', () => {
it('gets job counts from the specified queue', async () => {
const mockQueue = mock<Queue>();
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,
});
});
describe('processSubmitTransactionJob', () => {
const mockContracts = new Map<string, Contract>();
const mockPayload = Buffer.from('MOCK PAYLOAD');
const mockSavedState = Buffer.from('MOCK SAVED STATE');
let mockTransaction: MockProxy<Transaction>;
let mockContract: MockProxy<Contract>;
let mockApplication: MockProxy<Application>;
let mockJob: MockProxy<Job>;
beforeEach(() => {
mockTransaction = mock<Transaction>();
mockTransaction.getTransactionId.mockReturnValue('mockTransactionId');
mockContract = mock<Contract>();
mockContract.createTransaction
.calledWith('txn')
.mockReturnValue(mockTransaction);
mockContract.deserializeTransaction
.calledWith(mockSavedState)
.mockReturnValue(mockTransaction);
mockContracts.set('mockMspid', mockContract);
mockApplication = mock<Application>();
mockApplication.locals.mockMspid = { assetContract: mockContract };
mockJob = mock<Job>();
});
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(
mockApplication,
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(
mockApplication,
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(
mockApplication,
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(
mockApplication,
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(mockApplication, mockJob);
}).rejects.toThrow('MOCK ERROR');
});
});
});

View file

@ -0,0 +1,346 @@
/*
* SPDX-License-Identifier: Apache-2.0
*
* This sample uses BullMQ jobs to process submit transactions, which includes
* retry support for failing jobs
*/
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';
import { submitTransaction } 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;
};
export type JobSummary = {
jobId: string;
transactionIds: string[];
transactionPayload?: string;
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,
username: config.redisUsername,
password: config.redisPassword,
};
/*
* Set up the queue for submit jobs
*/
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;
};
/*
* 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<JobData, JobResult>(
config.JOB_QUEUE_NAME,
async (job): Promise<JobResult> => {
return await processSubmitTransactionJob(app, job);
},
{ connection, concurrency: config.submitJobConcurrency }
);
worker.on('failed', (job) => {
logger.warn({ job }, 'Job failed');
});
// 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;
};
/*
* Process a submit transaction request from the job queue
*
* The job will be retried if this function throws an error
*/
export const processSubmitTransactionJob = async (
app: Application,
job: Job<JobData, JobResult>
): Promise<JobResult> => {
logger.debug({ jobId: job.id, jobName: job.name }, 'Processing job');
const contract = app.locals[job.data.mspid]?.assetContract as Contract;
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;
}
};
/*
* 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<JobData, JobResult>,
mspid: string,
transactionName: string,
...transactionArgs: string[]
): Promise<string> => {
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<JobData, JobResult>,
transaction: Transaction | undefined
): Promise<void> => {
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<JobSummary> => {
const job: Job<JobData, JobResult> | 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;
};

View file

@ -0,0 +1,10 @@
/*
* SPDX-License-Identifier: Apache-2.0
*/
import pino from 'pino';
import * as config from './config';
export const logger = pino({
level: config.logLevel,
});

View file

@ -0,0 +1,34 @@
/*
* SPDX-License-Identifier: Apache-2.0
*/
import { isMaxmemoryPolicyNoeviction } from './redis';
const mockRedisConfig = jest.fn();
jest.mock('ioredis', () => {
return jest.fn().mockImplementation(() => {
return {
config: mockRedisConfig,
disconnect: jest.fn(),
};
});
});
jest.mock('./config');
describe('Redis', () => {
beforeEach(() => {
mockRedisConfig.mockClear();
});
describe('isMaxmemoryPolicyNoeviction', () => {
it('returns true when the maxmemory-policy is noeviction', async () => {
mockRedisConfig.mockReturnValue(['maxmemory-policy', 'noeviction']);
expect(await isMaxmemoryPolicyNoeviction()).toBe(true);
});
it('returns false when the maxmemory-policy is not noeviction', async () => {
mockRedisConfig.mockReturnValue(['maxmemory-policy', 'allkeys-lru']);
expect(await isMaxmemoryPolicyNoeviction()).toBe(false);
});
});
});

View file

@ -0,0 +1,51 @@
/*
* SPDX-License-Identifier: Apache-2.0
*
* This sample uses the BullMQ queue system, which is built on top of Redis
*/
import IORedis, { Redis, RedisOptions } from 'ioredis';
import * as config from './config';
import { logger } from './logger';
/*
* 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 isMaxmemoryPolicyNoeviction = async (): Promise<boolean> => {
let redis: Redis | undefined;
const redisOptions: RedisOptions = {
port: config.redisPort,
host: config.redisHost,
username: config.redisUsername,
password: config.redisPassword,
};
try {
redis = new IORedis(redisOptions);
const maxmemoryPolicyConfig = await (redis as Redis).config(
'GET',
'maxmemory-policy'
);
logger.debug({ maxmemoryPolicyConfig }, 'Got maxmemory-policy config');
if (
maxmemoryPolicyConfig.length == 2 &&
'maxmemory-policy' === maxmemoryPolicyConfig[0] &&
'noeviction' === maxmemoryPolicyConfig[1]
) {
return true;
}
} finally {
if (redis != undefined) {
redis.disconnect();
}
}
return false;
};

View file

@ -0,0 +1,86 @@
/*
* SPDX-License-Identifier: Apache-2.0
*/
import express, { Application, NextFunction, Request, Response } from 'express';
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<Application> => {
const app = express();
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 }));
//define passport startegy
passport.use(fabricAPIKeyStrategy);
//initialize passport js
app.use(passport.initialize());
if (process.env.NODE_ENV === 'development') {
// TBC
}
if (process.env.NODE_ENV === 'test') {
// TBC
}
if (process.env.NODE_ENV === 'production') {
app.use(helmet());
}
app.use('/', healthRouter);
app.use('/api/assets', authenticateApiKey, assetsRouter);
app.use('/api/jobs', authenticateApiKey, jobsRouter);
app.use('/api/transactions', authenticateApiKey, transactionsRouter);
// For everything else
app.use((_req, res) =>
res.status(NOT_FOUND).json({
status: getReasonPhrase(NOT_FOUND),
timestamp: new Date().toISOString(),
})
);
// Print API 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;
};

View file

@ -0,0 +1,55 @@
/*
* 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 { getTransactionValidationCode } from './fabric';
import { logger } from './logger';
import { TransactionNotFoundError } from './errors';
const { INTERNAL_SERVER_ERROR, NOT_FOUND, OK } = StatusCodes;
export const transactionsRouter = express.Router();
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);
try {
const qsccContract = req.app.locals[mspId]?.qsccContract as Contract;
const validationCode = await getTransactionValidationCode(
qsccContract,
transactionId
);
return res.status(OK).json({
transactionId,
validationCode,
});
} catch (err) {
if (err instanceof TransactionNotFoundError) {
return res.status(NOT_FOUND).json({
status: getReasonPhrase(NOT_FOUND),
timestamp: new Date().toISOString(),
});
} else {
logger.error(
{ err },
'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(),
});
}
}
}
);

View file

@ -0,0 +1,75 @@
{
"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. */
},
"include": [
"src/"
]
}