Skip to content

Commit 10e3d1c

Browse files
committed
feat(sdk): routing to nodes by ledger ID
This implements the routing of API requests to specific Cactus nodes based on a pre-specified ledger ID. The idea is that since each Cactus node has it's own API host we can store the ledger connector + ledger associations in the consortium definition and use that to look up which nodes can we send requests to when wanting to transact on a specific ledger. The theme here is to offload the bulk of the routing to consortium management. This is the first half of the routing solution that will also have to include a back-end component that will ensure that requests end up at the right connector plugin instance if the same plugin package is used within the same Cactus node but for connecting two separate ledgers which are of the same type, e.g: Two Fabric 1.4.x ledgers with their own ledger connector instances, both running in the same Cactus node (e.g. `ApiServer` class instance). Side effect: The cactus-sdk package now has to depend on the cactus-plugin-consortium-manual package which is the simplest (and only at the time of this writing) consortium management implementation we have on hand and is therefore a natural/prime candidate for being the default consortium definition provider to power the client side component of the routing. The majority of the routing functionality is implemented within the `ApiClient` class of the SDK package and for now it only supports routing based on specific ledgers, but not other pluggable aspects. To avoid circular dependencies because of the above, the plugin packages can no longer depend on the sdk package which is also reflected in this change. Other side effect: Reusability of OpenAPI spec types is now partially in effect by way of having the core-api package export it's own types into a fixed, version controlled .json file which we'll be able to reference online as well through direct links to the github repo files (which helps because OpenAPI uses JSON references to resolve dependent specification schemas) Signed-off-by: Peter Somogyvari <[email protected]>
1 parent 80f633d commit 10e3d1c

17 files changed

+3398
-1
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
@startuml Routing to Plugin Instances
2+
3+
4+
!include <material/common>
5+
' To import the sprite file you DON'T need to place a prefix!
6+
!include <material/cellphone>
7+
!include <material/laptop_chromebook>
8+
!include <material/database>
9+
10+
title Routing to Plugin Instances\nDeployment Diagram\nHyperledger Cactus
11+
12+
actor "User A" as usera <<human>>
13+
14+
frame "End User Device" as enduserdevice {
15+
frame "Business Application" as ba {
16+
rectangle "Cactus SDK" as cactussdk {
17+
rectangle "API Client" as apiclient {
18+
}
19+
rectangle "Client Side\nRouter" as clientsiderouter {
20+
}
21+
}
22+
}
23+
}
24+
25+
cloud "Public Internet" as publicinternet {
26+
}
27+
28+
frame "Cactus Backend" as cactus {
29+
rectangle "API Server A" as apia {
30+
rectangle "Connector\nPlugin A" as connectorplugina {
31+
}
32+
}
33+
rectangle "API Server B" as apib {
34+
rectangle "Connector\nPlugin B" as connectorpluginb {
35+
}
36+
}
37+
}
38+
39+
40+
frame "Ledgers" as ledgers {
41+
MA_DATABASE(Gray, 1, ledger1, rectangle, "Ledger 1") {
42+
}
43+
MA_DATABASE(Gray, 1, ledger2, rectangle, "Ledger 2") {
44+
}
45+
}
46+
47+
usera => apiclient: TX: Ledger 1
48+
apiclient => clientsiderouter: TX: Ledger 1
49+
clientsiderouter => publicinternet: TX: Ledger 1
50+
51+
publicinternet ==> connectorplugina: TX: Ledger 1
52+
publicinternet -[#AAAAAA]-> connectorpluginb
53+
54+
connectorplugina ==> ledger1: TX: Ledger 1
55+
connectorpluginb -[#AAAAAA]-> ledger2
56+
57+
@enduml
58+
59+
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
@startuml Routing to Plugin Instances
2+
3+
4+
!include <material/common>
5+
' To import the sprite file you DON'T need to place a prefix!
6+
!include <material/cellphone>
7+
!include <material/laptop_chromebook>
8+
!include <material/database>
9+
10+
title Routing to Plugin Instances\nDeployment Diagram\nHyperledger Cactus
11+
12+
actor "User A" as usera <<human>>
13+
14+
frame "End User Device" as enduserdevice {
15+
frame "Business Application" as ba {
16+
rectangle "Cactus SDK" as cactussdk {
17+
rectangle "API Client" as apiclient {
18+
}
19+
rectangle "Client Side\nRouter" as clientsiderouter {
20+
}
21+
}
22+
}
23+
}
24+
25+
cloud "Public Internet" as publicinternet {
26+
}
27+
28+
frame "Cactus Backend" as cactus {
29+
rectangle "API Server A" as apia {
30+
rectangle "Connector\nPlugin A" as connectorplugina {
31+
}
32+
rectangle "Connector\nPlugin B" as connectorpluginb {
33+
}
34+
}
35+
}
36+
37+
38+
frame "Ledgers" as ledgers {
39+
MA_DATABASE(Gray, 1, ledger1, rectangle, "Ledger 1") {
40+
}
41+
MA_DATABASE(Gray, 1, ledger2, rectangle, "Ledger 2") {
42+
}
43+
}
44+
45+
usera => apiclient: TX: Ledger 1
46+
apiclient => clientsiderouter: TX: Ledger 1
47+
clientsiderouter => publicinternet: TX: Ledger 1
48+
49+
publicinternet ==> connectorplugina: TX: Ledger 1
50+
publicinternet -[#AAAAAA]-> connectorpluginb
51+
52+
connectorplugina ==> ledger1: TX: Ledger 1
53+
connectorpluginb -[#AAAAAA]-> ledger2
54+
55+
@enduml

packages/cactus-api-client/package.json

+2
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@
6363
"homepage": "https://github.com/hyperledger/cactus#readme",
6464
"dependencies": {
6565
"@hyperledger/cactus-common": "0.2.0",
66+
"@hyperledger/cactus-core-api": "^0.2.0",
67+
"@hyperledger/cactus-plugin-consortium-manual": "^0.2.0",
6668
"axios": "0.19.2",
6769
"joi": "14.3.1",
6870
"typescript-optional": "2.0.1"

packages/cactus-api-client/src/main/typescript/api-client.ts

+133-1
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,41 @@
1-
import { Objects } from "@hyperledger/cactus-common";
1+
import { Checks, IAsyncProvider, Objects } from "@hyperledger/cactus-common";
2+
import {
3+
Consortium,
4+
ConsortiumMember,
5+
CactusNode,
6+
Ledger,
7+
} from "@hyperledger/cactus-core-api";
8+
import { DefaultApi as ApiConsortium } from "@hyperledger/cactus-plugin-consortium-manual";
9+
import { DefaultConsortiumProvider } from "./default-consortium-provider";
10+
211
import {
312
Configuration,
413
DefaultApi,
514
} from "./generated/openapi/typescript-axios";
615

16+
/**
17+
* Class responsible for providing additional functionality to the `DefaultApi`
18+
* classes of the generated clients (OpenAPI generator / typescript-axios).
19+
*
20+
* Each package (plugin) can define it's own OpenAPI spec which means that they
21+
* all can ship with their own `DefaultApi` class that is generated directly
22+
* from the respective OpenAPI spec of the package/plugin.
23+
*
24+
* The functionality provided by this class is meant to be common traints that
25+
* can be useful for all of those different `DefaultApi` implementations.
26+
*
27+
* One such common trait is the client side component of the routing that
28+
* decides which Cactus node to point the `ApiClient` towards (which is in
29+
* itself ends up being the act of routing).
30+
*
31+
* @see https://github.com/OpenAPITools/openapi-generator/blob/v5.0.0-beta2/modules/openapi-generator/src/main/resources/typescript-axios/apiInner.mustache#L337
32+
* @see https://github.com/OpenAPITools/openapi-generator/blob/master/docs/generators/typescript-axios.md
33+
*/
734
export class ApiClient extends DefaultApi {
35+
/**
36+
*
37+
* @param ctor
38+
*/
839
public extendWith<T extends {}>(
940
ctor: new (configuration?: Configuration) => T
1041
): T & this {
@@ -21,4 +52,105 @@ export class ApiClient extends DefaultApi {
2152

2253
return this as T & this;
2354
}
55+
56+
/**
57+
* Builds the default `Consortium` provider that can be used by this object
58+
* to retrieve the Cactus Consortium metadata object when necessary (one such
59+
* case is when we need information about the consortium nodes to perform
60+
* routing requests to a specific ledger via a connector plugin, but later
61+
* other uses could be added as well).
62+
*
63+
* The `DefaultConsortiumProvider` class leverages the simplest consortium
64+
* plugin that we have at the time of this writing:
65+
* `@hyperledger/cactus-plugin-consortium-manual` which holds the consortium
66+
* metadata as pre-configured by the consortium operators.
67+
*
68+
* The pattern we use in the `ApiClient` class is that you can inject your
69+
* own `IAsyncProvider<Consortium>` implementation which then will be used
70+
* for routing information and in theory you can implement completely arbitrary
71+
* consortium management in your own consortium plugins which then Cactus
72+
* can use and leverage for the routing.
73+
* This allows us to support any exotic consortium management algorithms
74+
* that people may come up with such as storing the consortium definiton in
75+
* a multi-sig smart contract or have the list of consortium nodes be powered
76+
* by some sort of automatic service discovery or anything else that people
77+
* might think of.
78+
*
79+
* @see {DefaultConsortiumProvider}
80+
*/
81+
public get defaultConsortiumProvider(): IAsyncProvider<Consortium> {
82+
Checks.truthy(this.configuration, "ApiClient#configuration");
83+
const apiClient = new ApiConsortium(this.configuration);
84+
return new DefaultConsortiumProvider({ apiClient });
85+
}
86+
87+
public async ofLedger<T>(
88+
ledgerOrId: string | Ledger,
89+
ctor: new (configuration?: Configuration) => T
90+
): Promise<ApiClient & T>;
91+
/**
92+
* Constructs a new `ApiClient` object that is tied to whichever Cactus node
93+
* has a ledger connector plugin configured to talk to the distributed ledger
94+
* denoted by the `ledgerId` parameter of the method.
95+
*
96+
* This is part of how we do request routing between different nodes, some of
97+
* which may or may not run a ledger connector tied to a particular instance.
98+
* (E.g. this method ensures that the returned `ApiClient` instance is bound
99+
* to the network host of a Cactus node which does indeed have a connection
100+
* to the specified `ledgerId` parameter)
101+
*
102+
* @param ledgerOrId The ID of the ledger to obtain an API client object for
103+
* or the `Ledger` object which will be used to get the ledgerId from.
104+
* @param consortiumProvider The provider that can be used to retrieve the
105+
* consortium metadata at runtime for the purposes of looking up ledgers by
106+
* the provided `ledgerId` parameter.
107+
*/
108+
public async ofLedger<T extends {}>(
109+
ledgerOrId: string | Ledger,
110+
ctor: new (configuration?: Configuration) => T,
111+
consortiumProvider: IAsyncProvider<Consortium> = this
112+
.defaultConsortiumProvider
113+
): Promise<ApiClient & T> {
114+
const fnTags = "ApiClient#forLedgerId()";
115+
116+
Checks.truthy(ledgerOrId, `${fnTags}:ledgerOrId`);
117+
Checks.truthy(consortiumProvider, `${fnTags}:consortiumProvider`);
118+
119+
let ledgerId: string;
120+
if (typeof ledgerOrId === "string") {
121+
ledgerId = ledgerOrId;
122+
} else {
123+
ledgerId = ledgerOrId.id;
124+
}
125+
126+
const consortium: Consortium = await consortiumProvider.get();
127+
Checks.truthy(consortiumProvider, `${fnTags}:consortium`);
128+
129+
// Find a list of nodes in the consortium that have a connector plugin
130+
// running that's associated with the ledger based on ledger ID.
131+
const nodes = consortium.members
132+
.map((member: ConsortiumMember) =>
133+
member.nodes.filter((node: CactusNode) =>
134+
node.ledgers.some((ledger: Ledger) => ledger.id === ledgerId)
135+
)
136+
)
137+
.flat();
138+
139+
// pick a random element from the array of nodes that have a connection to
140+
// the target ledger (based on the ledger ID)
141+
const randomNode = nodes[Math.floor(Math.random() * nodes.length)];
142+
143+
const configuration = new Configuration({
144+
basePath: randomNode.nodeApiHost,
145+
});
146+
147+
return new ApiClient(configuration).extendWith(ctor);
148+
}
24149
}
150+
151+
// type UnionToIntersection<U> =
152+
// (U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never
153+
154+
// function extendWith<A extends any[]>(...args: A): UnionToIntersection<A[number]> { return null! }
155+
156+
// extendWith(new DefaultApi(), new ApiConsortium());
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import {
2+
Logger,
3+
LogLevelDesc,
4+
LoggerProvider,
5+
} from "@hyperledger/cactus-common";
6+
import { Checks, IAsyncProvider } from "@hyperledger/cactus-common";
7+
import { Consortium } from "@hyperledger/cactus-core-api";
8+
import {
9+
DefaultApi,
10+
GetConsortiumJwsResponse,
11+
} from "@hyperledger/cactus-plugin-consortium-manual";
12+
13+
export interface IDefaultConsortiumProviderOptions {
14+
logLevel?: LogLevelDesc;
15+
apiClient: DefaultApi;
16+
}
17+
18+
export class DefaultConsortiumProvider implements IAsyncProvider<Consortium> {
19+
public static readonly CLASS_NAME = "DefaultConsortiumProvider";
20+
21+
private readonly log: Logger;
22+
23+
public get className() {
24+
return DefaultConsortiumProvider.CLASS_NAME;
25+
}
26+
27+
constructor(public readonly options: IDefaultConsortiumProviderOptions) {
28+
const fnTag = `${this.className}#constructor()`;
29+
Checks.truthy(options, `${fnTag} arg options`);
30+
31+
const level = this.options.logLevel || "INFO";
32+
const label = this.className;
33+
this.log = LoggerProvider.getOrCreate({ level, label });
34+
}
35+
36+
parseConsortiumJws(response: GetConsortiumJwsResponse): Consortium {
37+
const fnTag = `DefaultConsortiumProvider#parseConsortiumJws()`;
38+
39+
Checks.truthy(response, `${fnTag}::response`);
40+
Checks.truthy(response.jws, `${fnTag}::response.jws`);
41+
Checks.truthy(response.jws.payload, `${fnTag}::response.jws.payload`);
42+
43+
const json = Buffer.from(response.jws.payload, "base64").toString();
44+
const consortium = JSON.parse(json)?.consortium as Consortium;
45+
46+
Checks.truthy(consortium, `${fnTag}::consortium`);
47+
48+
// FIXME Ideally there would be an option here to validate the JWS based on
49+
// all the signatures and the corresponding public keys (which the caller
50+
// would have to be able to supply).
51+
// We do not yet have this crypto functions available in a cross platform
52+
// manner so it is omitted for now but much needed prior to any GA release.
53+
return consortium;
54+
}
55+
56+
public async get(): Promise<Consortium> {
57+
try {
58+
const res = await this.options.apiClient.apiV1PluginsHyperledgerCactusPluginConsortiumManualConsortiumJwsGet();
59+
return this.parseConsortiumJws(res.data);
60+
} catch (ex) {
61+
this.log.error(`Request for Consortium JWS failed: `, ex?.toJSON());
62+
throw ex;
63+
}
64+
}
65+
}
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export { ApiClient } from "./api-client";
2+
export { DefaultConsortiumProvider } from "./default-consortium-provider";
23
export * from "./generated/openapi/typescript-axios/index";

0 commit comments

Comments
 (0)