se eliminaron applications-gateways inecesarios

This commit is contained in:
luc662 2025-04-02 19:45:02 +00:00
parent 356622a410
commit 5f7ddaeb4a
75 changed files with 0 additions and 21531 deletions

View file

@ -1,294 +0,0 @@
/*
Copyright 2021 IBM All Rights Reserved.
SPDX-License-Identifier: Apache-2.0
*/
package main
import (
"bytes"
"context"
"crypto/x509"
"encoding/json"
"errors"
"fmt"
"os"
"path"
"time"
"github.com/hyperledger/fabric-gateway/pkg/client"
"github.com/hyperledger/fabric-gateway/pkg/identity"
"github.com/hyperledger/fabric-protos-go-apiv2/gateway"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/status"
)
const (
mspID = "Org1MSP"
cryptoPath = "../../test-network/organizations/peerOrganizations/org1.example.com"
certPath = cryptoPath + "/users/User1@org1.example.com/msp/signcerts"
keyPath = cryptoPath + "/users/User1@org1.example.com/msp/keystore"
tlsCertPath = cryptoPath + "/peers/peer0.org1.example.com/tls/ca.crt"
peerEndpoint = "dns:///localhost:7051"
gatewayPeer = "peer0.org1.example.com"
)
var now = time.Now()
var assetId = fmt.Sprintf("asset%d", now.Unix()*1e3+int64(now.Nanosecond())/1e6)
func main() {
// The gRPC client connection should be shared by all Gateway connections to this endpoint
clientConnection := newGrpcConnection()
defer clientConnection.Close()
id := newIdentity()
sign := newSign()
// Create a Gateway connection for a specific client identity
gw, err := client.Connect(
id,
client.WithSign(sign),
client.WithClientConnection(clientConnection),
// Default timeouts for different gRPC calls
client.WithEvaluateTimeout(5*time.Second),
client.WithEndorseTimeout(15*time.Second),
client.WithSubmitTimeout(5*time.Second),
client.WithCommitStatusTimeout(1*time.Minute),
)
if err != nil {
panic(err)
}
defer gw.Close()
// Override default values for chaincode and channel name as they may differ in testing contexts.
chaincodeName := "basic"
if ccname := os.Getenv("CHAINCODE_NAME"); ccname != "" {
chaincodeName = ccname
}
channelName := "mychannel"
if cname := os.Getenv("CHANNEL_NAME"); cname != "" {
channelName = cname
}
network := gw.GetNetwork(channelName)
contract := network.GetContract(chaincodeName)
initLedger(contract)
getAllAssets(contract)
createAsset(contract)
readAssetByID(contract)
transferAssetAsync(contract)
exampleErrorHandling(contract)
}
// newGrpcConnection creates a gRPC connection to the Gateway server.
func newGrpcConnection() *grpc.ClientConn {
certificatePEM, err := os.ReadFile(tlsCertPath)
if err != nil {
panic(fmt.Errorf("failed to read TLS certifcate file: %w", err))
}
certificate, err := identity.CertificateFromPEM(certificatePEM)
if err != nil {
panic(err)
}
certPool := x509.NewCertPool()
certPool.AddCert(certificate)
transportCredentials := credentials.NewClientTLSFromCert(certPool, gatewayPeer)
connection, err := grpc.NewClient(peerEndpoint, grpc.WithTransportCredentials(transportCredentials))
if err != nil {
panic(fmt.Errorf("failed to create gRPC connection: %w", err))
}
return connection
}
// newIdentity creates a client identity for this Gateway connection using an X.509 certificate.
func newIdentity() *identity.X509Identity {
certificatePEM, err := readFirstFile(certPath)
if err != nil {
panic(fmt.Errorf("failed to read certificate file: %w", err))
}
certificate, err := identity.CertificateFromPEM(certificatePEM)
if err != nil {
panic(err)
}
id, err := identity.NewX509Identity(mspID, certificate)
if err != nil {
panic(err)
}
return id
}
// newSign creates a function that generates a digital signature from a message digest using a private key.
func newSign() identity.Sign {
privateKeyPEM, err := readFirstFile(keyPath)
if err != nil {
panic(fmt.Errorf("failed to read private key file: %w", err))
}
privateKey, err := identity.PrivateKeyFromPEM(privateKeyPEM)
if err != nil {
panic(err)
}
sign, err := identity.NewPrivateKeySign(privateKey)
if err != nil {
panic(err)
}
return sign
}
func readFirstFile(dirPath string) ([]byte, error) {
dir, err := os.Open(dirPath)
if err != nil {
return nil, err
}
fileNames, err := dir.Readdirnames(1)
if err != nil {
return nil, err
}
return os.ReadFile(path.Join(dirPath, fileNames[0]))
}
// This type of transaction would typically only be run once by an application the first time it was started after its
// initial deployment. A new version of the chaincode deployed later would likely not need to run an "init" function.
func initLedger(contract *client.Contract) {
fmt.Printf("\n--> Submit Transaction: InitLedger, function creates the initial set of assets on the ledger \n")
_, err := contract.SubmitTransaction("InitLedger")
if err != nil {
panic(fmt.Errorf("failed to submit transaction: %w", err))
}
fmt.Printf("*** Transaction committed successfully\n")
}
// Evaluate a transaction to query ledger state.
func getAllAssets(contract *client.Contract) {
fmt.Println("\n--> Evaluate Transaction: GetAllAssets, function returns all the current assets on the ledger")
evaluateResult, err := contract.EvaluateTransaction("GetAllAssets")
if err != nil {
panic(fmt.Errorf("failed to evaluate transaction: %w", err))
}
result := formatJSON(evaluateResult)
fmt.Printf("*** Result:%s\n", result)
}
// Submit a transaction synchronously, blocking until it has been committed to the ledger.
func createAsset(contract *client.Contract) {
fmt.Printf("\n--> Submit Transaction: CreateAsset, creates new asset with ID, Color, Size, Owner and AppraisedValue arguments \n")
_, err := contract.SubmitTransaction("CreateAsset", assetId, "yellow", "5", "Tom", "1300")
if err != nil {
panic(fmt.Errorf("failed to submit transaction: %w", err))
}
fmt.Printf("*** Transaction committed successfully\n")
}
// Evaluate a transaction by assetID to query ledger state.
func readAssetByID(contract *client.Contract) {
fmt.Printf("\n--> Evaluate Transaction: ReadAsset, function returns asset attributes\n")
evaluateResult, err := contract.EvaluateTransaction("ReadAsset", assetId)
if err != nil {
panic(fmt.Errorf("failed to evaluate transaction: %w", err))
}
result := formatJSON(evaluateResult)
fmt.Printf("*** Result:%s\n", result)
}
// Submit transaction asynchronously, blocking until the transaction has been sent to the orderer, and allowing
// this thread to process the chaincode response (e.g. update a UI) without waiting for the commit notification
func transferAssetAsync(contract *client.Contract) {
fmt.Printf("\n--> Async Submit Transaction: TransferAsset, updates existing asset owner")
submitResult, commit, err := contract.SubmitAsync("TransferAsset", client.WithArguments(assetId, "Mark"))
if err != nil {
panic(fmt.Errorf("failed to submit transaction asynchronously: %w", err))
}
fmt.Printf("\n*** Successfully submitted transaction to transfer ownership from %s to Mark. \n", string(submitResult))
fmt.Println("*** Waiting for transaction commit.")
if commitStatus, err := commit.Status(); err != nil {
panic(fmt.Errorf("failed to get commit status: %w", err))
} else if !commitStatus.Successful {
panic(fmt.Errorf("transaction %s failed to commit with status: %d", commitStatus.TransactionID, int32(commitStatus.Code)))
}
fmt.Printf("*** Transaction committed successfully\n")
}
// Submit transaction, passing in the wrong number of arguments ,expected to throw an error containing details of any error responses from the smart contract.
func exampleErrorHandling(contract *client.Contract) {
fmt.Println("\n--> Submit Transaction: UpdateAsset asset70, asset70 does not exist and should return an error")
_, err := contract.SubmitTransaction("UpdateAsset", "asset70", "blue", "5", "Tomoko", "300")
if err == nil {
panic("******** FAILED to return an error")
}
fmt.Println("*** Successfully caught the error:")
var endorseErr *client.EndorseError
var submitErr *client.SubmitError
var commitStatusErr *client.CommitStatusError
var commitErr *client.CommitError
if errors.As(err, &endorseErr) {
fmt.Printf("Endorse error for transaction %s with gRPC status %v: %s\n", endorseErr.TransactionID, status.Code(endorseErr), endorseErr)
} else if errors.As(err, &submitErr) {
fmt.Printf("Submit error for transaction %s with gRPC status %v: %s\n", submitErr.TransactionID, status.Code(submitErr), submitErr)
} else if errors.As(err, &commitStatusErr) {
if errors.Is(err, context.DeadlineExceeded) {
fmt.Printf("Timeout waiting for transaction %s commit status: %s", commitStatusErr.TransactionID, commitStatusErr)
} else {
fmt.Printf("Error obtaining commit status for transaction %s with gRPC status %v: %s\n", commitStatusErr.TransactionID, status.Code(commitStatusErr), commitStatusErr)
}
} else if errors.As(err, &commitErr) {
fmt.Printf("Transaction %s failed to commit with status %d: %s\n", commitErr.TransactionID, int32(commitErr.Code), err)
} else {
panic(fmt.Errorf("unexpected error type %T: %w", err, err))
}
// Any error that originates from a peer or orderer node external to the gateway will have its details
// embedded within the gRPC status error. The following code shows how to extract that.
statusErr := status.Convert(err)
details := statusErr.Details()
if len(details) > 0 {
fmt.Println("Error Details:")
for _, detail := range details {
switch detail := detail.(type) {
case *gateway.ErrorDetail:
fmt.Printf("- address: %s, mspId: %s, message: %s\n", detail.Address, detail.MspId, detail.Message)
}
}
}
}
// Format JSON data
func formatJSON(data []byte) string {
var prettyJSON bytes.Buffer
if err := json.Indent(&prettyJSON, data, "", " "); err != nil {
panic(fmt.Errorf("failed to parse JSON: %w", err))
}
return prettyJSON.String()
}

View file

@ -1,19 +0,0 @@
module assetTransfer
go 1.21
require (
github.com/hyperledger/fabric-gateway v1.5.0
github.com/hyperledger/fabric-protos-go-apiv2 v0.3.3
google.golang.org/grpc v1.63.2
)
require (
github.com/miekg/pkcs11 v1.1.1 // indirect
golang.org/x/crypto v0.22.0 // indirect
golang.org/x/net v0.24.0 // indirect
golang.org/x/sys v0.19.0 // indirect
golang.org/x/text v0.14.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240401170217-c3f982113cda // indirect
google.golang.org/protobuf v1.33.0 // indirect
)

View file

@ -1,32 +0,0 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/hyperledger/fabric-gateway v1.5.0 h1:JChlqtJNm2479Q8YWJ6k8wwzOiu2IRrV3K8ErsQmdTU=
github.com/hyperledger/fabric-gateway v1.5.0/go.mod h1:v13OkXAp7pKi4kh6P6epn27SyivRbljr8Gkfy8JlbtM=
github.com/hyperledger/fabric-protos-go-apiv2 v0.3.3 h1:Xpd6fzG/KjAOHJsq7EQXY2l+qi/y8muxBaY7R6QWABk=
github.com/hyperledger/fabric-protos-go-apiv2 v0.3.3/go.mod h1:2pq0ui6ZWA0cC8J+eCErgnMDCS1kPOEYVY+06ZAK0qE=
github.com/miekg/pkcs11 v1.1.1 h1:Ugu9pdy6vAYku5DEpVWVFPYnzV+bxB+iRdbuFSu7TvU=
github.com/miekg/pkcs11 v1.1.1/go.mod h1:XsNlhZGX73bx86s2hdc/FuaLm2CPZJemRLMA+WTFxgs=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
go.uber.org/mock v0.3.0 h1:3mUxI1No2/60yUYax92Pt8eNOEecx2D3lcXZh2NEZJo=
go.uber.org/mock v0.3.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc=
golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30=
golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w=
golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o=
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240401170217-c3f982113cda h1:LI5DOvAxUPMv/50agcLLoo+AdWc1irS9Rzz4vPuD1V4=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240401170217-c3f982113cda/go.mod h1:WtryC6hu0hhx87FDGxWCDptyssuo68sk10vYjF+T9fY=
google.golang.org/grpc v1.63.2 h1:MUeiw1B2maTVZthpU5xvASfTh3LDbxHd6IJ6QQVU+xM=
google.golang.org/grpc v1.63.2/go.mod h1:WAX/8DgncnokcFUldAxq7GeB5DXHDbMF+lLvDomNkRA=
google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View file

@ -1,11 +0,0 @@
#
# SPDX-License-Identifier: Apache-2.0
#
# Coverage directory used by tools like istanbul
coverage
# Dependency directories
node_modules/
jspm_packages/

View file

@ -1 +0,0 @@
engine-strict=true

View file

@ -1,15 +0,0 @@
import js from '@eslint/js';
import globals from 'globals';
export default [
js.configs.recommended,
{
languageOptions: {
ecmaVersion: 2023,
sourceType: 'commonjs',
globals: {
...globals.node,
},
},
},
];

View file

@ -1,25 +0,0 @@
{
"name": "asset-transfer-basic",
"version": "1.0.0",
"description": "Asset Transfer Basic Application implemented in JavaScript using fabric-gateway",
"engines": {
"node": ">=18"
},
"scripts": {
"lint": "eslint src",
"pretest": "npm run lint",
"start": "node src/app.js"
},
"engineStrict": true,
"author": "Hyperledger",
"license": "Apache-2.0",
"dependencies": {
"@grpc/grpc-js": "^1.10",
"@hyperledger/fabric-gateway": "^1.5"
},
"devDependencies": {
"@eslint/js": "^9.5.0",
"eslint": "^9.5.0",
"globals": "^15.6.0"
}
}

View file

@ -1,300 +0,0 @@
/*
* Copyright IBM Corp. All Rights Reserved.
*
* SPDX-License-Identifier: Apache-2.0
*/
const grpc = require('@grpc/grpc-js');
const { connect, signers } = require('@hyperledger/fabric-gateway');
const crypto = require('node:crypto');
const fs = require('node:fs/promises');
const path = require('node:path');
const { TextDecoder } = require('node:util');
const channelName = envOrDefault('CHANNEL_NAME', 'mychannel');
const chaincodeName = envOrDefault('CHAINCODE_NAME', 'basic');
const mspId = envOrDefault('MSP_ID', 'Org1MSP');
// Path to crypto materials.
const cryptoPath = envOrDefault(
'CRYPTO_PATH',
path.resolve(
__dirname,
'..',
'..',
'..',
'test-network',
'organizations',
'peerOrganizations',
'org1.example.com'
)
);
// Path to user private key directory.
const keyDirectoryPath = envOrDefault(
'KEY_DIRECTORY_PATH',
path.resolve(
cryptoPath,
'users',
'User1@org1.example.com',
'msp',
'keystore'
)
);
// Path to user certificate directory.
const certDirectoryPath = envOrDefault(
'CERT_DIRECTORY_PATH',
path.resolve(
cryptoPath,
'users',
'User1@org1.example.com',
'msp',
'signcerts'
)
);
// Path to peer tls certificate.
const tlsCertPath = envOrDefault(
'TLS_CERT_PATH',
path.resolve(cryptoPath, 'peers', 'peer0.org1.example.com', 'tls', 'ca.crt')
);
// Gateway peer endpoint.
const peerEndpoint = envOrDefault('PEER_ENDPOINT', 'localhost:7051');
// Gateway peer SSL host name override.
const peerHostAlias = envOrDefault('PEER_HOST_ALIAS', 'peer0.org1.example.com');
const utf8Decoder = new TextDecoder();
const assetId = `asset${String(Date.now())}`;
async function main() {
displayInputParameters();
// The gRPC client connection should be shared by all Gateway connections to this endpoint.
const client = await newGrpcConnection();
const gateway = connect({
client,
identity: await newIdentity(),
signer: await newSigner(),
// Default timeouts for different gRPC calls
evaluateOptions: () => {
return { deadline: Date.now() + 5000 }; // 5 seconds
},
endorseOptions: () => {
return { deadline: Date.now() + 15000 }; // 15 seconds
},
submitOptions: () => {
return { deadline: Date.now() + 5000 }; // 5 seconds
},
commitStatusOptions: () => {
return { deadline: Date.now() + 60000 }; // 1 minute
},
});
try {
// Get a network instance representing the channel where the smart contract is deployed.
const network = gateway.getNetwork(channelName);
// Get the smart contract from the network.
const contract = network.getContract(chaincodeName);
// Initialize a set of asset data on the ledger using the chaincode 'InitLedger' function.
await initLedger(contract);
// Return all the current assets on the ledger.
await getAllAssets(contract);
// Create a new asset on the ledger.
await createAsset(contract);
// Update an existing asset asynchronously.
await transferAssetAsync(contract);
// Get the asset details by assetID.
await readAssetByID(contract);
// Update an asset which does not exist.
await updateNonExistentAsset(contract);
} finally {
gateway.close();
client.close();
}
}
main().catch((error) => {
console.error('******** FAILED to run the application:', error);
process.exitCode = 1;
});
async function newGrpcConnection() {
const tlsRootCert = await fs.readFile(tlsCertPath);
const tlsCredentials = grpc.credentials.createSsl(tlsRootCert);
return new grpc.Client(peerEndpoint, tlsCredentials, {
'grpc.ssl_target_name_override': peerHostAlias,
});
}
async function newIdentity() {
const certPath = await getFirstDirFileName(certDirectoryPath);
const credentials = await fs.readFile(certPath);
return { mspId, credentials };
}
async function getFirstDirFileName(dirPath) {
const files = await fs.readdir(dirPath);
const file = files[0];
if (!file) {
throw new Error(`No files in directory: ${dirPath}`);
}
return path.join(dirPath, file);
}
async function newSigner() {
const keyPath = await getFirstDirFileName(keyDirectoryPath);
const privateKeyPem = await fs.readFile(keyPath);
const privateKey = crypto.createPrivateKey(privateKeyPem);
return signers.newPrivateKeySigner(privateKey);
}
/**
* This type of transaction would typically only be run once by an application the first time it was started after its
* initial deployment. A new version of the chaincode deployed later would likely not need to run an "init" function.
*/
async function initLedger(contract) {
console.log(
'\n--> Submit Transaction: InitLedger, function creates the initial set of assets on the ledger'
);
await contract.submitTransaction('InitLedger');
console.log('*** Transaction committed successfully');
}
/**
* Evaluate a transaction to query ledger state.
*/
async function getAllAssets(contract) {
console.log(
'\n--> Evaluate Transaction: GetAllAssets, function returns all the current assets on the ledger'
);
const resultBytes = await contract.evaluateTransaction('GetAllAssets');
const resultJson = utf8Decoder.decode(resultBytes);
const result = JSON.parse(resultJson);
console.log('*** Result:', result);
}
/**
* Submit a transaction synchronously, blocking until it has been committed to the ledger.
*/
async function createAsset(contract) {
console.log(
'\n--> Submit Transaction: CreateAsset, creates new asset with ID, Color, Size, Owner and AppraisedValue arguments'
);
await contract.submitTransaction(
'CreateAsset',
assetId,
'yellow',
'5',
'Tom',
'1300'
);
console.log('*** Transaction committed successfully');
}
/**
* Submit transaction asynchronously, allowing the application to process the smart contract response (e.g. update a UI)
* while waiting for the commit notification.
*/
async function transferAssetAsync(contract) {
console.log(
'\n--> Async Submit Transaction: TransferAsset, updates existing asset owner'
);
const commit = await contract.submitAsync('TransferAsset', {
arguments: [assetId, 'Saptha'],
});
const oldOwner = utf8Decoder.decode(commit.getResult());
console.log(
`*** Successfully submitted transaction to transfer ownership from ${oldOwner} to Saptha`
);
console.log('*** Waiting for transaction commit');
const status = await commit.getStatus();
if (!status.successful) {
throw new Error(
`Transaction ${
status.transactionId
} failed to commit with status code ${String(status.code)}`
);
}
console.log('*** Transaction committed successfully');
}
async function readAssetByID(contract) {
console.log(
'\n--> Evaluate Transaction: ReadAsset, function returns asset attributes'
);
const resultBytes = await contract.evaluateTransaction(
'ReadAsset',
assetId
);
const resultJson = utf8Decoder.decode(resultBytes);
const result = JSON.parse(resultJson);
console.log('*** Result:', result);
}
/**
* submitTransaction() will throw an error containing details of any error responses from the smart contract.
*/
async function updateNonExistentAsset(contract) {
console.log(
'\n--> Submit Transaction: UpdateAsset asset70, asset70 does not exist and should return an error'
);
try {
await contract.submitTransaction(
'UpdateAsset',
'asset70',
'blue',
'5',
'Tomoko',
'300'
);
console.log('******** FAILED to return an error');
} catch (error) {
console.log('*** Successfully caught the error: \n', error);
}
}
/**
* envOrDefault() will return the value of an environment variable, or a default value if the variable is undefined.
*/
function envOrDefault(key, defaultValue) {
return process.env[key] || defaultValue;
}
/**
* displayInputParameters() will print the global scope parameters used by the main driver routine.
*/
function displayInputParameters() {
console.log(`channelName: ${channelName}`);
console.log(`chaincodeName: ${chaincodeName}`);
console.log(`mspId: ${mspId}`);
console.log(`cryptoPath: ${cryptoPath}`);
console.log(`keyDirectoryPath: ${keyDirectoryPath}`);
console.log(`certDirectoryPath: ${certDirectoryPath}`);
console.log(`tlsCertPath: ${tlsCertPath}`);
console.log(`peerEndpoint: ${peerEndpoint}`);
console.log(`peerHostAlias: ${peerHostAlias}`);
}

View file

@ -1,14 +0,0 @@
#
# SPDX-License-Identifier: Apache-2.0
#
# Coverage directory used by tools like istanbul
coverage
# Dependency directories
node_modules/
jspm_packages/
# Compiled TypeScript files
dist

View file

@ -1 +0,0 @@
engine-strict=true

View file

@ -1,13 +0,0 @@
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
export default tseslint.config(js.configs.recommended, ...tseslint.configs.strictTypeChecked, {
languageOptions: {
ecmaVersion: 2023,
sourceType: 'module',
parserOptions: {
project: 'tsconfig.json',
tsconfigRootDir: import.meta.dirname,
},
},
});

View file

@ -1,33 +0,0 @@
{
"name": "asset-transfer-basic",
"version": "1.0.0",
"description": "Asset Transfer Basic Application implemented in typeScript using fabric-gateway",
"main": "dist/index.js",
"typings": "dist/index.d.ts",
"engines": {
"node": ">=18"
},
"scripts": {
"build": "tsc",
"build:watch": "tsc -w",
"lint": "eslint src",
"prepare": "npm run build",
"pretest": "npm run lint",
"start": "node dist/app.js"
},
"engineStrict": true,
"author": "Hyperledger",
"license": "Apache-2.0",
"dependencies": {
"@grpc/grpc-js": "^1.10",
"@hyperledger/fabric-gateway": "^1.5"
},
"devDependencies": {
"@eslint/js": "^9.3.0",
"@tsconfig/node18": "^18.2.2",
"@types/node": "^18.18.6",
"eslint": "^8.57.0",
"typescript": "~5.4",
"typescript-eslint": "^7.13.0"
}
}

View file

@ -1,246 +0,0 @@
/*
* Copyright IBM Corp. All Rights Reserved.
*
* SPDX-License-Identifier: Apache-2.0
*/
import * as grpc from '@grpc/grpc-js';
import { connect, Contract, Identity, Signer, signers } from '@hyperledger/fabric-gateway';
import * as crypto from 'crypto';
import { promises as fs } from 'fs';
import * as path from 'path';
import { TextDecoder } from 'util';
const channelName = envOrDefault('CHANNEL_NAME', 'mychannel');
const chaincodeName = envOrDefault('CHAINCODE_NAME', 'basic');
const mspId = envOrDefault('MSP_ID', 'Org1MSP');
// Path to crypto materials.
const cryptoPath = envOrDefault('CRYPTO_PATH', path.resolve(__dirname, '..', '..', '..', 'test-network', 'organizations', 'peerOrganizations', 'org1.example.com'));
// Path to user private key directory.
const keyDirectoryPath = envOrDefault('KEY_DIRECTORY_PATH', path.resolve(cryptoPath, 'users', 'User1@org1.example.com', 'msp', 'keystore'));
// Path to user certificate directory.
const certDirectoryPath = envOrDefault('CERT_DIRECTORY_PATH', path.resolve(cryptoPath, 'users', 'User1@org1.example.com', 'msp', 'signcerts'));
// Path to peer tls certificate.
const tlsCertPath = envOrDefault('TLS_CERT_PATH', path.resolve(cryptoPath, 'peers', 'peer0.org1.example.com', 'tls', 'ca.crt'));
// Gateway peer endpoint.
const peerEndpoint = envOrDefault('PEER_ENDPOINT', 'localhost:7051');
// Gateway peer SSL host name override.
const peerHostAlias = envOrDefault('PEER_HOST_ALIAS', 'peer0.org1.example.com');
const utf8Decoder = new TextDecoder();
const assetId = `asset${String(Date.now())}`;
async function main(): Promise<void> {
displayInputParameters();
// The gRPC client connection should be shared by all Gateway connections to this endpoint.
const client = await newGrpcConnection();
const gateway = connect({
client,
identity: await newIdentity(),
signer: await newSigner(),
// Default timeouts for different gRPC calls
evaluateOptions: () => {
return { deadline: Date.now() + 5000 }; // 5 seconds
},
endorseOptions: () => {
return { deadline: Date.now() + 15000 }; // 15 seconds
},
submitOptions: () => {
return { deadline: Date.now() + 5000 }; // 5 seconds
},
commitStatusOptions: () => {
return { deadline: Date.now() + 60000 }; // 1 minute
},
});
try {
// Get a network instance representing the channel where the smart contract is deployed.
const network = gateway.getNetwork(channelName);
// Get the smart contract from the network.
const contract = network.getContract(chaincodeName);
// Initialize a set of asset data on the ledger using the chaincode 'InitLedger' function.
await initLedger(contract);
// Return all the current assets on the ledger.
await getAllAssets(contract);
// Create a new asset on the ledger.
await createAsset(contract);
// Update an existing asset asynchronously.
await transferAssetAsync(contract);
// Get the asset details by assetID.
await readAssetByID(contract);
// Update an asset which does not exist.
await updateNonExistentAsset(contract)
} finally {
gateway.close();
client.close();
}
}
main().catch((error: unknown) => {
console.error('******** FAILED to run the application:', error);
process.exitCode = 1;
});
async function newGrpcConnection(): Promise<grpc.Client> {
const tlsRootCert = await fs.readFile(tlsCertPath);
const tlsCredentials = grpc.credentials.createSsl(tlsRootCert);
return new grpc.Client(peerEndpoint, tlsCredentials, {
'grpc.ssl_target_name_override': peerHostAlias,
});
}
async function newIdentity(): Promise<Identity> {
const certPath = await getFirstDirFileName(certDirectoryPath);
const credentials = await fs.readFile(certPath);
return { mspId, credentials };
}
async function getFirstDirFileName(dirPath: string): Promise<string> {
const files = await fs.readdir(dirPath);
const file = files[0];
if (!file) {
throw new Error(`No files in directory: ${dirPath}`);
}
return path.join(dirPath, file);
}
async function newSigner(): Promise<Signer> {
const keyPath = await getFirstDirFileName(keyDirectoryPath);
const privateKeyPem = await fs.readFile(keyPath);
const privateKey = crypto.createPrivateKey(privateKeyPem);
return signers.newPrivateKeySigner(privateKey);
}
/**
* This type of transaction would typically only be run once by an application the first time it was started after its
* initial deployment. A new version of the chaincode deployed later would likely not need to run an "init" function.
*/
async function initLedger(contract: Contract): Promise<void> {
console.log('\n--> Submit Transaction: InitLedger, function creates the initial set of assets on the ledger');
await contract.submitTransaction('InitLedger');
console.log('*** Transaction committed successfully');
}
/**
* Evaluate a transaction to query ledger state.
*/
async function getAllAssets(contract: Contract): Promise<void> {
console.log('\n--> Evaluate Transaction: GetAllAssets, function returns all the current assets on the ledger');
const resultBytes = await contract.evaluateTransaction('GetAllAssets');
const resultJson = utf8Decoder.decode(resultBytes);
const result: unknown = JSON.parse(resultJson);
console.log('*** Result:', result);
}
/**
* Submit a transaction synchronously, blocking until it has been committed to the ledger.
*/
async function createAsset(contract: Contract): Promise<void> {
console.log('\n--> Submit Transaction: CreateAsset, creates new asset with ID, Color, Size, Owner and AppraisedValue arguments');
await contract.submitTransaction(
'CreateAsset',
assetId,
'yellow',
'5',
'Tom',
'1300',
);
console.log('*** Transaction committed successfully');
}
/**
* Submit transaction asynchronously, allowing the application to process the smart contract response (e.g. update a UI)
* while waiting for the commit notification.
*/
async function transferAssetAsync(contract: Contract): Promise<void> {
console.log('\n--> Async Submit Transaction: TransferAsset, updates existing asset owner');
const commit = await contract.submitAsync('TransferAsset', {
arguments: [assetId, 'Saptha'],
});
const oldOwner = utf8Decoder.decode(commit.getResult());
console.log(`*** Successfully submitted transaction to transfer ownership from ${oldOwner} to Saptha`);
console.log('*** Waiting for transaction commit');
const status = await commit.getStatus();
if (!status.successful) {
throw new Error(`Transaction ${status.transactionId} failed to commit with status code ${String(status.code)}`);
}
console.log('*** Transaction committed successfully');
}
async function readAssetByID(contract: Contract): Promise<void> {
console.log('\n--> Evaluate Transaction: ReadAsset, function returns asset attributes');
const resultBytes = await contract.evaluateTransaction('ReadAsset', assetId);
const resultJson = utf8Decoder.decode(resultBytes);
const result: unknown = JSON.parse(resultJson);
console.log('*** Result:', result);
}
/**
* submitTransaction() will throw an error containing details of any error responses from the smart contract.
*/
async function updateNonExistentAsset(contract: Contract): Promise<void>{
console.log('\n--> Submit Transaction: UpdateAsset asset70, asset70 does not exist and should return an error');
try {
await contract.submitTransaction(
'UpdateAsset',
'asset70',
'blue',
'5',
'Tomoko',
'300',
);
console.log('******** FAILED to return an error');
} catch (error) {
console.log('*** Successfully caught the error: \n', error);
}
}
/**
* envOrDefault() will return the value of an environment variable, or a default value if the variable is undefined.
*/
function envOrDefault(key: string, defaultValue: string): string {
return process.env[key] || defaultValue;
}
/**
* displayInputParameters() will print the global scope parameters used by the main driver routine.
*/
function displayInputParameters(): void {
console.log(`channelName: ${channelName}`);
console.log(`chaincodeName: ${chaincodeName}`);
console.log(`mspId: ${mspId}`);
console.log(`cryptoPath: ${cryptoPath}`);
console.log(`keyDirectoryPath: ${keyDirectoryPath}`);
console.log(`certDirectoryPath: ${certDirectoryPath}`);
console.log(`tlsCertPath: ${tlsCertPath}`);
console.log(`peerEndpoint: ${peerEndpoint}`);
console.log(`peerHostAlias: ${peerHostAlias}`);
}

View file

@ -1,15 +0,0 @@
{
"extends": "@tsconfig/node18/tsconfig.json",
"compilerOptions": {
"outDir": "dist",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"noUnusedLocals": true,
"noImplicitReturns": true,
"noUncheckedIndexedAccess": true,
"forceConsistentCasingInFileNames": true
},
"include": ["./src/**/*"],
"exclude": ["./src/**/*.spec.ts"]
}

View file

@ -1,5 +0,0 @@
#
# SPDX-License-Identifier: Apache-2.0
#
coverage

View file

@ -1,39 +0,0 @@
/*
* SPDX-License-Identifier: Apache-2.0
*/
module.exports = {
env: {
node: true,
mocha: true,
es6: true
},
parserOptions: {
ecmaVersion: 8,
sourceType: 'script'
},
extends: "eslint:recommended",
rules: {
indent: ['error', 4],
'linebreak-style': ['error', 'unix'],
quotes: ['error', 'single'],
semi: ['error', 'always'],
'no-unused-vars': ['error', { args: 'none' }],
'no-console': 'off',
curly: 'error',
eqeqeq: 'error',
'no-throw-literal': 'error',
strict: 'error',
'no-var': 'error',
'dot-notation': 'error',
'no-tabs': 'error',
'no-trailing-spaces': 'error',
'no-use-before-define': 'error',
'no-useless-call': 'error',
'no-with': 'error',
'operator-linebreak': 'error',
yoda: 'error',
'quote-props': ['error', 'as-needed'],
'no-constant-condition': ["error", { "checkLoops": false }]
}
};

View file

@ -1,15 +0,0 @@
#
# SPDX-License-Identifier: Apache-2.0
#
# Coverage directory used by tools like istanbul
coverage
# Report cache used by istanbul
.nyc_output
# Dependency directories
node_modules/
jspm_packages/
package-lock.json

View file

@ -1,12 +0,0 @@
/*
* Copyright IBM Corp. All Rights Reserved.
*
* SPDX-License-Identifier: Apache-2.0
*/
'use strict';
const assetTransfer = require('./lib/assetTransfer');
module.exports.AssetTransfer = assetTransfer;
module.exports.contracts = [assetTransfer];

View file

@ -1,167 +0,0 @@
/*
* Copyright IBM Corp. All Rights Reserved.
*
* SPDX-License-Identifier: Apache-2.0
*/
'use strict';
// Deterministic JSON.stringify()
const stringify = require('json-stringify-deterministic');
const sortKeysRecursive = require('sort-keys-recursive');
const { Contract } = require('fabric-contract-api');
class AssetTransfer extends Contract {
async InitLedger(ctx) {
const assets = [
{
ID: 'asset1',
Color: 'blue',
Size: 5,
Owner: 'Tomoko',
AppraisedValue: 300,
},
{
ID: 'asset2',
Color: 'red',
Size: 5,
Owner: 'Brad',
AppraisedValue: 400,
},
{
ID: 'asset3',
Color: 'green',
Size: 10,
Owner: 'Jin Soo',
AppraisedValue: 500,
},
{
ID: 'asset4',
Color: 'yellow',
Size: 10,
Owner: 'Max',
AppraisedValue: 600,
},
{
ID: 'asset5',
Color: 'black',
Size: 15,
Owner: 'Adriana',
AppraisedValue: 700,
},
{
ID: 'asset6',
Color: 'white',
Size: 15,
Owner: 'Michel',
AppraisedValue: 800,
},
];
for (const asset of assets) {
asset.docType = 'asset';
// example of how to write to world state deterministically
// use convetion of alphabetic order
// we insert data in alphabetic order using 'json-stringify-deterministic' and 'sort-keys-recursive'
// when retrieving data, in any lang, the order of data will be the same and consequently also the corresonding hash
await ctx.stub.putState(asset.ID, Buffer.from(stringify(sortKeysRecursive(asset))));
}
}
// CreateAsset issues a new asset to the world state with given details.
async CreateAsset(ctx, id, color, size, owner, appraisedValue) {
const exists = await this.AssetExists(ctx, id);
if (exists) {
throw new Error(`The asset ${id} already exists`);
}
const asset = {
ID: id,
Color: color,
Size: size,
Owner: owner,
AppraisedValue: appraisedValue,
};
// we insert data in alphabetic order using 'json-stringify-deterministic' and 'sort-keys-recursive'
await ctx.stub.putState(id, Buffer.from(stringify(sortKeysRecursive(asset))));
return JSON.stringify(asset);
}
// ReadAsset returns the asset stored in the world state with given id.
async ReadAsset(ctx, id) {
const assetJSON = await ctx.stub.getState(id); // get the asset from chaincode state
if (!assetJSON || assetJSON.length === 0) {
throw new Error(`The asset ${id} does not exist`);
}
return assetJSON.toString();
}
// UpdateAsset updates an existing asset in the world state with provided parameters.
async UpdateAsset(ctx, id, color, size, owner, appraisedValue) {
const exists = await this.AssetExists(ctx, id);
if (!exists) {
throw new Error(`The asset ${id} does not exist`);
}
// overwriting original asset with new asset
const updatedAsset = {
ID: id,
Color: color,
Size: size,
Owner: owner,
AppraisedValue: appraisedValue,
};
// we insert data in alphabetic order using 'json-stringify-deterministic' and 'sort-keys-recursive'
return ctx.stub.putState(id, Buffer.from(stringify(sortKeysRecursive(updatedAsset))));
}
// DeleteAsset deletes an given asset from the world state.
async DeleteAsset(ctx, id) {
const exists = await this.AssetExists(ctx, id);
if (!exists) {
throw new Error(`The asset ${id} does not exist`);
}
return ctx.stub.deleteState(id);
}
// AssetExists returns true when asset with given ID exists in world state.
async AssetExists(ctx, id) {
const assetJSON = await ctx.stub.getState(id);
return assetJSON && assetJSON.length > 0;
}
// TransferAsset updates the owner field of asset with given id in the world state.
async TransferAsset(ctx, id, newOwner) {
const assetString = await this.ReadAsset(ctx, id);
const asset = JSON.parse(assetString);
const oldOwner = asset.Owner;
asset.Owner = newOwner;
// we insert data in alphabetic order using 'json-stringify-deterministic' and 'sort-keys-recursive'
await ctx.stub.putState(id, Buffer.from(stringify(sortKeysRecursive(asset))));
return oldOwner;
}
// GetAllAssets returns all assets found in the world state.
async GetAllAssets(ctx) {
const allResults = [];
// range query with empty string for startKey and endKey does an open-ended query of all assets in the chaincode namespace.
const iterator = await ctx.stub.getStateByRange('', '');
let result = await iterator.next();
while (!result.done) {
const strValue = Buffer.from(result.value.value.toString()).toString('utf8');
let record;
try {
record = JSON.parse(strValue);
} catch (err) {
console.log(err);
record = strValue;
}
allResults.push(record);
result = await iterator.next();
}
return JSON.stringify(allResults);
}
}
module.exports = AssetTransfer;

File diff suppressed because it is too large Load diff

View file

@ -1,50 +0,0 @@
{
"name": "asset-transfer-basic",
"version": "1.0.0",
"description": "Asset-Transfer-Basic contract implemented in JavaScript",
"main": "index.js",
"engines": {
"node": ">=18"
},
"scripts": {
"lint": "eslint *.js */**.js",
"pretest": "npm run lint",
"test": "nyc mocha --recursive",
"start": "fabric-chaincode-node start"
},
"engineStrict": true,
"author": "Hyperledger",
"license": "Apache-2.0",
"dependencies": {
"fabric-contract-api": "~2.5",
"fabric-shim": "~2.5",
"json-stringify-deterministic": "^1.0.0",
"sort-keys-recursive": "^2.1.0"
},
"devDependencies": {
"chai": "^4.4.1",
"eslint": "^8.57.0",
"mocha": "^10.4.0",
"nyc": "^15.1.0",
"sinon": "^18.0.0",
"sinon-chai": "^3.7.0"
},
"nyc": {
"exclude": [
"coverage/**",
"test/**",
"index.js",
".eslintrc.js"
],
"reporter": [
"text-summary",
"html"
],
"all": true,
"check-coverage": true,
"statements": 100,
"branches": 100,
"functions": 100,
"lines": 100
}
}

View file

@ -1,268 +0,0 @@
/*
* Copyright IBM Corp. All Rights Reserved.
*
* SPDX-License-Identifier: Apache-2.0
*/
'use strict';
const sinon = require('sinon');
const chai = require('chai');
const sinonChai = require('sinon-chai');
const expect = chai.expect;
const { Context } = require('fabric-contract-api');
const { ChaincodeStub } = require('fabric-shim');
const AssetTransfer = require('../lib/assetTransfer.js');
let assert = sinon.assert;
chai.use(sinonChai);
describe('Asset Transfer Basic Tests', () => {
let transactionContext, chaincodeStub, asset;
beforeEach(() => {
transactionContext = new Context();
chaincodeStub = sinon.createStubInstance(ChaincodeStub);
transactionContext.setChaincodeStub(chaincodeStub);
chaincodeStub.putState.callsFake((key, value) => {
if (!chaincodeStub.states) {
chaincodeStub.states = {};
}
chaincodeStub.states[key] = value;
});
chaincodeStub.getState.callsFake(async (key) => {
let ret;
if (chaincodeStub.states) {
ret = chaincodeStub.states[key];
}
return Promise.resolve(ret);
});
chaincodeStub.deleteState.callsFake(async (key) => {
if (chaincodeStub.states) {
delete chaincodeStub.states[key];
}
return Promise.resolve(key);
});
chaincodeStub.getStateByRange.callsFake(async () => {
function* internalGetStateByRange() {
if (chaincodeStub.states) {
// Shallow copy
const copied = Object.assign({}, chaincodeStub.states);
for (let key in copied) {
yield {value: copied[key]};
}
}
}
return Promise.resolve(internalGetStateByRange());
});
asset = {
ID: 'asset1',
Color: 'blue',
Size: 5,
Owner: 'Tomoko',
AppraisedValue: 300,
};
});
describe('Test InitLedger', () => {
it('should return error on InitLedger', async () => {
chaincodeStub.putState.rejects('failed inserting key');
let assetTransfer = new AssetTransfer();
try {
await assetTransfer.InitLedger(transactionContext);
assert.fail('InitLedger should have failed');
} catch (err) {
expect(err.name).to.equal('failed inserting key');
}
});
it('should return success on InitLedger', async () => {
let assetTransfer = new AssetTransfer();
await assetTransfer.InitLedger(transactionContext);
let ret = JSON.parse((await chaincodeStub.getState('asset1')).toString());
expect(ret).to.eql(Object.assign({docType: 'asset'}, asset));
});
});
describe('Test CreateAsset', () => {
it('should return error on CreateAsset', async () => {
chaincodeStub.putState.rejects('failed inserting key');
let assetTransfer = new AssetTransfer();
try {
await assetTransfer.CreateAsset(transactionContext, asset.ID, asset.Color, asset.Size, asset.Owner, asset.AppraisedValue);
assert.fail('CreateAsset should have failed');
} catch(err) {
expect(err.name).to.equal('failed inserting key');
}
});
it('should return success on CreateAsset', async () => {
let assetTransfer = new AssetTransfer();
await assetTransfer.CreateAsset(transactionContext, asset.ID, asset.Color, asset.Size, asset.Owner, asset.AppraisedValue);
let ret = JSON.parse((await chaincodeStub.getState(asset.ID)).toString());
expect(ret).to.eql(asset);
});
});
describe('Test ReadAsset', () => {
it('should return error on ReadAsset', async () => {
let assetTransfer = new AssetTransfer();
await assetTransfer.CreateAsset(transactionContext, asset.ID, asset.Color, asset.Size, asset.Owner, asset.AppraisedValue);
try {
await assetTransfer.ReadAsset(transactionContext, 'asset2');
assert.fail('ReadAsset should have failed');
} catch (err) {
expect(err.message).to.equal('The asset asset2 does not exist');
}
});
it('should return success on ReadAsset', async () => {
let assetTransfer = new AssetTransfer();
await assetTransfer.CreateAsset(transactionContext, asset.ID, asset.Color, asset.Size, asset.Owner, asset.AppraisedValue);
let ret = JSON.parse(await chaincodeStub.getState(asset.ID));
expect(ret).to.eql(asset);
});
});
describe('Test UpdateAsset', () => {
it('should return error on UpdateAsset', async () => {
let assetTransfer = new AssetTransfer();
await assetTransfer.CreateAsset(transactionContext, asset.ID, asset.Color, asset.Size, asset.Owner, asset.AppraisedValue);
try {
await assetTransfer.UpdateAsset(transactionContext, 'asset2', 'orange', 10, 'Me', 500);
assert.fail('UpdateAsset should have failed');
} catch (err) {
expect(err.message).to.equal('The asset asset2 does not exist');
}
});
it('should return success on UpdateAsset', async () => {
let assetTransfer = new AssetTransfer();
await assetTransfer.CreateAsset(transactionContext, asset.ID, asset.Color, asset.Size, asset.Owner, asset.AppraisedValue);
await assetTransfer.UpdateAsset(transactionContext, 'asset1', 'orange', 10, 'Me', 500);
let ret = JSON.parse(await chaincodeStub.getState(asset.ID));
let expected = {
ID: 'asset1',
Color: 'orange',
Size: 10,
Owner: 'Me',
AppraisedValue: 500
};
expect(ret).to.eql(expected);
});
});
describe('Test DeleteAsset', () => {
it('should return error on DeleteAsset', async () => {
let assetTransfer = new AssetTransfer();
await assetTransfer.CreateAsset(transactionContext, asset.ID, asset.Color, asset.Size, asset.Owner, asset.AppraisedValue);
try {
await assetTransfer.DeleteAsset(transactionContext, 'asset2');
assert.fail('DeleteAsset should have failed');
} catch (err) {
expect(err.message).to.equal('The asset asset2 does not exist');
}
});
it('should return success on DeleteAsset', async () => {
let assetTransfer = new AssetTransfer();
await assetTransfer.CreateAsset(transactionContext, asset.ID, asset.Color, asset.Size, asset.Owner, asset.AppraisedValue);
await assetTransfer.DeleteAsset(transactionContext, asset.ID);
let ret = await chaincodeStub.getState(asset.ID);
expect(ret).to.equal(undefined);
});
});
describe('Test TransferAsset', () => {
it('should return error on TransferAsset', async () => {
let assetTransfer = new AssetTransfer();
await assetTransfer.CreateAsset(transactionContext, asset.ID, asset.Color, asset.Size, asset.Owner, asset.AppraisedValue);
try {
await assetTransfer.TransferAsset(transactionContext, 'asset2', 'Me');
assert.fail('DeleteAsset should have failed');
} catch (err) {
expect(err.message).to.equal('The asset asset2 does not exist');
}
});
it('should return success on TransferAsset', async () => {
let assetTransfer = new AssetTransfer();
await assetTransfer.CreateAsset(transactionContext, asset.ID, asset.Color, asset.Size, asset.Owner, asset.AppraisedValue);
await assetTransfer.TransferAsset(transactionContext, asset.ID, 'Me');
let ret = JSON.parse((await chaincodeStub.getState(asset.ID)).toString());
expect(ret).to.eql(Object.assign({}, asset, {Owner: 'Me'}));
});
});
describe('Test GetAllAssets', () => {
it('should return success on GetAllAssets', async () => {
let assetTransfer = new AssetTransfer();
await assetTransfer.CreateAsset(transactionContext, 'asset1', 'blue', 5, 'Robert', 100);
await assetTransfer.CreateAsset(transactionContext, 'asset2', 'orange', 10, 'Paul', 200);
await assetTransfer.CreateAsset(transactionContext, 'asset3', 'red', 15, 'Troy', 300);
await assetTransfer.CreateAsset(transactionContext, 'asset4', 'pink', 20, 'Van', 400);
let ret = await assetTransfer.GetAllAssets(transactionContext);
ret = JSON.parse(ret);
expect(ret.length).to.equal(4);
let expected = [
{ID: 'asset1', Color: 'blue', Size: 5, Owner: 'Robert', AppraisedValue: 100},
{ID: 'asset2', Color: 'orange', Size: 10, Owner: 'Paul', AppraisedValue: 200},
{ID: 'asset3', Color: 'red', Size: 15, Owner: 'Troy', AppraisedValue: 300},
{ID: 'asset4', Color: 'pink', Size: 20, Owner: 'Van', AppraisedValue: 400}
];
expect(ret).to.eql(expected);
});
it('should return success on GetAllAssets for non JSON value', async () => {
let assetTransfer = new AssetTransfer();
chaincodeStub.putState.onFirstCall().callsFake((key, value) => {
if (!chaincodeStub.states) {
chaincodeStub.states = {};
}
chaincodeStub.states[key] = 'non-json-value';
});
await assetTransfer.CreateAsset(transactionContext, 'asset1', 'blue', 5, 'Robert', 100);
await assetTransfer.CreateAsset(transactionContext, 'asset2', 'orange', 10, 'Paul', 200);
await assetTransfer.CreateAsset(transactionContext, 'asset3', 'red', 15, 'Troy', 300);
await assetTransfer.CreateAsset(transactionContext, 'asset4', 'pink', 20, 'Van', 400);
let ret = await assetTransfer.GetAllAssets(transactionContext);
ret = JSON.parse(ret);
expect(ret.length).to.equal(4);
let expected = [
'non-json-value',
{ID: 'asset2', Color: 'orange', Size: 10, Owner: 'Paul', AppraisedValue: 200},
{ID: 'asset3', Color: 'red', Size: 15, Owner: 'Troy', AppraisedValue: 300},
{ID: 'asset4', Color: 'pink', Size: 20, Owner: 'Van', AppraisedValue: 400}
];
expect(ret).to.eql(expected);
});
});
});

View file

@ -1 +0,0 @@
node_modules

View file

@ -1,16 +0,0 @@
#
# SPDX-License-Identifier: Apache-2.0
#
# Coverage directory used by tools like istanbul
coverage
# Dependency directories
node_modules/
jspm_packages/
package-lock.json
# Compiled TypeScript files
dist

View file

@ -1,36 +0,0 @@
#
# SPDX-License-Identifier: Apache-2.0
#
FROM node:16 AS builder
WORKDIR /usr/src/app
# Copy node.js source and build, changing owner as well
COPY --chown=node:node . /usr/src/app
ENV npm_config_cache=/usr/src/app
RUN npm ci && npm run package
FROM node:16 AS production
ARG CC_SERVER_PORT
# Setup tini to work better handle signals
ENV TINI_VERSION v0.19.0
ENV PLATFORM=amd64
ADD https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini-${PLATFORM} /tini
RUN chmod +x /tini
WORKDIR /usr/src/app
COPY --chown=node:node --from=builder /usr/src/app/dist ./dist
COPY --chown=node:node --from=builder /usr/src/app/package.json ./
COPY --chown=node:node --from=builder /usr/src/app/npm-shrinkwrap.json ./
COPY --chown=node:node docker/docker-entrypoint.sh /usr/src/app/docker-entrypoint.sh
RUN npm ci --omit=dev && npm cache clean --force
ENV PORT $CC_SERVER_PORT
EXPOSE $CC_SERVER_PORT
ENV NODE_ENV=production
USER node
ENTRYPOINT [ "/tini", "--", "/usr/src/app/docker-entrypoint.sh" ]

View file

@ -1,16 +0,0 @@
#!/usr/bin/env bash
#
# SPDX-License-Identifier: Apache-2.0
#
set -euo pipefail
: ${CORE_PEER_TLS_ENABLED:="false"}
: ${DEBUG:="false"}
if [ "${DEBUG,,}" = "true" ]; then
npm run start:server-debug
elif [ "${CORE_PEER_TLS_ENABLED,,}" = "true" ]; then
npm run start:server
else
npm run start:server-nontls
fi

View file

@ -1,13 +0,0 @@
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
export default tseslint.config(js.configs.recommended, ...tseslint.configs.strictTypeChecked, {
languageOptions: {
ecmaVersion: 2023,
sourceType: 'module',
parserOptions: {
project: 'tsconfig.json',
tsconfigRootDir: import.meta.dirname,
},
},
});

File diff suppressed because it is too large Load diff

View file

@ -1,41 +0,0 @@
{
"name": "asset-transfer-basic",
"version": "1.0.0",
"description": "Asset Transfer Basic contract implemented in TypeScript",
"main": "dist/index.js",
"typings": "dist/index.d.ts",
"engines": {
"node": ">=18"
},
"scripts": {
"lint": "eslint src",
"pretest": "npm run lint",
"test": "",
"start": "set -x && fabric-chaincode-node start",
"build": "tsc",
"build:watch": "tsc -w",
"prepublishOnly": "npm run build",
"docker": "docker build -f ./Dockerfile -t asset-transfer-basic .",
"package": "npm run build && npm shrinkwrap",
"start:server-nontls": "set -x && fabric-chaincode-node server --chaincode-address=$CHAINCODE_SERVER_ADDRESS --chaincode-id=$CHAINCODE_ID",
"start:server-debug": "set -x && NODE_OPTIONS='--inspect=0.0.0.0:9229' fabric-chaincode-node server --chaincode-address=$CHAINCODE_SERVER_ADDRESS --chaincode-id=$CHAINCODE_ID",
"start:server": "set -x && fabric-chaincode-node server --chaincode-address=$CHAINCODE_SERVER_ADDRESS --chaincode-id=$CHAINCODE_ID --chaincode-tls-key-file=/hyperledger/privatekey.pem --chaincode-tls-client-cacert-file=/hyperledger/rootcert.pem --chaincode-tls-cert-file=/hyperledger/cert.pem"
},
"engineStrict": true,
"author": "Hyperledger",
"license": "Apache-2.0",
"dependencies": {
"fabric-contract-api": "~2.5",
"fabric-shim": "~2.5",
"json-stringify-deterministic": "^1.0.0",
"sort-keys-recursive": "^2.1.0"
},
"devDependencies": {
"@types/node": "^18.19.33",
"@eslint/js": "^9.3.0",
"@tsconfig/node18": "^18.2.4",
"eslint": "^8.57.0",
"typescript": "~5.4.5",
"typescript-eslint": "^7.11.0"
}
}

View file

@ -1,26 +0,0 @@
/*
SPDX-License-Identifier: Apache-2.0
*/
import {Object, Property} from 'fabric-contract-api';
@Object()
export class Asset {
@Property()
public docType?: string;
@Property()
public ID: string = '';
@Property()
public Color: string = '';
@Property()
public Size: number = 0;
@Property()
public Owner: string = '';
@Property()
public AppraisedValue: number = 0;
}

View file

@ -1,173 +0,0 @@
/*
* SPDX-License-Identifier: Apache-2.0
*/
// Deterministic JSON.stringify()
import {Context, Contract, Info, Returns, Transaction} from 'fabric-contract-api';
import stringify from 'json-stringify-deterministic';
import sortKeysRecursive from 'sort-keys-recursive';
import {Asset} from './asset';
@Info({title: 'AssetTransfer', description: 'Smart contract for trading assets'})
export class AssetTransferContract extends Contract {
@Transaction()
public async InitLedger(ctx: Context): Promise<void> {
const assets: Asset[] = [
{
ID: 'asset1',
Color: 'blue',
Size: 5,
Owner: 'Tomoko',
AppraisedValue: 300,
},
{
ID: 'asset2',
Color: 'red',
Size: 5,
Owner: 'Brad',
AppraisedValue: 400,
},
{
ID: 'asset3',
Color: 'green',
Size: 10,
Owner: 'Jin Soo',
AppraisedValue: 500,
},
{
ID: 'asset4',
Color: 'yellow',
Size: 10,
Owner: 'Max',
AppraisedValue: 600,
},
{
ID: 'asset5',
Color: 'black',
Size: 15,
Owner: 'Adriana',
AppraisedValue: 700,
},
{
ID: 'asset6',
Color: 'white',
Size: 15,
Owner: 'Michel',
AppraisedValue: 800,
},
];
for (const asset of assets) {
asset.docType = 'asset';
// example of how to write to world state deterministically
// use convetion of alphabetic order
// we insert data in alphabetic order using 'json-stringify-deterministic' and 'sort-keys-recursive'
// when retrieving data, in any lang, the order of data will be the same and consequently also the corresonding hash
await ctx.stub.putState(asset.ID, Buffer.from(stringify(sortKeysRecursive(asset))));
console.info(`Asset ${asset.ID} initialized`);
}
}
// CreateAsset issues a new asset to the world state with given details.
@Transaction()
public async CreateAsset(ctx: Context, id: string, color: string, size: number, owner: string, appraisedValue: number): Promise<void> {
const exists = await this.AssetExists(ctx, id);
if (exists) {
throw new Error(`The asset ${id} already exists`);
}
const asset = {
ID: id,
Color: color,
Size: size,
Owner: owner,
AppraisedValue: appraisedValue,
};
// we insert data in alphabetic order using 'json-stringify-deterministic' and 'sort-keys-recursive'
await ctx.stub.putState(id, Buffer.from(stringify(sortKeysRecursive(asset))));
}
// ReadAsset returns the asset stored in the world state with given id.
@Transaction(false)
public async ReadAsset(ctx: Context, id: string): Promise<string> {
const assetJSON = await ctx.stub.getState(id); // get the asset from chaincode state
if (assetJSON.length === 0) {
throw new Error(`The asset ${id} does not exist`);
}
return assetJSON.toString();
}
// UpdateAsset updates an existing asset in the world state with provided parameters.
@Transaction()
public async UpdateAsset(ctx: Context, id: string, color: string, size: number, owner: string, appraisedValue: number): Promise<void> {
const exists = await this.AssetExists(ctx, id);
if (!exists) {
throw new Error(`The asset ${id} does not exist`);
}
// overwriting original asset with new asset
const updatedAsset = {
ID: id,
Color: color,
Size: size,
Owner: owner,
AppraisedValue: appraisedValue,
};
// we insert data in alphabetic order using 'json-stringify-deterministic' and 'sort-keys-recursive'
return ctx.stub.putState(id, Buffer.from(stringify(sortKeysRecursive(updatedAsset))));
}
// DeleteAsset deletes an given asset from the world state.
@Transaction()
public async DeleteAsset(ctx: Context, id: string): Promise<void> {
const exists = await this.AssetExists(ctx, id);
if (!exists) {
throw new Error(`The asset ${id} does not exist`);
}
return ctx.stub.deleteState(id);
}
// AssetExists returns true when asset with given ID exists in world state.
@Transaction(false)
@Returns('boolean')
public async AssetExists(ctx: Context, id: string): Promise<boolean> {
const assetJSON = await ctx.stub.getState(id);
return assetJSON.length > 0;
}
// TransferAsset updates the owner field of asset with given id in the world state, and returns the old owner.
@Transaction()
public async TransferAsset(ctx: Context, id: string, newOwner: string): Promise<string> {
const assetString = await this.ReadAsset(ctx, id);
const asset = JSON.parse(assetString) as Asset;
const oldOwner = asset.Owner;
asset.Owner = newOwner;
// we insert data in alphabetic order using 'json-stringify-deterministic' and 'sort-keys-recursive'
await ctx.stub.putState(id, Buffer.from(stringify(sortKeysRecursive(asset))));
return oldOwner;
}
// GetAllAssets returns all assets found in the world state.
@Transaction(false)
@Returns('string')
public async GetAllAssets(ctx: Context): Promise<string> {
const allResults = [];
// range query with empty string for startKey and endKey does an open-ended query of all assets in the chaincode namespace.
const iterator = await ctx.stub.getStateByRange('', '');
let result = await iterator.next();
while (!result.done) {
const strValue = Buffer.from(result.value.value.toString()).toString('utf8');
let record;
try {
record = JSON.parse(strValue) as Asset;
} catch (err) {
console.log(err);
record = strValue;
}
allResults.push(record);
result = await iterator.next();
}
return JSON.stringify(allResults);
}
}

View file

@ -1,8 +0,0 @@
/*
* SPDX-License-Identifier: Apache-2.0
*/
import {type Contract} from 'fabric-contract-api';
import {AssetTransferContract} from './assetTransfer';
export const contracts: typeof Contract[] = [AssetTransferContract];

View file

@ -1,17 +0,0 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@tsconfig/node18/tsconfig.json",
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "dist",
"strict": true,
"noUnusedLocals": true,
"noImplicitReturns": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/"]
}

View file

@ -1,2 +0,0 @@
Requests.http
rest-api-go

View file

@ -1,39 +0,0 @@
# Asset Transfer REST API Sample
This is a simple REST server written in golang with endpoints for chaincode invoke and query.
## Usage
- Setup fabric test network and deploy the asset transfer chaincode by [following this instructions](https://hyperledger-fabric.readthedocs.io/en/release-2.4/test_network.html).
- cd into rest-api-go directory
- Download required dependencies using `go mod download`
- Run `go run main.go` to run the REST server
## Sending Requests
Invoke endpoint accepts POST requests with chaincode function and arguments. Query endpoint accepts get requests with chaincode function and arguments.
Sample chaincode invoke for the "createAsset" function. Response will contain transaction ID for a successful invoke.
``` sh
curl --request POST \
--url http://localhost:3000/invoke \
--header 'content-type: application/x-www-form-urlencoded' \
--data = \
--data channelid=mychannel \
--data chaincodeid=basic \
--data function=createAsset \
--data args=Asset123 \
--data args=yellow \
--data args=54 \
--data args=Tom \
--data args=13005
```
Sample chaincode query for getting asset details.
``` sh
curl --request GET \
--url 'http://localhost:3000/query?channelid=mychannel&chaincodeid=basic&function=ReadAsset&args=Asset123'
```

View file

@ -1,19 +0,0 @@
module rest-api-go
go 1.21
require (
github.com/hyperledger/fabric-gateway v1.5.0
google.golang.org/grpc v1.63.2
)
require (
github.com/hyperledger/fabric-protos-go-apiv2 v0.3.3 // indirect
github.com/miekg/pkcs11 v1.1.1 // indirect
golang.org/x/crypto v0.22.0 // indirect
golang.org/x/net v0.24.0 // indirect
golang.org/x/sys v0.19.0 // indirect
golang.org/x/text v0.14.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240401170217-c3f982113cda // indirect
google.golang.org/protobuf v1.33.0 // indirect
)

View file

@ -1,32 +0,0 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/hyperledger/fabric-gateway v1.5.0 h1:JChlqtJNm2479Q8YWJ6k8wwzOiu2IRrV3K8ErsQmdTU=
github.com/hyperledger/fabric-gateway v1.5.0/go.mod h1:v13OkXAp7pKi4kh6P6epn27SyivRbljr8Gkfy8JlbtM=
github.com/hyperledger/fabric-protos-go-apiv2 v0.3.3 h1:Xpd6fzG/KjAOHJsq7EQXY2l+qi/y8muxBaY7R6QWABk=
github.com/hyperledger/fabric-protos-go-apiv2 v0.3.3/go.mod h1:2pq0ui6ZWA0cC8J+eCErgnMDCS1kPOEYVY+06ZAK0qE=
github.com/miekg/pkcs11 v1.1.1 h1:Ugu9pdy6vAYku5DEpVWVFPYnzV+bxB+iRdbuFSu7TvU=
github.com/miekg/pkcs11 v1.1.1/go.mod h1:XsNlhZGX73bx86s2hdc/FuaLm2CPZJemRLMA+WTFxgs=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
go.uber.org/mock v0.3.0 h1:3mUxI1No2/60yUYax92Pt8eNOEecx2D3lcXZh2NEZJo=
go.uber.org/mock v0.3.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc=
golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30=
golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w=
golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o=
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240401170217-c3f982113cda h1:LI5DOvAxUPMv/50agcLLoo+AdWc1irS9Rzz4vPuD1V4=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240401170217-c3f982113cda/go.mod h1:WtryC6hu0hhx87FDGxWCDptyssuo68sk10vYjF+T9fY=
google.golang.org/grpc v1.63.2 h1:MUeiw1B2maTVZthpU5xvASfTh3LDbxHd6IJ6QQVU+xM=
google.golang.org/grpc v1.63.2/go.mod h1:WAX/8DgncnokcFUldAxq7GeB5DXHDbMF+lLvDomNkRA=
google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View file

@ -1,26 +0,0 @@
package main
import (
"fmt"
"rest-api-go/web"
)
func main() {
//Initialize setup for Org1
cryptoPath := "../../test-network/organizations/peerOrganizations/org1.example.com"
orgConfig := web.OrgSetup{
OrgName: "Org1",
MSPID: "Org1MSP",
CertPath: cryptoPath + "/users/User1@org1.example.com/msp/signcerts/cert.pem",
KeyPath: cryptoPath + "/users/User1@org1.example.com/msp/keystore/",
TLSCertPath: cryptoPath + "/peers/peer0.org1.example.com/tls/ca.crt",
PeerEndpoint: "dns:///localhost:7051",
GatewayPeer: "peer0.org1.example.com",
}
orgSetup, err := web.Initialize(orgConfig)
if err != nil {
fmt.Println("Error initializing setup for Org1: ", err)
}
web.Serve(web.OrgSetup(*orgSetup))
}

View file

@ -1,31 +0,0 @@
package web
import (
"fmt"
"net/http"
"github.com/hyperledger/fabric-gateway/pkg/client"
)
// OrgSetup contains organization's config to interact with the network.
type OrgSetup struct {
OrgName string
MSPID string
CryptoPath string
CertPath string
KeyPath string
TLSCertPath string
PeerEndpoint string
GatewayPeer string
Gateway client.Gateway
}
// Serve starts http web server.
func Serve(setups OrgSetup) {
http.HandleFunc("/query", setups.Query)
http.HandleFunc("/invoke", setups.Invoke)
fmt.Println("Listening (http://localhost:3000/)...")
if err := http.ListenAndServe(":3000", nil); err != nil {
fmt.Println(err)
}
}

View file

@ -1,106 +0,0 @@
package web
import (
"crypto/x509"
"fmt"
"log"
"os"
"path"
"time"
"github.com/hyperledger/fabric-gateway/pkg/client"
"github.com/hyperledger/fabric-gateway/pkg/identity"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
)
// Initialize the setup for the organization.
func Initialize(setup OrgSetup) (*OrgSetup, error) {
log.Printf("Initializing connection for %s...\n", setup.OrgName)
clientConnection := setup.newGrpcConnection()
id := setup.newIdentity()
sign := setup.newSign()
gateway, err := client.Connect(
id,
client.WithSign(sign),
client.WithClientConnection(clientConnection),
client.WithEvaluateTimeout(5*time.Second),
client.WithEndorseTimeout(15*time.Second),
client.WithSubmitTimeout(5*time.Second),
client.WithCommitStatusTimeout(1*time.Minute),
)
if err != nil {
panic(err)
}
setup.Gateway = *gateway
log.Println("Initialization complete")
return &setup, nil
}
// newGrpcConnection creates a gRPC connection to the Gateway server.
func (setup OrgSetup) newGrpcConnection() *grpc.ClientConn {
certificate, err := loadCertificate(setup.TLSCertPath)
if err != nil {
panic(err)
}
certPool := x509.NewCertPool()
certPool.AddCert(certificate)
transportCredentials := credentials.NewClientTLSFromCert(certPool, setup.GatewayPeer)
connection, err := grpc.NewClient(setup.PeerEndpoint, grpc.WithTransportCredentials(transportCredentials))
if err != nil {
panic(fmt.Errorf("failed to create gRPC connection: %w", err))
}
return connection
}
// newIdentity creates a client identity for this Gateway connection using an X.509 certificate.
func (setup OrgSetup) newIdentity() *identity.X509Identity {
certificate, err := loadCertificate(setup.CertPath)
if err != nil {
panic(err)
}
id, err := identity.NewX509Identity(setup.MSPID, certificate)
if err != nil {
panic(err)
}
return id
}
// newSign creates a function that generates a digital signature from a message digest using a private key.
func (setup OrgSetup) newSign() identity.Sign {
files, err := os.ReadDir(setup.KeyPath)
if err != nil {
panic(fmt.Errorf("failed to read private key directory: %w", err))
}
privateKeyPEM, err := os.ReadFile(path.Join(setup.KeyPath, files[0].Name()))
if err != nil {
panic(fmt.Errorf("failed to read private key file: %w", err))
}
privateKey, err := identity.PrivateKeyFromPEM(privateKeyPEM)
if err != nil {
panic(err)
}
sign, err := identity.NewPrivateKeySign(privateKey)
if err != nil {
panic(err)
}
return sign
}
func loadCertificate(filename string) (*x509.Certificate, error) {
certificatePEM, err := os.ReadFile(filename)
if err != nil {
return nil, fmt.Errorf("failed to read certificate file: %w", err)
}
return identity.CertificateFromPEM(certificatePEM)
}

View file

@ -1,40 +0,0 @@
package web
import (
"fmt"
"net/http"
"github.com/hyperledger/fabric-gateway/pkg/client"
)
// Invoke handles chaincode invoke requests.
func (setup *OrgSetup) Invoke(w http.ResponseWriter, r *http.Request) {
fmt.Println("Received Invoke request")
if err := r.ParseForm(); err != nil {
fmt.Fprintf(w, "ParseForm() err: %s", err)
return
}
chainCodeName := r.FormValue("chaincodeid")
channelID := r.FormValue("channelid")
function := r.FormValue("function")
args := r.Form["args"]
fmt.Printf("channel: %s, chaincode: %s, function: %s, args: %s\n", channelID, chainCodeName, function, args)
network := setup.Gateway.GetNetwork(channelID)
contract := network.GetContract(chainCodeName)
txn_proposal, err := contract.NewProposal(function, client.WithArguments(args...))
if err != nil {
fmt.Fprintf(w, "Error creating txn proposal: %s", err)
return
}
txn_endorsed, err := txn_proposal.Endorse()
if err != nil {
fmt.Fprintf(w, "Error endorsing txn: %s", err)
return
}
txn_committed, err := txn_endorsed.Submit()
if err != nil {
fmt.Fprintf(w, "Error submitting transaction: %s", err)
return
}
fmt.Fprintf(w, "Transaction ID : %s Response: %s", txn_committed.TransactionID(), txn_endorsed.Result())
}

View file

@ -1,25 +0,0 @@
package web
import (
"fmt"
"net/http"
)
// Query handles chaincode query requests.
func (setup OrgSetup) Query(w http.ResponseWriter, r *http.Request) {
fmt.Println("Received Query request")
queryParams := r.URL.Query()
chainCodeName := queryParams.Get("chaincodeid")
channelID := queryParams.Get("channelid")
function := queryParams.Get("function")
args := r.URL.Query()["args"]
fmt.Printf("channel: %s, chaincode: %s, function: %s, args: %s\n", channelID, chainCodeName, function, args)
network := setup.Gateway.GetNetwork(channelID)
contract := network.GetContract(chainCodeName)
evaluateResponse, err := contract.EvaluateTransaction(function, args...)
if err != nil {
fmt.Fprintf(w, "Error: %s", err)
return
}
fmt.Fprintf(w, "Response: %s", evaluateResponse)
}

View file

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

View file

@ -1,3 +0,0 @@
[*.ts]
indent_size = 4
quote_type = single

View file

@ -1,23 +0,0 @@
# 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

@ -1,34 +0,0 @@
{
"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

@ -1,15 +0,0 @@
#
# 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

@ -1,25 +0,0 @@
FROM node:18-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:18-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

@ -1,253 +0,0 @@
# Asset Transfer REST API Sample
Sample REST server to demonstrate good Fabric Node SDK practices.
The REST API is only 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. You need to go at least as far as the step where the ledger gets initialized with assets.
## Overview
The primary aim of this sample is to show how to write a long running client application using the Fabric Node SDK.
In particular, client applications **should not** create new connections for every transaction.
The sample also demonstrates possible error handling approaches and handles multiple requests from multiple identities.
The following sections describe the structure of the sample, or [skip to the usage section](#usage) to try it out first.
### Fabric network connections
The sample creates two long lived connections to a Fabric network in order to submit and evaluate transactions using two different identities.
The connections are made when the application starts and are retained for the life of the REST server.
Related files:
- [src/fabric.ts](src/fabric.ts)
All the sample code which interacts with the Fabric network via the Fabric SDK.
For example, the `createGateway` function which connects to the Fabric network.
- [src/index.ts](src/index.ts)
The primary entry point for the sample.
Connects to the Fabric network and starts the REST server.
### Error handling
In this sample 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
Related files:
- [src/errors.ts](src/errors.ts)
All the Fabric transaction error handling and retry logic.
### Asset REST API
While the basic asset transfer chaincode maps well to an `/api/assets` REST API, response times when submitting transactions to a Fabric network are problematic for REST APIs.
A common approach to handle long running operations in REST APIs is to immediately return `202 ACCEPTED`, with the operation being represented by another resource, namely a `job` in this sample.
Jobs are used for submitting transactions to create, update, delete, or transfer an asset.
The `202 ACCEPTED` response includes a `jobId` which can be used with the `/api/jobs` endpoint to get the results of the create, update, delete, or transfer request.
Jobs are not used to get assets, because evaluating transactions is typically much faster.
Related files:
- [src/assets.router.ts](src/assets.router.ts)
Defines the main `/api/assets` endpoint.
- [src/fabric.ts](src/fabric.ts)
All the sample code which interacts with the Fabric network via the Fabric SDK.
- [src/jobs.router.ts](src/jobs.router.ts)
Defines the `/api/jobs` endpoint for getting job status.
- [src/jobs.ts](src/jobs.ts)
Job queue implementation details.
- [src/transactions.router.ts](src/transactions.router.ts)
Defines the `/api/transactions` endpoint for getting transaction status.
**Note:** If you are not specifically interested in REST APIs, you should only need to look at the files in the [Fabric network connections](#fabric-network-connections) and [Error handling](#error-handling) sections above.
### REST server
The remaining sample files are related to the REST server aspects of the sample, rather than Fabric itself:
- [src/auth.ts](src/auth.ts)
Basic API key authentication strategy used for the sample.
- [src/config.ts](src/config.ts)
Descriptions of all the available configuration environment variables.
- [src/jobs.ts](src/jobs.ts)
Job queue implementation details.
- [src/logger.ts](src/logger.ts)
Logging implementation details.
- [src/redis.ts](src/redis.ts)
Redis implementation details.
- [src/server.ts](src/server.ts)
Express server implementation details.
**Note:** If you are not specifically interested in REST APIs, you should only need to look at the files in the [Fabric network connections](#fabric-network-connections) and [Error handling](#error-handling) sections above.
## 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
```
**Note:** see [src/config.ts](src/config.ts) for details of configuring the sample
Start a Redis server (Redis is used to store the queue of submit transactions)
```shell
export REDIS_PASSWORD=$(uuidgen)
npm run start:redis
```
Start the sample REST server
```shell
npm run start:dev
```
### Docker image
It's also possible to use the [published docker image](https://github.com/hyperledger/fabric-samples/pkgs/container/fabric-rest-sample) to run the sample
Clone the `fabric-samples` repository and change to the `fabric-samples/asset-transfer-basic/rest-api-typescript` directory before running the following commands
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
```
**Note:** see [src/config.ts](src/config.ts) for details of configuring the sample
Start the sample REST server and Redis server
```shell
export REDIS_PASSWORD=$(uuidgen)
docker-compose up -d
```
## REST API demo
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

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

@ -1,27 +0,0 @@
version: '3'
# Replace network name with the fabric test-network name
services:
redis:
image: 'redis'
command: ['--maxmemory-policy','noeviction','--requirepass','${REDIS_PASSWORD}']
ports:
- 6379:6379
networks:
- fabric_test
nodeapp:
image: 'ghcr.io/hyperledger/fabric-rest-sample:latest'
command: ['start:dotenv']
ports:
- 3000:3000
env_file:
- ./.env
environment:
- REDIS_PASSWORD
networks:
- fabric_test
networks:
fabric_test:
external: true

View file

@ -1,208 +0,0 @@
/*
* 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

@ -1,70 +0,0 @@
{
"name": "asset-transfer-basic",
"version": "1.0.0",
"description": "Asset Transfer Basic REST API implemented in TypeScript",
"main": "dist/index.js",
"engines": {
"node": ">=12"
},
"dependencies": {
"bullmq": "^1.47.2",
"cors": "^2.8.5",
"dotenv": "^10.0.0",
"env-var": "^7.0.1",
"express": "^4.18.2",
"express-validator": "^6.12.0",
"fabric-network": "^2.2.19",
"helmet": "^4.6.0",
"http-status-codes": "^2.1.4",
"ioredis": "^4.27.8",
"long": "^5.2.3",
"passport": "^0.6.0",
"passport-headerapikey": "^1.2.2",
"pino": "^6.11.3",
"pino-http": "^5.5.0",
"source-map-support": "^0.5.19"
},
"devDependencies": {
"@tsconfig/node12": "^12.1.0",
"@types/cors": "^2.8.12",
"@types/express": "^4.17.12",
"@types/ioredis": "^4.26.4",
"@types/jest": "^27.4.1",
"@types/node": "^12.20.55",
"@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": "^6.7.0",
"@typescript-eslint/parser": "^6.7.0",
"eslint": "^8.49.0",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-prettier": "^3.4.0",
"ioredis-mock": "^5.6.0",
"jest": "^29.7.0",
"jest-mock-extended": "^3.0.5",
"pino-pretty": "^5.0.2",
"prettier": "^2.3.1",
"rimraf": "^3.0.2",
"supertest": "^6.1.4",
"ts-jest": "^29.1.1",
"ts-node": "^10.1.0",
"typescript": "~5.2.0"
},
"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 --requirepass \"${REDIS_PASSWORD}\"",
"test": "jest"
},
"author": "Hyperledger",
"license": "Apache-2.0",
"private": true
}

View file

@ -1,70 +0,0 @@
#!/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

@ -1,742 +0,0 @@
/*
* 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 { jest } from '@jest/globals';
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({
wrongidfield: '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({
wrongID: '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 () => {
jest.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

@ -1,353 +0,0 @@
/*
* 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

@ -1,61 +0,0 @@
/*
* 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 },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(err: any, user: Express.User, _info: any) => {
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

@ -1,577 +0,0 @@
/*
* 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

@ -1,293 +0,0 @@
/*
* 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

@ -1,329 +0,0 @@
/*
* 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

@ -1,279 +0,0 @@
/*
* 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.
*/
export enum RetryAction {
/**
* Transactions should be retried using the same transaction ID to protect
* against duplicate transactions being committed if a timeout error occurs
*/
WithExistingTransactionId,
/**
* Transactions which could not be committed due to other errors require a
* new transaction ID when retrying
*/
WithNewTransactionId,
/**
* Transactions that failed due to a duplicate transaction error, or errors
* from the smart contract, should not be retried
*/
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 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

@ -1,314 +0,0 @@
/*
* 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

@ -1,205 +0,0 @@
/*
* SPDX-License-Identifier: Apache-2.0
*/
import {
Contract,
DefaultEventHandlerStrategies,
DefaultQueryHandlerStrategies,
Gateway,
GatewayOptions,
Network,
Transaction,
Wallet,
Wallets,
} from 'fabric-network';
import * as protos from 'fabric-protos';
import Long from 'long';
import * as config from './config';
import { handleError } from './errors';
import { logger } from './logger';
/**
* 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> => {
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

@ -1,57 +0,0 @@
/*
* 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

@ -1,97 +0,0 @@
/*
* 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
*/
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

@ -1,44 +0,0 @@
/*
* 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

@ -1,351 +0,0 @@
/*
* 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

@ -1,346 +0,0 @@
/*
* 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

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

View file

@ -1,37 +0,0 @@
/*
* 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

@ -1,51 +0,0 @@
/*
* 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

@ -1,87 +0,0 @@
/*
* 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';
import cors from 'cors';
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') {
app.use(cors());
}
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

@ -1,59 +0,0 @@
/*
* 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

@ -1,11 +0,0 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@tsconfig/node12/tsconfig.json",
"compilerOptions": {
"sourceMap": true,
"outDir": "./dist"
},
"include": [
"src/"
]
}