From cac0056fc42b03672975f251a00dc5d8217c43fd Mon Sep 17 00:00:00 2001 From: Jaco Greeff Date: Wed, 23 Oct 2019 17:36:27 +0200 Subject: [PATCH] Allow user-define RPC interfaces (#1504) * Allow user-define RPC interfaces * Add user-decorated tests * Pacify CC * Typos * Just push. --- packages/api/src/base/Decorate.ts | 17 ++--- packages/api/src/types.ts | 5 ++ packages/rpc-core/src/index.spec.ts | 35 ++++++++++- packages/rpc-core/src/index.ts | 96 ++++++++++++++++++++--------- packages/rpc-core/src/types.ts | 9 +++ 5 files changed, 119 insertions(+), 43 deletions(-) diff --git a/packages/api/src/base/Decorate.ts b/packages/api/src/base/Decorate.ts index bfa166114b..ca95adefee 100644 --- a/packages/api/src/base/Decorate.ts +++ b/packages/api/src/base/Decorate.ts @@ -2,7 +2,6 @@ // This software may be modified and distributed under the terms // of the Apache-2.0 license. See the LICENSE file for details. -import { RpcMethod } from '@polkadot/jsonrpc/types'; import { RpcInterface } from '@polkadot/rpc-core/jsonrpc.types'; import { Call, Hash, RuntimeVersion } from '@polkadot/types/interfaces'; import { AnyFunction, CallFunction, Codec, CodecArg as Arg, ModulesWithCalls } from '@polkadot/types/types'; @@ -20,7 +19,6 @@ import { map, switchMap } from 'rxjs/operators'; import decorateDerive, { ExactDerive } from '@polkadot/api-derive'; import { Constants } from '@polkadot/api-metadata/consts/types'; import { Storage } from '@polkadot/api-metadata/storage/types'; -import jsonrpc from '@polkadot/jsonrpc'; import RpcCore from '@polkadot/rpc-core'; import { WsProvider } from '@polkadot/rpc-provider'; import { Metadata, Null, u64 } from '@polkadot/types'; @@ -69,8 +67,6 @@ export default abstract class Decorate extends Events { protected _rpcCore: RpcCore; - private _rpcMap: Map = new Map(); - protected _runtimeMetadata?: Metadata; protected _runtimeVersion?: RuntimeVersion; @@ -125,7 +121,7 @@ export default abstract class Decorate extends Events { this.decorateMethod = decorateMethod; this._options = options; this._type = type; - this._rpcCore = new RpcCore(thisProvider); + this._rpcCore = new RpcCore(thisProvider, this._options.rpc); this._isConnected = new BehaviorSubject(this._rpcCore.provider.isConnected()); this._rx.hasSubscriptions = this._rpcCore.provider.hasSubscriptions; } @@ -173,7 +169,7 @@ export default abstract class Decorate extends Events { const hasResults = methods.length !== 0; // loop through all entries we have (populated in decorate) and filter as required - [...this._rpcMap.entries()] + [...this._rpcCore.mapping.entries()] .filter(([key, { isOptional }]): boolean => // only remove when we have results and method missing, or with no results if optional hasResults @@ -188,17 +184,14 @@ export default abstract class Decorate extends Events { } protected decorateRpc (rpc: RpcCore, decorateMethod: DecorateMethod): DecoratedRpc { - return Object.keys(jsonrpc).reduce((out, _sectionName): DecoratedRpc => { + return rpc.sections.reduce((out, _sectionName): DecoratedRpc => { const sectionName = _sectionName as keyof DecoratedRpc; - // out and section here are horrors to get right from a typing perspective :() + // out and section here are horrors to get right from a typing perspective :( (out as any)[sectionName] = Object.entries(rpc[sectionName]).reduce((section, [methodName, method]): DecoratedRpcSection => { - // skip subscriptions where we have a non-subscribable interface + // skip subscriptions where we have a non-subscribe interface if (this.hasSubscriptions || !(methodName.startsWith('subscribe') || methodName.startsWith('unsubscribe'))) { (section as any)[methodName] = decorateMethod(method, { methodName }); - - // add this endpoint mapping to our internal map - we use this for filters - this._rpcMap.set(`${sectionName}_${methodName}`, jsonrpc[sectionName].methods[methodName]); } return section; diff --git a/packages/api/src/types.ts b/packages/api/src/types.ts index a1e753ce1b..d5c1b6fb9d 100644 --- a/packages/api/src/types.ts +++ b/packages/api/src/types.ts @@ -2,6 +2,7 @@ // This software may be modified and distributed under the terms // of the Apache-2.0 license. See the LICENSE file for details. +import { UserRpc } from '@polkadot/rpc-core/types'; import { Hash, RuntimeVersion } from '@polkadot/types/interfaces'; import { AnyFunction, Callback, CallFunction, Codec, CodecArg, RegistryTypes, SignatureOptions, SignerPayloadJSON, SignerPayloadRaw } from '@polkadot/types/types'; import { SubmittableResultImpl, SubmittableExtrinsic } from './submittable/types'; @@ -185,6 +186,10 @@ export interface ApiOptions { * connecting to a WsProvider connecting localhost with the default port, i.e. `ws://127.0.0.1:9944` */ provider?: ProviderInterface; + /** + * @description User-defined RPC methods + */ + rpc?: UserRpc; /** * @description An external signer which will be used to sign extrinsic when account passed in is not KeyringPair */ diff --git a/packages/rpc-core/src/index.spec.ts b/packages/rpc-core/src/index.spec.ts index 1efcadefed..d390caa8d0 100644 --- a/packages/rpc-core/src/index.spec.ts +++ b/packages/rpc-core/src/index.spec.ts @@ -3,6 +3,7 @@ // of the Apache-2.0 license. See the LICENSE file for details. import MockProvider from '@polkadot/rpc-provider/mock'; +import { isFunction } from '@polkadot/util'; import Rpc from '.'; @@ -19,8 +20,40 @@ describe('Api', (): void => { expect( Object.keys(rpc).filter((key): boolean => !key.startsWith('_')) ).toEqual([ - 'provider', + 'provider', 'mapping', 'sections', 'account', 'author', 'chain', 'contracts', 'rpc', 'state', 'system' ]); }); + + it('allows for the definition of user RPCs', (): void => { + const rpc = new Rpc(new MockProvider(), { + testing: [ + { + name: 'foo', + params: [{ name: 'bar', type: 'u32' }], + type: 'Balance' + } + ] + }); + + expect(isFunction((rpc as any).testing.foo)).toBe(true); + expect(rpc.sections.includes('testing')).toBe(true); + expect(rpc.mapping.get('testing_foo')).toEqual({ + description: 'User defined', + isDeprecated: false, + isHidden: false, + isOptional: false, + isSigned: false, + isSubscription: false, + method: 'foo', + params: [{ + isOptional: false, + name: 'bar', + type: 'u32' + }], + pubsub: ['', '', ''], + section: 'testing', + type: 'Balance' + }); + }); }); diff --git a/packages/rpc-core/src/index.ts b/packages/rpc-core/src/index.ts index ec8f1df039..cd8cd0e9a9 100644 --- a/packages/rpc-core/src/index.ts +++ b/packages/rpc-core/src/index.ts @@ -3,19 +3,23 @@ // of the Apache-2.0 license. See the LICENSE file for details. import { ProviderInterface } from '@polkadot/rpc-provider/types'; -import { RpcMethod } from '@polkadot/jsonrpc/types'; +import { RpcMethod, RpcSection, RpcParam } from '@polkadot/jsonrpc/types'; import { AnyJson, Codec } from '@polkadot/types/types'; import { RpcInterface } from './jsonrpc.types'; -import { RpcInterfaceMethod } from './types'; +import { RpcInterfaceMethod, UserRpc } from './types'; import memoizee from 'memoizee'; import { combineLatest, from, Observable, Observer, of, throwError } from 'rxjs'; import { catchError, distinctUntilChanged, map, publishReplay, refCount, switchMap } from 'rxjs/operators'; -import interfaces from '@polkadot/jsonrpc'; +import jsonrpc from '@polkadot/jsonrpc'; +import jsonrpcMethod from '@polkadot/jsonrpc/create/method'; +import jsonrpcParam from '@polkadot/jsonrpc/create/param'; import { Option, StorageKey, Vec, createClass } from '@polkadot/types'; import { createTypeUnsafe } from '@polkadot/types/codec'; import { assert, isFunction, isNull, isNumber, logger, u8aToU8a } from '@polkadot/util'; +type UserRpcConverted = Record>; + const l = logger('rpc-core'); const EMPTY_META = { @@ -55,38 +59,40 @@ export default class Rpc implements RpcInterface { public readonly provider: ProviderInterface; - public readonly account: RpcInterface['account']; + public readonly mapping: Map = new Map(); - public readonly author: RpcInterface['author']; + public readonly sections: string[] = []; - public readonly chain: RpcInterface['chain']; + // Ok, this is quite horrible - we really should not be using the ! here, but we are actually assigning + // these via the createInterfaces inside the constructor. However... this is not quite visible. The reason + // why we don't do for individual assignments is to allow user-defined RPCs to also be defined - public readonly contracts: RpcInterface['contracts']; + public readonly account!: RpcInterface['account']; - public readonly rpc: RpcInterface['rpc']; + public readonly author!: RpcInterface['author']; - public readonly state: RpcInterface['state']; + public readonly chain!: RpcInterface['chain']; - public readonly system: RpcInterface['system']; + public readonly contracts!: RpcInterface['contracts']; + + public readonly rpc!: RpcInterface['rpc']; + + public readonly state!: RpcInterface['state']; + + public readonly system!: RpcInterface['system']; /** * @constructor * Default constructor for the Api Object * @param {ProviderInterface} provider An API provider using HTTP or WebSocket */ - public constructor (provider: ProviderInterface) { + public constructor (provider: ProviderInterface, userRpc: UserRpc = {}) { // eslint-disable-next-line @typescript-eslint/unbound-method assert(provider && isFunction(provider.send), 'Expected Provider to API create'); this.provider = provider; - this.account = this.createInterface('account'); - this.author = this.createInterface('author'); - this.contracts = this.createInterface('contracts'); - this.chain = this.createInterface('chain'); - this.rpc = this.createInterface('rpc'); - this.state = this.createInterface('state'); - this.system = this.createInterface('system'); + this.createInterfaces(jsonrpc, userRpc); } /** @@ -105,9 +111,7 @@ export default class Rpc implements RpcInterface { * ``` */ public static signature ({ method, params, type }: RpcMethod): string { - const inputs = params.map(({ name, type }): string => - `${name}: ${type}` - ).join(', '); + const inputs = params.map(({ name, type }): string => `${name}: ${type}`).join(', '); return `${method} (${inputs}): ${type}`; } @@ -123,13 +127,47 @@ export default class Rpc implements RpcInterface { return `${Rpc.signature(method)}:: ${error.message}`; } - private createInterface
(section: Section): RpcInterface[Section] { - const { methods } = interfaces[section]; + private createInterfaces
(interfaces: Record, userBare: UserRpc): void { + // these are the base keys (i.e. part of jsonrpc) + this.sections.push(...Object.keys(interfaces)); + // add any extra user-defined sections + this.sections.push(...Object.keys(userBare).filter((key): boolean => !this.sections.includes(key))); + + // convert the user inputs into the same format as used in jsonrpc + const user = Object.entries(userBare).reduce((user: UserRpcConverted, [sectionName, methods]): UserRpcConverted => { + user[sectionName] = methods.reduce((section: Record, def): Record => { + const { description = 'User defined', name, params, type } = def; + + section[name] = jsonrpcMethod(sectionName, name, { + description, + params: params.map(({ isOptional, name, type }): RpcParam => + jsonrpcParam(name, type as any, { isOptional })), + type: type as any + }); + + return section; + }, {}); + + return user; + }, {}); + + // decorate the sections with base and user methods + this.sections.forEach((sectionName): void => { + (this as any)[sectionName as Section] = { + ...this.createInterface(sectionName, interfaces[sectionName] ? interfaces[sectionName].methods : {}), + ...this.createInterface(sectionName, user[sectionName] || {}) + }; + }); + } + + private createInterface
(section: string, methods: Record): RpcInterface[Section] { return Object .keys(methods) - .reduce((exposed, methodName): RpcInterface[Section] => { - const def = methods[methodName]; + .reduce((exposed, method): RpcInterface[Section] => { + const def = methods[method]; + + this.mapping.set(`${section}_${method}`, def); // FIXME Remove any here // To do so, remove `RpcInterfaceMethod` from './types.ts', and refactor @@ -137,7 +175,7 @@ export default class Rpc implements RpcInterface { // `` // Not doing so, because it makes this class a little bit less readable, // and leaving it as-is doesn't harm much - (exposed as any)[methodName] = def.isSubscription + (exposed as any)[method] = def.isSubscription ? this.createMethodSubscribe(def) : this.createMethodSend(def); @@ -170,14 +208,12 @@ export default class Rpc implements RpcInterface { const message = this.createErrorMessage(method, error); // don't scare with old nodes, this is handled transparently - if (rpcName !== 'rpc_methods') { - l.error(message); - } + rpcName !== 'rpc_methods' && l.error(message); return throwError(new Error(message)); }), publishReplay(1), // create a Replay(1) - refCount() // Unsubcribe WS when there are no more subscribers + refCount() // Unsubscribe WS when there are no more subscribers ); }; diff --git a/packages/rpc-core/src/types.ts b/packages/rpc-core/src/types.ts index 1fe96de7a0..b772e3e7ca 100644 --- a/packages/rpc-core/src/types.ts +++ b/packages/rpc-core/src/types.ts @@ -7,3 +7,12 @@ import { Observable } from 'rxjs'; export interface RpcInterfaceMethod { (...params: any[]): Observable; } + +export interface UserRpcMethod { + description?: string; + name: string; + params: { isOptional?: boolean; name: string; type: string }[]; + type: string; +} + +export type UserRpc = Record;