diff --git a/.github/scripts/install-local-cli.sh b/.github/scripts/install-local-cli.sh new file mode 100755 index 0000000000..14cf7e263b --- /dev/null +++ b/.github/scripts/install-local-cli.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash + +# +# Copyright (c) Gala Games Inc. All rights reserved. +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +set -eu + +cd "$GITHUB_WORKSPACE" + +npm i +(cd chain-cli && ../npm-pack-and-replace.sh --skipConfirmation && npm i -g gala-chain-cli-*) + +galachain --help diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3c3742ace8..e23c6f7479 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,29 +12,51 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 - - name: Install jq - run: | - sudo apt-get update - sudo apt-get install -y jq - - name: Check Copyright + - name: Verify copyright run: ./verify_copyright.sh - name: Install Node 18 uses: actions/setup-node@v4 with: node-version: 18 + - name: Install dependencies + run: npm i - name: Lint - run: | - npm i - npm run lint + run: npm run lint - name: Build run: npm run build - name: Test run: npm run test + template-ci: + name: Chaincode template CI + runs-on: ubuntu-22.04 + needs: [ ci ] + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 18 + - name: Install local CLI + run: .github/scripts/install-local-cli.sh + - name: Create test project + run: galachain init ./project-test + - name: Install dependencies + run: | + npm i --prefix ./project-test + (cd ./project-test && ../npm-pack-and-replace.sh --skipConfirmation) + - name: Lint + run: npm run lint --prefix ./project-test + - name: Build + run: npm run build --prefix ./project-test + - name: Test + run: npm run test --prefix ./project-test + template-e2e: - needs: [ci] - name: Test Project Template + name: Chaincode template E2E (watch mode) runs-on: ubuntu-22.04 + needs: [ ci ] env: GALA_CLIENT_DEV_MODE: "true" steps: @@ -55,28 +77,26 @@ jobs: docker version docker-compose version npm i -g nx - - - name: Build template - run: | - npm i - cd chain-cli - ../npm-pack-and-replace.sh --skipConfirmation - npm i -g gala-chain-cli-* - galachain --help - cd .. + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 18 + - name: Install local CLI + run: .github/scripts/install-local-cli.sh - name: Create test project + run: galachain init ./project-test + - name: Install dependencies run: | - galachain init ./project-test - cd ./project-test - - ../npm-pack-and-replace.sh --skipConfirmation - cat package.json - npm run network:start & # Run network in background and wait for it to start - npm run test - + npm i --prefix ./project-test + (cd ./project-test && ../npm-pack-and-replace.sh --skipConfirmation) + - name: Run network in watch mode and wait for it to start + run: | + npm run network:start --prefix ./project-test & sleep 120 - npm run test:e2e - + - name: Run E2E tests + run: npm run test:e2e --prefix ./project-test + - name: Verify chain browser blocks + run: | MAX_BLOCK_INDEX=$(curl --location 'http://localhost:3010/product-channel/blocks' --header 'Content-Type: application/json' | jq '.info.fromBlock') if [ $MAX_BLOCK_INDEX -lt 56]; then echo "The number of blocks is less than 56" @@ -84,11 +104,11 @@ jobs: else echo "There are at least 56 blocks after the tests ($$MAX_BLOCK_INDEX)" fi - npm run network:prune + npm run network:prune --prefix ./project-test template-image-check: - name: Template Image Check - needs: [ci] + name: Chaincode template image check + needs: [ ci ] runs-on: ubuntu-22.04 steps: - name: Checkout @@ -97,17 +117,17 @@ jobs: uses: actions/setup-node@v4 with: node-version: 18 - - name: Install tools + - name: Install local CLI + run: .github/scripts/install-local-cli.sh + - name: Create test project + run: galachain init ./project-test + - name: Install dependencies + run: | + npm i --prefix ./project-test + (cd ./project-test && ../npm-pack-and-replace.sh --skipConfirmation) + - name: Verify image run: | - npm i - cd chain-cli - npm run build - npm link ./ - cd .. - galachain init ./project-test cd ./project-test - ../npm-pack-and-replace.sh --skipConfirmation - npm i docker build . -t chaincode-test run_output=$(docker run --rm chaincode-test 2>&1 || true) echo "$run_output" @@ -121,37 +141,9 @@ jobs: exit 1 fi - chaincode-template-lint: - name: Chaincode Template Lint - runs-on: ubuntu-22.04 - steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version: 18 - - name: Install dependencies - run: npm install - - name: Build CLI and link it - run: | - cd chain-cli/chaincode-template - ../../npm-pack-and-replace.sh --skipConfirmation - cd .. - npm install - npm run build - npm link --force - - name: Chaincode lint - run: | - galachain init test-project - cd test-project - npm install - npm run build - npm run lint - publish: name: Publish Release - needs: [template-e2e, template-image-check] + needs: [ ci, template-ci, template-e2e, template-image-check ] if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v')) runs-on: ubuntu-22.04 steps: diff --git a/chain-cli/chaincode-template/e2e/gcclient.spec.ts b/chain-cli/chaincode-template/e2e/gcclient.spec.ts index a17a98a679..81fc52fd4b 100644 --- a/chain-cli/chaincode-template/e2e/gcclient.spec.ts +++ b/chain-cli/chaincode-template/e2e/gcclient.spec.ts @@ -46,7 +46,7 @@ describe("Chaincode client (PartnerOrg1)", () => { const params = { orgMsp: "PartnerOrg1", userId: "admin", - userPass: "adminpw", + userSecret: "adminpw", connectionProfilePath: path.resolve(networkRoot(), "connection-profiles/cpp-partner.json") }; @@ -83,7 +83,7 @@ describe("Chaincode client (CuratorOrg)", () => { const params: HFClientConfig = { orgMsp: "CuratorOrg", userId: "admin", - userPass: "adminpw", + userSecret: "adminpw", connectionProfilePath: path.resolve(networkRoot(), "connection-profiles/cpp-curator.json") }; @@ -132,7 +132,7 @@ describe.skip("REST API client", () => { beforeAll(() => { const params: RestApiClientConfig = { orgMsp: "CuratorOrg", - userKey: "GC_ADMIN_CURATOR", + userId: "GC_ADMIN_CURATOR", userSecret: "abc", apiUrl: "http://localhost:3000/api", configPath: path.resolve(__dirname, "api-config.json") diff --git a/chain-cli/chaincode-template/package.json b/chain-cli/chaincode-template/package.json index 7c5db38c6b..6a7ee9164f 100644 --- a/chain-cli/chaincode-template/package.json +++ b/chain-cli/chaincode-template/package.json @@ -14,10 +14,11 @@ "format": "prettier --config .prettierrc 'src/**/*.ts' 'e2e/**/*.ts' --write", "prepublishOnly": "npm run format && npm run build && npm run lint && npm run test", "network:start": "galachain network:up -C=product-channel -t=curator -n=basic-product -d=./ --envConfig=./.dev-env --watch", + "network:up": "galachain network:up -C=product-channel -t=curator -n=basic-product -d=./ --envConfig=./.dev-env", "network:prune": "galachain network:prune", "network:recreate": "npm run network:prune && rm -rf ./test-network && npm run network:start", "test": "jest", - "test:e2e": "jest --config=e2e/jest.config.js", + "test:e2e": "jest --config=e2e/jest.config.js --runInBand", "update-snapshot": "jest --updateSnapshot" }, "dependencies": { diff --git a/chain-cli/chaincode-template/rest-api-samples.sh b/chain-cli/chaincode-template/rest-api-samples.sh new file mode 100755 index 0000000000..0d18c69fa5 --- /dev/null +++ b/chain-cli/chaincode-template/rest-api-samples.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash + +# +# Copyright (c) Gala Games Inc. All rights reserved. +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +set -eu + +# Enroll a user (default admin credentials for local env) +enroll_response="$( + curl --request POST \ + --url http://localhost:8801/user/enroll \ + --data '{"id": "admin", "secret": "adminpw"}' +)" +echo "Enroll response: $enroll_response" + +token="$(echo "$enroll_response" | jq -r '.token')" + +# Call chaincode (GetChaincodeVersion - the simplest call) +version_response="$( + curl --request POST \ + --url http://localhost:8801/invoke/product-channel/basic-product \ + --header "Authorization: Bearer $token" \ + --data '{"method": "PublicKeyContract:GetChaincodeVersion", "args": []}' +)" +echo "Version response: $version_response" + + +# Call chaincode (GetMyProfile - requires signing of DTO with user's private key +get_my_profile_dto="$(galachain dto-sign test-network/dev-admin-key/dev-admin.priv.hex.txt '{}')" +echo "get_my_profile_dto: $get_my_profile_dto" +get_my_profile_payload="$( + echo '{}' | + jq '.method="PublicKeyContract:GetMyProfile"' | + jq ".args=[\"${get_my_profile_dto//\"/\\\"}\"]" +)" + +profile_response="$( + curl --request POST \ + --url http://localhost:8801/invoke/product-channel/basic-product \ + --header "Authorization: Bearer $token" \ + --header 'Content-Type: application/json' \ + --data "$get_my_profile_payload" +)" +echo "Profile response: $profile_response" diff --git a/chain-cli/network/api-config.json b/chain-cli/network/api-config.json new file mode 100644 index 0000000000..6ff6b888bd --- /dev/null +++ b/chain-cli/network/api-config.json @@ -0,0 +1,26 @@ +{ + "channels": [ + { + "pathFragment": "product", + "channelName": "product-channel", + "asLocalHost": true, + "contracts": [ + { + "pathFragment": "apple", + "chaincodeName": "basic-product", + "contractName": "AppleContract" + }, + { + "pathFragment": "public-key-contract", + "chaincodeName": "basic-product", + "contractName": "PublicKeyContract" + }, + { + "pathFragment": "token-contract", + "chaincodeName": "basic-product", + "contractName": "GalaChainToken" + } + ] + } + ] +} diff --git a/chain-cli/network/fablo-config-default.json b/chain-cli/network/fablo-config-default.json index 17a69bba42..7cea213218 100644 --- a/chain-cli/network/fablo-config-default.json +++ b/chain-cli/network/fablo-config-default.json @@ -30,6 +30,9 @@ }, "peer": { "instances": 1 + }, + "tools": { + "fabloRest": true } }, { @@ -40,6 +43,9 @@ }, "peer": { "instances": 1 + }, + "tools": { + "fabloRest": true } }, { @@ -47,6 +53,9 @@ "name": "UsersOrg1", "mspName": "UsersOrg1", "domain": "users1.local" + }, + "tools": { + "fabloRest": true } } ], diff --git a/chain-client/src/gcclient.spec.ts b/chain-client/src/gcclient.spec.ts index c0095dab06..341b370d3f 100644 --- a/chain-client/src/gcclient.spec.ts +++ b/chain-client/src/gcclient.spec.ts @@ -35,7 +35,7 @@ describe("forConnectionProfile", () => { const org = { orgMsp: "CuratorOrg", userId: "admin", - userPass: "adminpw", + userSecret: "adminpw", connectionProfilePath: "some/path" }; @@ -51,7 +51,7 @@ describe("forConnectionProfile", () => { const org = { orgMsp: "MissingOrg", userId: "admin", - userPass: "adminpw", + userSecret: "adminpw", connectionProfilePath: "some/path" }; @@ -79,7 +79,7 @@ describe("forApiConfig", () => { const api = { orgMsp: "CuratorOrg", apiUrl: "http://localhost:3000", - userKey: "GC_ADMIN_KEY", + userId: "GC_ADMIN_ID", userSecret: "GC_ADMIN_SECRET", configPath: "some/path" }; diff --git a/chain-client/src/gcclient.ts b/chain-client/src/gcclient.ts index ebfc00c81a..e9adfd96be 100644 --- a/chain-client/src/gcclient.ts +++ b/chain-client/src/gcclient.ts @@ -21,7 +21,7 @@ import { RestApiClientBuilder, loadRestApiConfig } from "./rest-api"; export interface HFClientConfig { orgMsp: string; userId?: string; - userPass?: string; + userSecret?: string; connectionProfilePath: string; } @@ -43,7 +43,7 @@ function forConnectionProfile(hf: HFClientConfig): ChainClientBuilder { throw new Error("Missing user id. Please provide it manually or in GC_USER_ID environment variable."); } - const adminPass = hf.userPass ?? process.env.GC_USER_PASS; + const adminPass = hf.userSecret ?? process.env.GC_USER_PASS; if (!adminPass) { throw new Error("Missing user pass. Please provide it manually or in GC_USER_PASS environment variable."); } @@ -54,7 +54,7 @@ function forConnectionProfile(hf: HFClientConfig): ChainClientBuilder { export interface RestApiClientConfig { orgMsp: string; apiUrl: string; - userKey?: string; + userId?: string; userSecret?: string; configPath: string; } @@ -62,7 +62,7 @@ export interface RestApiClientConfig { function forApiConfig(api: RestApiClientConfig): ChainClientBuilder { const config = loadRestApiConfig(api.configPath); - const adminKey = api.userKey ?? process.env.GC_API_KEY; + const adminKey = api.userId ?? process.env.GC_API_KEY; if (!adminKey) { throw new Error("Missing admin key. Please provide it manually or GC_API_KEY environment variable."); } diff --git a/chain-client/src/generic/ChainClient.ts b/chain-client/src/generic/ChainClient.ts index 1027efc694..3fcf0a079b 100644 --- a/chain-client/src/generic/ChainClient.ts +++ b/chain-client/src/generic/ChainClient.ts @@ -60,7 +60,7 @@ export abstract class ChainClient { abstract disconnect(): Promise; - abstract forUser(userId: string): ChainClient; + abstract forUser(userId: string, secret?: string): ChainClient; extendAPI(apiHandlerFn: (_: ChainClient) => T): this & T { const handler = apiHandlerFn(this); diff --git a/chain-client/src/hf/CAClient.ts b/chain-client/src/hf/CAClient.ts index cb497e49b9..bd44799e0c 100644 --- a/chain-client/src/hf/CAClient.ts +++ b/chain-client/src/hf/CAClient.ts @@ -115,7 +115,7 @@ async function enrollUser( wallet: Wallet, orgMspId: string, userId: string, - userPass: string + userSecret: string ): Promise { // Check to see if we've already enrolled the admin user. const identity = await wallet.get(userId); @@ -127,7 +127,7 @@ async function enrollUser( // Enroll the user, and import the new identity into the wallet. const enrollment = await caClient.enroll({ enrollmentID: userId, - enrollmentSecret: userPass + enrollmentSecret: userSecret }); const x509Identity = { diff --git a/chain-client/src/rest-api/FabloRestClient.ts b/chain-client/src/rest-api/FabloRestClient.ts new file mode 100644 index 0000000000..5944ea39f3 --- /dev/null +++ b/chain-client/src/rest-api/FabloRestClient.ts @@ -0,0 +1,178 @@ +/* + * Copyright (c) Gala Games Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { ChainCallDTO, ContractAPI, GalaChainResponse, Inferred } from "@gala-chain/api"; +import axios from "axios"; + +import { ChainClient, ChainClientBuilder, ClassType, ContractConfig, isClassType } from "../generic"; +import { RestApiAdminCredentials, SetContractApiParams, globalRestApiConfig } from "./GlobalRestApiConfig"; +import { catchAxiosError } from "./catchAxiosError"; +import { RestApiConfig } from "./loadRestApiConfig"; + +async function getPath( + restApiUrl: string, + cfg: ContractConfig, + method: string, + isWrite: boolean +): Promise { + const { api, contractPath } = globalRestApiConfig.getContractApi(cfg); + + const methodApi = api.methods.find((m) => m.methodName === method); + + if (!methodApi) { + throw new Error(`Cannot find method API for method ${method} and contract ${contractPath}`); + } + + if (isWrite && !methodApi.isWrite) { + throw new Error(`Method ${method} is read-only`); + } + + if (!isWrite && methodApi.isWrite) { + throw new Error(`Method ${method} is not read-only`); + } + + const type = isWrite ? "invoke" : "query"; + + return `${restApiUrl}/${type}/${cfg.channelName}/${cfg.chaincodeName}`; +} + +export class FabloRestClient extends ChainClient { + private readonly restApiUrl: Promise; + + constructor( + builder: Promise, + restApiUrl: string, + contractConfig: ContractConfig, + private readonly credentials: RestApiAdminCredentials, + private token: Promise, + orgMsp: string + ) { + super(builder, credentials.adminKey, contractConfig, orgMsp); + this.restApiUrl = builder.then(() => restApiUrl); + } + + public async isReady(): Promise { + await this.builder; + await this.token; + return true; + } + + async disconnect(): Promise { + // ensure all promises end, then do nothing + await this.isReady(); + } + + async submitTransaction( + method: string, + dtoOrResp?: ChainCallDTO | ClassType>, + resp?: ClassType> + ): Promise> { + const path = await getPath(await this.restApiUrl, this.contractConfig, method, true); + + return this.post(path, method, dtoOrResp, resp); + } + + async evaluateTransaction( + method: string, + dtoOrResp?: ChainCallDTO | ClassType>, + resp?: ClassType> + ): Promise> { + const path = await getPath(await this.restApiUrl, this.contractConfig, method, false); + + return this.post(path, method, dtoOrResp, resp); + } + + private async post( + path: string, + methodName: string, + dtoOrResp?: ChainCallDTO | ClassType>, + resp?: ClassType> + ): Promise> { + const [dto, responseType] = isClassType(dtoOrResp) ? [undefined, dtoOrResp] : [dtoOrResp, resp]; + const serialized = dto?.serialize() ?? "{}"; + const payload = { method: `${this.contractConfig.contractName}:${methodName}`, args: [serialized] }; + + const headers = { + Authorization: `Bearer ${await this.token}` + }; + + const response = await axios.post(path, payload, { headers }).catch((e) => catchAxiosError(e)); + + console.log(`${payload.method} response: `, JSON.stringify(response.data.response)); + + return GalaChainResponse.deserialize(responseType, response?.data?.response ?? {}); + } + + public static async enroll(restApiUrl: string, userId: string, secret: string): Promise { + const response = await axios + .post(`${restApiUrl}/user/enroll`, { id: userId, secret }) + .catch((e) => catchAxiosError(e)); + + if (!response.data.token) { + throw new Error(`User enrollment failed, invalid response: ${JSON.stringify(response.data)}`); + } + + return response.data.token; + } + + public forUser(userId: string, secret?: string): ChainClient { + this.token = this.restApiUrl.then((url) => + FabloRestClient.enroll(url, userId, secret ?? this.credentials.adminSecret) + ); + return this; + } + + public static async getContractApis( + token: string, + restApiUrl: string, + restApiConfig: RestApiConfig + ): Promise { + const headers = { + Authorization: `Bearer ${token}` + }; + + const contractApis: SetContractApiParams[] = []; + + for (const channel of restApiConfig.channels) { + for (const contract of channel.contracts) { + const contractPath = `${channel.channelName}/${contract.chaincodeName}`; + const getApiPath = `${restApiUrl}/query/${contractPath}`; + const payload = { method: `${contract.contractName}:GetContractAPI`, args: [] }; + + console.log(`Getting contract API for ${contractPath}, payload: ${JSON.stringify(payload)}`); + const apiResponse = await axios + .post(getApiPath, payload, { headers }) + .catch((e) => catchAxiosError(e)); + + console.log(`Got contract API for ${contractPath}: `, JSON.stringify(apiResponse.data)); + + if (!GalaChainResponse.isSuccess(apiResponse.data.response)) { + throw new Error( + `Failed to get ${payload.method} for ${contractPath}: ${JSON.stringify(apiResponse.data)}` + ); + } + + contractApis.push({ + channelName: channel.channelName, + chaincodeName: contract.chaincodeName, + contractName: contract.contractName, + contractPath: contractPath, + api: apiResponse.data.response.Data + }); + } + } + + return contractApis; + } +} diff --git a/chain-client/src/rest-api/GlobalRestApiConfig.ts b/chain-client/src/rest-api/GlobalRestApiConfig.ts new file mode 100644 index 0000000000..38ec5cb36d --- /dev/null +++ b/chain-client/src/rest-api/GlobalRestApiConfig.ts @@ -0,0 +1,82 @@ +/* + * Copyright (c) Gala Games Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { ContractAPI } from "@gala-chain/api"; + +import { ContractConfig } from "../generic"; + +export interface RestApiAdminCredentials { + adminKey: string; + adminSecret: string; +} + +interface ContractApiValue { + contractPath: string; + api: ContractAPI; +} + +export type SetContractApiParams = ContractApiValue & ContractConfig; + +export class GlobalRestApiConfig { + private isRestApiInitializedAndHealthy: Record = {}; + private contractApis: Record = {}; + private authorizedFabloRest: Record = {}; + + public isHealthy(apiUrl: string) { + const result = this.isRestApiInitializedAndHealthy[apiUrl]; + + if (result === true) { + return true; + } + + if (result === undefined) { + return false; + } + + throw new Error(`Failed to initialize Rest API at ${apiUrl} failed to initialize: ${result?.message}`); + } + + public markHealthy(apiUrl: string) { + this.isRestApiInitializedAndHealthy[apiUrl] = true; + } + + public markUnhealthy(apiUrl: string, error: Error) { + this.isRestApiInitializedAndHealthy[apiUrl] = error; + } + + public setContractApi(params: SetContractApiParams) { + const key = `${params.channelName}|${params.chaincodeName}|${params.contractName}`; + this.contractApis[key] = { contractPath: params.contractPath, api: params.api }; + } + + public getContractApi(params: { channelName: string; chaincodeName: string; contractName: string }) { + const key = `${params.channelName}|${params.chaincodeName}|${params.contractName}`; + return ( + this.contractApis[key] ?? + (() => { + throw new Error(`Cannot find contract API for key ${key}`); + }) + ); + } + + public setAuthorizedFabloRest(apiUrl: string, token: string) { + this.authorizedFabloRest[apiUrl] = token; + } + + public getAuthorizedFabloRest(apiUrl: string): string | undefined { + return this.authorizedFabloRest[apiUrl]; + } +} + +export const globalRestApiConfig = new GlobalRestApiConfig(); diff --git a/chain-client/src/rest-api/RestApiClient.ts b/chain-client/src/rest-api/RestApiClient.ts index 829de4c986..ce03306bdb 100644 --- a/chain-client/src/rest-api/RestApiClient.ts +++ b/chain-client/src/rest-api/RestApiClient.ts @@ -16,6 +16,7 @@ import { ChainCallDTO, ContractAPI, GalaChainResponse, Inferred } from "@gala-ch import axios, { AxiosError } from "axios"; import { ChainClient, ChainClientBuilder, ClassType, ContractConfig, isClassType } from "../generic"; +import { RestApiAdminCredentials, SetContractApiParams, globalRestApiConfig } from "./GlobalRestApiConfig"; import { RestApiConfig } from "./loadRestApiConfig"; async function getPath( @@ -24,28 +25,23 @@ async function getPath( method: string, isWrite: boolean ): Promise { - const key = `${cfg.channelName}|${cfg.chaincodeName}|${cfg.contractName}`; - const contractApi = RestApiClientBuilder.restApiConfigReversed[key]; + const { api, contractPath } = globalRestApiConfig.getContractApi(cfg); - if (!contractApi) { - throw new Error(`Cannot find contract API for key ${key}`); - } - - const methodApi = contractApi.api.methods.find((m) => m.methodName === method); + const methodApi = api.methods.find((m) => m.methodName === method); if (!methodApi) { - throw new Error(`Cannot find method API for method ${method} and contract key ${key}`); + throw new Error(`Cannot find method API for method ${method} and contract ${contractPath}`); } if (isWrite && !methodApi.isWrite) { - throw new Error(`Method ${method} is read-only`); + throw new Error(`Method ${method} from contract ${contractPath} is read-only`); } if (!isWrite && methodApi.isWrite) { - throw new Error(`Method ${method} is not read-only`); + throw new Error(`Method ${method} from contract ${contractPath} is not read-only`); } - return `${restApiUrl}/${contractApi.path}/${methodApi.apiMethodName ?? methodApi.methodName}`; + return `${restApiUrl}/${contractPath}/${methodApi.apiMethodName ?? methodApi.methodName}`; } function catchAxiosError(e?: AxiosError<{ error?: { Status?: number } }>) { @@ -64,18 +60,18 @@ export class RestApiClient extends ChainClient { private readonly restApiUrl: Promise; constructor( - builder: Promise, + builder: Promise, + restApiUrl: string, contractConfig: ContractConfig, private readonly credentials: RestApiAdminCredentials, orgMsp: string ) { super(builder, credentials.adminKey, contractConfig, orgMsp); - this.restApiUrl = builder.then((b) => b.restApiUrl); + this.restApiUrl = builder.then(() => restApiUrl); } public async isReady(): Promise { await this.builder; - await this.credentials; return true; } @@ -132,94 +128,50 @@ export class RestApiClient extends ChainClient { console.warn(`Ignoring forUser(${userId}) for RestApiClient`); return this; } -} - -export interface RestApiAdminCredentials { - adminKey: string; - adminSecret: string; -} - -export class RestApiClientBuilder extends ChainClientBuilder { - static isRestApiInitializedAndHealthy: Record = {}; - static restApiConfigReversed: Record = {}; - - constructor( - public readonly restApiUrl: string, - public readonly orgMsp: string, - private readonly credentials: RestApiAdminCredentials, - public readonly restApiConfig: RestApiConfig - ) { - super(); - } - - private async ensureInitializedRestApi(): Promise { - if (RestApiClientBuilder.isRestApiInitializedAndHealthy[this.restApiUrl]) { - return; - } - try { - const headers = { - "x-identity-lookup-key": this.credentials.adminKey, - "x-user-encryption-key": this.credentials.adminSecret - }; + public static async getContractApis( + credentials: RestApiAdminCredentials, + restApiUrl: string, + restApiConfig: RestApiConfig + ): Promise { + const headers = { + "x-identity-lookup-key": credentials.adminKey, + "x-user-encryption-key": credentials.adminSecret + }; - // ensure admin account is created - await axios.post(`${this.restApiUrl}/identity/ensure-admin`, undefined, { headers }); + // ensure admin account is created + await axios.post(`${restApiUrl}/identity/ensure-admin`, undefined, { headers }); - // refresh api (may fail silently) - await axios.post(`${this.restApiUrl}/refresh-api`, undefined, { headers }); + // refresh api (may fail silently) + await axios.post(`${restApiUrl}/refresh-api`, undefined, { headers }); - for (const channel of this.restApiConfig.channels) { - for (const contract of channel.contracts) { - const key = `${channel.channelName}|${contract.chaincodeName}|${contract.contractName}`; - const path = `${channel.pathFragment}/${contract.pathFragment}`; - const getApiPath = `${this.restApiUrl}/${path}/GetContractAPI`; + const contractApis: SetContractApiParams[] = []; - console.log("Loading ContractAPI:", getApiPath); - const apiResponse = await axios.post(getApiPath, undefined, { headers }); + for (const channel of restApiConfig.channels) { + for (const contract of channel.contracts) { + const contractPath = `${channel.pathFragment}/${contract.pathFragment}`; + const getApiPath = `${restApiUrl}/${contractPath}/GetContractAPI`; - if (!GalaChainResponse.isSuccess(apiResponse.data)) { - throw new Error(`Failed to load ContractAPI for ${key}: ${JSON.stringify(apiResponse.data)}`); - } + console.log("Loading ContractAPI:", getApiPath); + const apiResponse = await axios.post(getApiPath, undefined, { headers }); - console.log("API:", getApiPath, apiResponse.data); - RestApiClientBuilder.restApiConfigReversed[key] = { path: path, api: apiResponse.data.Data }; + if (!GalaChainResponse.isSuccess(apiResponse.data)) { + throw new Error( + `Failed to load ContractAPI for ${contractPath}: ${JSON.stringify(apiResponse.data)}` + ); } - } - RestApiClientBuilder.isRestApiInitializedAndHealthy[this.restApiUrl] = true; - } catch (e) { - const { data } = catchAxiosError(e); - console.error(JSON.stringify(data)); - throw e; + console.log("API:", getApiPath, apiResponse.data); + contractApis.push({ + channelName: channel.channelName, + chaincodeName: contract.chaincodeName, + contractName: contract.contractName, + contractPath: contractPath, + api: apiResponse.data.Data + }); + } } - } - - public forContract(config: ContractConfig): RestApiClient { - const payload = { - userId: this.credentials.adminKey, - identityEncryptionKey: this.credentials.adminSecret - }; - - const headers = { - "x-identity-lookup-key": this.credentials.adminKey, - "x-user-encryption-key": this.credentials.adminSecret - }; - - const credentialsExists = this.ensureInitializedRestApi() - .then(async () => { - const resp = await axios.post(`${this.restApiUrl}/identity/ensure-user`, payload, { headers }); - const status = resp.data.status; - if (![1, 2, 3].includes(status)) { - console.warn(`Failed to create user ${this.credentials.adminKey}: ${JSON.stringify(resp.data)}`); - } - }) - .catch((e) => { - throw new Error(`Failed to create user ${this.credentials.adminKey}: ${e.message}`); - }); - - const readyBuilder = credentialsExists.then(() => this); - return new RestApiClient(readyBuilder, config, this.credentials, this.orgMsp); + return contractApis; } } diff --git a/chain-client/src/rest-api/RestApiClientBuilder.ts b/chain-client/src/rest-api/RestApiClientBuilder.ts new file mode 100644 index 0000000000..9d32e43379 --- /dev/null +++ b/chain-client/src/rest-api/RestApiClientBuilder.ts @@ -0,0 +1,150 @@ +/* + * Copyright (c) Gala Games Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { ChainCallDTO, GalaChainResponse, Inferred } from "@gala-chain/api"; +import axios from "axios"; + +import { ChainClient, ChainClientBuilder, ClassType, ContractConfig } from "../generic"; +import { FabloRestClient } from "./FabloRestClient"; +import { RestApiAdminCredentials, SetContractApiParams, globalRestApiConfig } from "./GlobalRestApiConfig"; +import { RestApiClient } from "./RestApiClient"; +import { RestApiConfig } from "./loadRestApiConfig"; + +export class RestApiClientBuilder extends ChainClientBuilder { + constructor( + public readonly restApiUrl: string, + public readonly orgMsp: string, + public readonly credentials: RestApiAdminCredentials, + public readonly restApiConfig: RestApiConfig + ) { + super(); + } + + private async ensureInitializedRestApi(): Promise { + if (globalRestApiConfig.isHealthy(this.restApiUrl)) { + return; + } + + const shouldUseFabloRest = await this.shouldUseFabloRest(); + + const apis = shouldUseFabloRest + ? await this.getContractApisFromFabloRest() + : await this.getContractApisFromGCRestApi(); + + for (const api of apis) { + globalRestApiConfig.setContractApi(api); + } + + globalRestApiConfig.markHealthy(this.restApiUrl); + } + + private async shouldUseFabloRest(): Promise { + try { + await axios.get(`${this.restApiUrl}/user/identities`); + return false; // won't happen for Fablo REST + } catch (e) { + // the path is present, but it should fail with 400 because of missing bearer token + return e.response?.status === 400; + } + } + + private async getContractApisFromFabloRest(): Promise { + const token = + globalRestApiConfig.getAuthorizedFabloRest(this.restApiUrl) ?? + (await FabloRestClient.enroll( + this.restApiUrl, + this.credentials.adminKey, + this.credentials.adminSecret + )); + + globalRestApiConfig.setAuthorizedFabloRest(this.restApiUrl, token); + + return await FabloRestClient.getContractApis(token, this.restApiUrl, this.restApiConfig); + } + + private async getContractApisFromGCRestApi(): Promise { + return await RestApiClient.getContractApis(this.credentials, this.restApiUrl, this.restApiConfig); + } + + public forContract(config: ContractConfig): ChainClient { + const readyBuilder = this.ensureInitializedRestApi().then(() => this); + return new AsyncProxyClient(readyBuilder, this.credentials.adminKey, config, this.orgMsp); + } +} + +class AsyncProxyClient extends ChainClient { + private clientPromise: Promise; + + constructor( + builder: Promise, + adminKey: string, + contractConfig: ContractConfig, + orgMsp: string + ) { + super(builder, adminKey, contractConfig, orgMsp); + this.clientPromise = builder.then((b) => { + // Token present means we want to use Fablo REST + const token = globalRestApiConfig.getAuthorizedFabloRest(b.restApiUrl); + + if (token) { + return new FabloRestClient( + Promise.resolve(b), + b.restApiUrl, + contractConfig, + b.credentials, + Promise.resolve(token), + this.orgMsp + ); + } else { + return new RestApiClient( + Promise.resolve(b), + b.restApiUrl, + contractConfig, + b.credentials, + this.orgMsp + ); + } + }); + } + + public async disconnect(): Promise { + const client = await this.clientPromise; + await client.disconnect(); + } + + public forUser(userId: string, secret?: string): ChainClient { + this.clientPromise = this.clientPromise.then((c) => c.forUser(userId, secret)); + return this; + } + + public async evaluateTransaction( + method: string, + dtoOrResp?: ChainCallDTO | ClassType>, + resp?: ClassType> + ): Promise> { + const client = await this.clientPromise; + // @ts-expect-error - method overload signatures fail for some reason on dtoOrResp + return await client.evaluateTransaction(method, dtoOrResp, resp); + } + + public async submitTransaction( + method: string, + dtoOrResp?: ChainCallDTO | ClassType>, + resp?: ClassType> + ): Promise> { + const client = await this.clientPromise; + // @ts-expect-error - method overload signatures fail for some reason on dtoOrResp + return await client.submitTransaction(method, dtoOrResp, resp); + } +} diff --git a/chain-client/src/rest-api/index.ts b/chain-client/src/rest-api/index.ts index 8121379da6..fc053c166b 100644 --- a/chain-client/src/rest-api/index.ts +++ b/chain-client/src/rest-api/index.ts @@ -12,7 +12,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { RestApiClient, RestApiClientBuilder } from "./RestApiClient"; +import { FabloRestClient } from "./FabloRestClient"; +import { RestApiClient } from "./RestApiClient"; +import { RestApiClientBuilder } from "./RestApiClientBuilder"; import { loadRestApiConfig } from "./loadRestApiConfig"; -export { loadRestApiConfig, RestApiClientBuilder, RestApiClient }; +export { loadRestApiConfig, RestApiClientBuilder, RestApiClient, FabloRestClient }; diff --git a/chain-test/src/e2e/ContractTestClient.ts b/chain-test/src/e2e/ContractTestClient.ts index 0a6a8b163f..5b36b902ee 100644 --- a/chain-test/src/e2e/ContractTestClient.ts +++ b/chain-test/src/e2e/ContractTestClient.ts @@ -55,8 +55,6 @@ const defaultParams = { connectionProfilePath: process.env.CURATORORG_CONNECTION_PROFILE_PATH, defaultConnectionProfilePath: () => defaultConnectionProfilePath("curator"), apiUrl: process.env.CURATORORG_OPS_API_URL, // note: no default value - adminKey: process.env.CURATORORG_OPS_API_ADMIN_ID ?? "GC_ADMIN_CURATOR", - adminSecret: process.env.CURATORORG_OPS_API_ADMIN_SECRET ?? "abc", configPath: process.env.CURATORORG_OPS_API_CONFIG_PATH }, UsersOrg1: { @@ -66,8 +64,6 @@ const defaultParams = { connectionProfilePath: process.env.USERSORG1_CONNECTION_PROFILE_PATH, defaultConnectionProfilePath: () => defaultConnectionProfilePath("users"), apiUrl: process.env.USERSORG1_OPS_API_URL, // note: no default value - adminKey: process.env.USERSORG1_OPS_API_ADMIN_ID ?? "GC_ADMIN_USERS", - adminSecret: process.env.USERSORG1_OPS_API_ADMIN_SECRET ?? "abc", configPath: process.env.USERSORG1_OPS_API_CONFIG_PATH }, PartnerOrg1: { @@ -77,8 +73,6 @@ const defaultParams = { connectionProfilePath: process.env.PARTNERORG1_CONNECTION_PROFILE_PATH, defaultConnectionProfilePath: () => defaultConnectionProfilePath("partner"), apiUrl: process.env.PARTNERORG1_OPS_API_URL, - adminKey: process.env.PARTNERORG1_OPS_API_ADMIN_ID ?? "GC_ADMIN_PARTNER", - adminSecret: process.env.PARTNERORG1_OPS_API_ADMIN_SECRET ?? "abc", configPath: process.env.PARTNERORG1_OPS_API_CONFIG_PATH } }; @@ -89,8 +83,6 @@ interface TestClientParams { adminPass?: string; connectionProfilePath?: string; apiUrl?: string; - adminKey?: string; - adminSecret?: string; configPath?: string; } @@ -114,7 +106,7 @@ function buildHFParams(params: TestClientParams): HFClientConfig { return { orgMsp: params.orgMsp, userId: params.adminId ?? defaultParams[params.orgMsp].adminId, - userPass: params.adminPass ?? defaultParams[params.orgMsp].adminPass, + userSecret: params.adminPass ?? defaultParams[params.orgMsp].adminPass, connectionProfilePath: params.connectionProfilePath ?? defaultParams[params.orgMsp].defaultConnectionProfilePath() }; @@ -124,8 +116,8 @@ function buildRestApiParams(params: TestClientParams & { apiUrl: string }): Rest return { orgMsp: params.orgMsp ?? "CuratorOrg", apiUrl: params.apiUrl, - userKey: params.adminKey, - userSecret: params.adminSecret, + userId: params.adminId, + userSecret: params.adminPass, configPath: params.configPath ?? defaultOpsApiConfigPath() }; } diff --git a/docs/chaincode-client.md b/docs/chaincode-client.md index 2a6667e846..5a3e079268 100644 --- a/docs/chaincode-client.md +++ b/docs/chaincode-client.md @@ -1,11 +1,12 @@ # Chaincode Client The `@gala-chain/client` package provides a client for interacting with the chaincode. -Currently, it supports two client types: +Currently, it supports the following client types: * client for interacting directly with the Hyperledger Fabric network, built on top of the [`fabric-network`](https://www.npmjs.com/package/fabric-network) and [`fabric-ca-client`](https://www.npmjs.com/package/fabric-ca-client) packages; -* client for interacting with the chaincode via REST API that meets the GalaChain REST API specification, used internally at GalaGames. +* client for interacting with the chaincode via REST API that meets the GalaChain REST API specification, used internally at GalaGames, + and is also compatible with the slightly different REST API exposed by [Fablo REST](https://github.com/fablo-io/fablo-rest). -They share the same API, so it is easy to switch between them, depending on your needs. +All client types share the same API, so it is easy to switch between them, depending on your needs. Also, `@gala-chain/client` package is designed to be lightweight. This is why `fabric-network` and `fabric-ca-client` dependencies are marked as optional `peerDependencies` and should be installed separately. @@ -27,14 +28,14 @@ The `HFClientConfig` interface defines parameters that are required to connect t const params: HFClientConfig = { orgMsp: "PartnerOrg1", userId: "admin", - userPass: "adminpw", + userSecret: "adminpw", connectionProfilePath: path.resolve(networkRoot, "connection-profiles/cpp-partner.json") }; ``` * `orgMsp` - Hyperledger Fabric MSP name of the organization that the client will connect to; * `userId` - id of the user in Fabric CA that will be used to connect to the network; -* `userPass` - password of the user in CA; +* `userSecret` - password/secret of the user in CA; * `connectionProfilePath` - path to the connection profile file that describes the network topology. Both `adminId` and `adminPass` are required to authorize the client with the network. @@ -98,7 +99,7 @@ The `RestApiClientConfig` interface defines parameters that are required to conn ```typescript const params: RestApiClientConfig = { orgMsp: "CuratorOrg", - userKey: "GC_ADMIN_CURATOR", + userId: "GC_ADMIN_CURATOR", userSecret: "abc", apiUrl: "http://localhost:3000/api", configPath: path.resolve(__dirname, "api-config.json") @@ -106,8 +107,8 @@ const params: RestApiClientConfig = { ``` * `orgMsp` - Hyperledger Fabric MSP name of the organization that the client will connect to; -* `userKey` - key of the user in the API key store that will be used to connect to the network; -* `userSecret` - secret of the user in the API key store; +* `userId` - key of the user in the API key store that will be used to connect to the network (for GC services), or a CA user ID (for Fablo REST); +* `userSecret` - secret of the user in the API key store (for GC services), or a password secret of CA user (for Fablo REST); * `apiUrl` - URL of the REST API; * `configPath` - path to the configuration file that describes path mapping for channels, chaincodes, and contracts. Sample configuration file can be found in the `e2e` directory of the chaincode generated from template by GalaChain CLI.