Skip to content

Commit

Permalink
feat(sdk): routing to nodes by ledger ID
Browse files Browse the repository at this point in the history
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 <peter.somogyvari@accenture.com>
  • Loading branch information
petermetz committed Dec 1, 2020
1 parent 80f633d commit 10e3d1c
Show file tree
Hide file tree
Showing 17 changed files with 3,398 additions and 1 deletion.
59 changes: 59 additions & 0 deletions docs/architecture/routing-to-api-servers-deployment-diagram.puml
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
@startuml Routing to Plugin Instances


!include <material/common>
' To import the sprite file you DON'T need to place a prefix!
!include <material/cellphone>
!include <material/laptop_chromebook>
!include <material/database>

title Routing to Plugin Instances\nDeployment Diagram\nHyperledger Cactus

actor "User A" as usera <<human>>

frame "End User Device" as enduserdevice {
frame "Business Application" as ba {
rectangle "Cactus SDK" as cactussdk {
rectangle "API Client" as apiclient {
}
rectangle "Client Side\nRouter" as clientsiderouter {
}
}
}
}

cloud "Public Internet" as publicinternet {
}

frame "Cactus Backend" as cactus {
rectangle "API Server A" as apia {
rectangle "Connector\nPlugin A" as connectorplugina {
}
}
rectangle "API Server B" as apib {
rectangle "Connector\nPlugin B" as connectorpluginb {
}
}
}


frame "Ledgers" as ledgers {
MA_DATABASE(Gray, 1, ledger1, rectangle, "Ledger 1") {
}
MA_DATABASE(Gray, 1, ledger2, rectangle, "Ledger 2") {
}
}

usera => apiclient: TX: Ledger 1
apiclient => clientsiderouter: TX: Ledger 1
clientsiderouter => publicinternet: TX: Ledger 1

publicinternet ==> connectorplugina: TX: Ledger 1
publicinternet -[#AAAAAA]-> connectorpluginb

connectorplugina ==> ledger1: TX: Ledger 1
connectorpluginb -[#AAAAAA]-> ledger2

@enduml


Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
@startuml Routing to Plugin Instances


!include <material/common>
' To import the sprite file you DON'T need to place a prefix!
!include <material/cellphone>
!include <material/laptop_chromebook>
!include <material/database>

title Routing to Plugin Instances\nDeployment Diagram\nHyperledger Cactus

actor "User A" as usera <<human>>

frame "End User Device" as enduserdevice {
frame "Business Application" as ba {
rectangle "Cactus SDK" as cactussdk {
rectangle "API Client" as apiclient {
}
rectangle "Client Side\nRouter" as clientsiderouter {
}
}
}
}

cloud "Public Internet" as publicinternet {
}

frame "Cactus Backend" as cactus {
rectangle "API Server A" as apia {
rectangle "Connector\nPlugin A" as connectorplugina {
}
rectangle "Connector\nPlugin B" as connectorpluginb {
}
}
}


frame "Ledgers" as ledgers {
MA_DATABASE(Gray, 1, ledger1, rectangle, "Ledger 1") {
}
MA_DATABASE(Gray, 1, ledger2, rectangle, "Ledger 2") {
}
}

usera => apiclient: TX: Ledger 1
apiclient => clientsiderouter: TX: Ledger 1
clientsiderouter => publicinternet: TX: Ledger 1

publicinternet ==> connectorplugina: TX: Ledger 1
publicinternet -[#AAAAAA]-> connectorpluginb

connectorplugina ==> ledger1: TX: Ledger 1
connectorpluginb -[#AAAAAA]-> ledger2

@enduml
2 changes: 2 additions & 0 deletions packages/cactus-api-client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@
"homepage": "https://github.com/hyperledger/cactus#readme",
"dependencies": {
"@hyperledger/cactus-common": "0.2.0",
"@hyperledger/cactus-core-api": "^0.2.0",
"@hyperledger/cactus-plugin-consortium-manual": "^0.2.0",
"axios": "0.19.2",
"joi": "14.3.1",
"typescript-optional": "2.0.1"
Expand Down
134 changes: 133 additions & 1 deletion packages/cactus-api-client/src/main/typescript/api-client.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,41 @@
import { Objects } from "@hyperledger/cactus-common";
import { Checks, IAsyncProvider, Objects } from "@hyperledger/cactus-common";
import {
Consortium,
ConsortiumMember,
CactusNode,
Ledger,
} from "@hyperledger/cactus-core-api";
import { DefaultApi as ApiConsortium } from "@hyperledger/cactus-plugin-consortium-manual";
import { DefaultConsortiumProvider } from "./default-consortium-provider";

import {
Configuration,
DefaultApi,
} from "./generated/openapi/typescript-axios";

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

return this as T & this;
}

/**
* Builds the default `Consortium` provider that can be used by this object
* to retrieve the Cactus Consortium metadata object when necessary (one such
* case is when we need information about the consortium nodes to perform
* routing requests to a specific ledger via a connector plugin, but later
* other uses could be added as well).
*
* The `DefaultConsortiumProvider` class leverages the simplest consortium
* plugin that we have at the time of this writing:
* `@hyperledger/cactus-plugin-consortium-manual` which holds the consortium
* metadata as pre-configured by the consortium operators.
*
* The pattern we use in the `ApiClient` class is that you can inject your
* own `IAsyncProvider<Consortium>` implementation which then will be used
* for routing information and in theory you can implement completely arbitrary
* consortium management in your own consortium plugins which then Cactus
* can use and leverage for the routing.
* This allows us to support any exotic consortium management algorithms
* that people may come up with such as storing the consortium definiton in
* a multi-sig smart contract or have the list of consortium nodes be powered
* by some sort of automatic service discovery or anything else that people
* might think of.
*
* @see {DefaultConsortiumProvider}
*/
public get defaultConsortiumProvider(): IAsyncProvider<Consortium> {
Checks.truthy(this.configuration, "ApiClient#configuration");
const apiClient = new ApiConsortium(this.configuration);
return new DefaultConsortiumProvider({ apiClient });
}

public async ofLedger<T>(
ledgerOrId: string | Ledger,
ctor: new (configuration?: Configuration) => T
): Promise<ApiClient & T>;
/**
* Constructs a new `ApiClient` object that is tied to whichever Cactus node
* has a ledger connector plugin configured to talk to the distributed ledger
* denoted by the `ledgerId` parameter of the method.
*
* This is part of how we do request routing between different nodes, some of
* which may or may not run a ledger connector tied to a particular instance.
* (E.g. this method ensures that the returned `ApiClient` instance is bound
* to the network host of a Cactus node which does indeed have a connection
* to the specified `ledgerId` parameter)
*
* @param ledgerOrId The ID of the ledger to obtain an API client object for
* or the `Ledger` object which will be used to get the ledgerId from.
* @param consortiumProvider The provider that can be used to retrieve the
* consortium metadata at runtime for the purposes of looking up ledgers by
* the provided `ledgerId` parameter.
*/
public async ofLedger<T extends {}>(
ledgerOrId: string | Ledger,
ctor: new (configuration?: Configuration) => T,
consortiumProvider: IAsyncProvider<Consortium> = this
.defaultConsortiumProvider
): Promise<ApiClient & T> {
const fnTags = "ApiClient#forLedgerId()";

Checks.truthy(ledgerOrId, `${fnTags}:ledgerOrId`);
Checks.truthy(consortiumProvider, `${fnTags}:consortiumProvider`);

let ledgerId: string;
if (typeof ledgerOrId === "string") {
ledgerId = ledgerOrId;
} else {
ledgerId = ledgerOrId.id;
}

const consortium: Consortium = await consortiumProvider.get();
Checks.truthy(consortiumProvider, `${fnTags}:consortium`);

// Find a list of nodes in the consortium that have a connector plugin
// running that's associated with the ledger based on ledger ID.
const nodes = consortium.members
.map((member: ConsortiumMember) =>
member.nodes.filter((node: CactusNode) =>
node.ledgers.some((ledger: Ledger) => ledger.id === ledgerId)
)
)
.flat();

// pick a random element from the array of nodes that have a connection to
// the target ledger (based on the ledger ID)
const randomNode = nodes[Math.floor(Math.random() * nodes.length)];

const configuration = new Configuration({
basePath: randomNode.nodeApiHost,
});

return new ApiClient(configuration).extendWith(ctor);
}
}

// type UnionToIntersection<U> =
// (U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never

// function extendWith<A extends any[]>(...args: A): UnionToIntersection<A[number]> { return null! }

// extendWith(new DefaultApi(), new ApiConsortium());
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import {
Logger,
LogLevelDesc,
LoggerProvider,
} from "@hyperledger/cactus-common";
import { Checks, IAsyncProvider } from "@hyperledger/cactus-common";
import { Consortium } from "@hyperledger/cactus-core-api";
import {
DefaultApi,
GetConsortiumJwsResponse,
} from "@hyperledger/cactus-plugin-consortium-manual";

export interface IDefaultConsortiumProviderOptions {
logLevel?: LogLevelDesc;
apiClient: DefaultApi;
}

export class DefaultConsortiumProvider implements IAsyncProvider<Consortium> {
public static readonly CLASS_NAME = "DefaultConsortiumProvider";

private readonly log: Logger;

public get className() {
return DefaultConsortiumProvider.CLASS_NAME;
}

constructor(public readonly options: IDefaultConsortiumProviderOptions) {
const fnTag = `${this.className}#constructor()`;
Checks.truthy(options, `${fnTag} arg options`);

const level = this.options.logLevel || "INFO";
const label = this.className;
this.log = LoggerProvider.getOrCreate({ level, label });
}

parseConsortiumJws(response: GetConsortiumJwsResponse): Consortium {
const fnTag = `DefaultConsortiumProvider#parseConsortiumJws()`;

Checks.truthy(response, `${fnTag}::response`);
Checks.truthy(response.jws, `${fnTag}::response.jws`);
Checks.truthy(response.jws.payload, `${fnTag}::response.jws.payload`);

const json = Buffer.from(response.jws.payload, "base64").toString();
const consortium = JSON.parse(json)?.consortium as Consortium;

Checks.truthy(consortium, `${fnTag}::consortium`);

// FIXME Ideally there would be an option here to validate the JWS based on
// all the signatures and the corresponding public keys (which the caller
// would have to be able to supply).
// We do not yet have this crypto functions available in a cross platform
// manner so it is omitted for now but much needed prior to any GA release.
return consortium;
}

public async get(): Promise<Consortium> {
try {
const res = await this.options.apiClient.apiV1PluginsHyperledgerCactusPluginConsortiumManualConsortiumJwsGet();
return this.parseConsortiumJws(res.data);
} catch (ex) {
this.log.error(`Request for Consortium JWS failed: `, ex?.toJSON());
throw ex;
}
}
}
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export { ApiClient } from "./api-client";
export { DefaultConsortiumProvider } from "./default-consortium-provider";
export * from "./generated/openapi/typescript-axios/index";
Loading

0 comments on commit 10e3d1c

Please sign in to comment.