diff --git a/.flowconfig b/.flowconfig index 6b474050532f..ce3ff01cbc05 100644 --- a/.flowconfig +++ b/.flowconfig @@ -12,4 +12,5 @@ all=warn [options] include_warnings=true -module.name_mapper='^@polkadot/api-\(format\|jsonrpc\|provider\)\(.*\)$' -> '/packages/api-\1/src\2' +module.name_mapper='^@polkadot/api-\(format\|jsonrpc\|provider\|rx\)\(.*\)$' -> '/packages/api-\1/src\2' +module.name_mapper='^@polkadot/api\(.*\)$' -> '/packages/api/src\1' diff --git a/README.md b/README.md index 51053d2aaea2..bdb6fcf4b124 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,8 @@ This library provides a clean wrapper around all the methods exposed by a Polkad The API is split up into a number of internal packages - -- [@polkadot/api](packages/api/) The API library +- [@polkadot/api](packages/api/) The low-level base API library +- [@polkadot/api-rx](packages/api-rx/) A RxJs Observable wrapper around the API - [@polkadot/api-format](packages/api-format/) Input and output formatters - [@polkadot/api-jsonrpc](packages/api-jsonrpc/) Interface definitions for RPC - [@polkadot/api-provider](packages/api-provider/) Providers for connecting diff --git a/jest.config.js b/jest.config.js index e1e235c1c6d6..fea24fbbc362 100644 --- a/jest.config.js +++ b/jest.config.js @@ -2,6 +2,7 @@ const config = require('@polkadot/dev/config/jest'); module.exports = Object.assign({}, config, { moduleNameMapper: { - '@polkadot/api-(format|jsonrpc|provider)(.*)$': '/packages/api-$1/src/$2' + '@polkadot/api-(format|jsonrpc|provider|rx)(.*)$': '/packages/api-$1/src/$2', + '@polkadot/api(.*)$': '/packages/api/src/$1' } }); diff --git a/packages/api-rx/LICENSE b/packages/api-rx/LICENSE new file mode 100644 index 000000000000..3d150a620a99 --- /dev/null +++ b/packages/api-rx/LICENSE @@ -0,0 +1,15 @@ +ISC License (ISC) + +Copyright 2017-2018 Jaco Greeff + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THIS SOFTWARE. diff --git a/packages/api-rx/README.md b/packages/api-rx/README.md new file mode 100644 index 000000000000..d2f465c2c466 --- /dev/null +++ b/packages/api-rx/README.md @@ -0,0 +1,13 @@ +[![polkadotjs](https://img.shields.io/badge/polkadot-js-orange.svg?style=flat-square)](https://polkadot.js.org) +![isc](https://img.shields.io/badge/license-ISC-lightgrey.svg?style=flat-square) +[![style](https://img.shields.io/badge/code%20style-semistandard-lightgrey.svg?style=flat-square)](https://github.com/Flet/semistandard) +[![npm](https://img.shields.io/npm/v/@polkadot/api-rx.svg?style=flat-square)](https://www.npmjs.com/package/@polkadot/api-rx) +[![travis](https://img.shields.io/travis/polkadot-js/api.svg?style=flat-square)](https://travis-ci.org/polkadot-js/api) +[![maintainability](https://img.shields.io/codeclimate/maintainability/polkadot-js/api.svg?style=flat-square)](https://codeclimate.com/github/polkadot-js/api/maintainability) +[![coverage](https://img.shields.io/coveralls/polkadot-js/api.svg?style=flat-square)](https://coveralls.io/github/polkadot-js/api?branch=master) +[![dependency](https://david-dm.org/polkadot-js/api.svg?style=flat-square&path=packages/api-rx)](https://david-dm.org/polkadot-js/api?path=packages/api-rx) +[![devDependency](https://david-dm.org/polkadot-js/api/dev-status.svg?style=flat-square&path=packages/api-rx)](https://david-dm.org/polkadot-js/api?path=packages/api-rx#info=devDependencies) + +# @polkadot/api-rx + +An RxJs wrapper around the [@polkadot/api](../api). diff --git a/packages/api-rx/package.json b/packages/api-rx/package.json new file mode 100644 index 000000000000..eedb1695289b --- /dev/null +++ b/packages/api-rx/package.json @@ -0,0 +1,37 @@ +{ + "name": "@polkadot/api-rx", + "version": "0.6.1", + "description": "An RxJs wrapper around the Polkadot JS API", + "main": "index.js", + "keywords": [ + "Polkadot", + "RxJs" + ], + "author": "Jaco Greeff ", + "license": "ISC", + "engines": { + "node": ">=8.0" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/polkadot-js/api.git" + }, + "bugs": { + "url": "https://github.com/polkadot-js/api/issues" + }, + "homepage": "https://github.com/polkadot-js/api/tree/master/packages/api-rx#readme", + "scripts": { + "build": "polkadot-dev-build-babel", + "check": "eslint src && flow check", + "test": "echo \"Tests only available from root wrapper\"" + }, + "dependencies": { + "@polkadot/api": "^0.9.16", + "@polkadot/api-provider": "^0.9.16", + "rxjs": "^5.5.10" + } +} diff --git a/packages/api-rx/src/api/connected.js b/packages/api-rx/src/api/connected.js new file mode 100644 index 000000000000..3ff91f9388e9 --- /dev/null +++ b/packages/api-rx/src/api/connected.js @@ -0,0 +1,17 @@ +// Copyright 2017-2018 Jaco Greeff +// This software may be modified and distributed under the terms +// of the ISC license. See the LICENSE file for details. +// @flow + +import type { ProviderInterface } from '@polkadot/api-provider/types'; + +const { BehaviorSubject } = require('rxjs/BehaviorSubject'); + +module.exports = function connected (provider: ProviderInterface): rxjs$BehaviorSubject { + const subject = new BehaviorSubject(provider.isConnected()); + + provider.on('connected', () => subject.next(true)); + provider.on('disconnected', () => subject.next(false)); + + return subject; +}; diff --git a/packages/api-rx/src/api/index.js b/packages/api-rx/src/api/index.js new file mode 100644 index 000000000000..0c18bfe994da --- /dev/null +++ b/packages/api-rx/src/api/index.js @@ -0,0 +1,18 @@ +// Copyright 2017-2018 Jaco Greeff +// This software may be modified and distributed under the terms +// of the ISC license. See the LICENSE file for details. +// @flow + +import type { ProviderInterface } from '@polkadot/api-provider/types'; +import type { RxApiInterface } from '../types'; + +const createConnected = require('./connected'); + +module.exports = function exposed (provider: ProviderInterface): RxApiInterface { + const connected = createConnected(provider); + + return { + isConnected: (): rxjs$BehaviorSubject => + connected + }; +}; diff --git a/packages/api-rx/src/defaults.js b/packages/api-rx/src/defaults.js new file mode 100644 index 000000000000..740bc8a50834 --- /dev/null +++ b/packages/api-rx/src/defaults.js @@ -0,0 +1,10 @@ +// Copyright 2017-2018 Jaco Greeff +// This software may be modified and distributed under the terms +// of the ISC license. See the LICENSE file for details. +// @flow + +const WS_URL = 'ws://127.0.0.1:9944'; + +module.exports = { + WS_URL +}; diff --git a/packages/api-rx/src/index.js b/packages/api-rx/src/index.js new file mode 100644 index 000000000000..c078e6c2eae4 --- /dev/null +++ b/packages/api-rx/src/index.js @@ -0,0 +1,29 @@ +// Copyright 2017-2018 Jaco Greeff +// This software may be modified and distributed under the terms +// of the ISC license. See the LICENSE file for details. +// @flow + +import type { ProviderInterface } from '@polkadot/api-provider/types'; +import type { InterfaceTypes } from '@polkadot/api-jsonrpc/types'; +import type { RxApiInterface } from './types'; + +const createApi = require('@polkadot/api'); +const interfaces = require('@polkadot/api-jsonrpc'); +const createWs = require('@polkadot/api-provider/ws'); + +const createExposed = require('./api'); +const defaults = require('./defaults'); +const createInterface = require('./interface'); + +module.exports = function rxApi (provider?: ProviderInterface = createWs(defaults.WS_URL)): RxApiInterface { + const api = createApi(provider); + const exposed = createExposed(provider); + + return Object + .keys(interfaces) + .reduce((result, type: InterfaceTypes) => { + result[type] = createInterface(api, type); + + return result; + }, exposed); +}; diff --git a/packages/api-rx/src/index.spec.js b/packages/api-rx/src/index.spec.js new file mode 100644 index 000000000000..224117529529 --- /dev/null +++ b/packages/api-rx/src/index.spec.js @@ -0,0 +1,22 @@ +// Copyright 2017-2018 Jaco Greeff +// This software may be modified and distributed under the terms +// of the ISC license. See the LICENSE file for details. + +jest.mock('@polkadot/api-provider/ws', () => () => ({ + isConnected: () => true, + on: () => true, + send: () => true +})); +jest.mock('./interface', () => (api, sectionName) => sectionName); + +const createApi = require('./index'); + +describe('createApi', () => { + it('creates an instance with all sections', () => { + expect( + Object.keys(createApi()) + ).toEqual([ + 'isConnected', 'author', 'chain', 'state' + ]); + }); +}); diff --git a/packages/api-rx/src/interface.js b/packages/api-rx/src/interface.js new file mode 100644 index 000000000000..b2bf9f05da6f --- /dev/null +++ b/packages/api-rx/src/interface.js @@ -0,0 +1,23 @@ +// Copyright 2017-2018 Jaco Greeff +// This software may be modified and distributed under the terms +// of the ISC license. See the LICENSE file for details. +// @flow + +import type { ApiInterface, ApiInterface$Section } from '@polkadot/api/types'; +import type { InterfaceTypes } from '@polkadot/api-jsonrpc/types'; +import type { RxApiInterface$Section } from './types'; + +const observable = require('./observable'); + +module.exports = function createInterface (api: ApiInterface, sectionName: InterfaceTypes): RxApiInterface$Section { + const section: ApiInterface$Section = api[sectionName]; + + return Object + .keys(section) + .filter((name) => !['subscribe', 'unsubscribe'].includes(name)) + .reduce((observables, name) => { + observables[name] = observable(`${sectionName}_${name}`, name, section); + + return observables; + }, ({}: $Shape)); +}; diff --git a/packages/api-rx/src/interface.spec.js b/packages/api-rx/src/interface.spec.js new file mode 100644 index 000000000000..8f1e5339f8ce --- /dev/null +++ b/packages/api-rx/src/interface.spec.js @@ -0,0 +1,37 @@ +// Copyright 2017-2018 Jaco Greeff +// This software may be modified and distributed under the terms +// of the ISC license. See the LICENSE file for details. + +jest.mock('./observable', () => (subName, name, section) => `${subName}_${name}_${Object.keys(section).join(':')}`); + +const createInterface = require('./interface'); + +describe('createInterface', () => { + let api; + let int; + + beforeEach(() => { + api = { + chain: { + foo: 'test', + bar: 'test', + subscribe: 'noInclude', + unsubscribe: 'noInclude' + }, + state: { + baz: 'test' + } + }; + + int = createInterface(api, 'chain'); + }); + + it('creates observables for all relevant methods', () => { + expect( + int + ).toEqual({ + foo: 'chain_foo_foo_foo:bar:subscribe:unsubscribe', + bar: 'chain_bar_bar_foo:bar:subscribe:unsubscribe' + }); + }); +}); diff --git a/packages/api-rx/src/observable/cached.js b/packages/api-rx/src/observable/cached.js new file mode 100644 index 000000000000..cf80271695b8 --- /dev/null +++ b/packages/api-rx/src/observable/cached.js @@ -0,0 +1,35 @@ +// Copyright 2017-2018 Jaco Greeff +// This software may be modified and distributed under the terms +// of the ISC license. See the LICENSE file for details. +// @flow + +import type { ApiInterface$Section } from '@polkadot/api/types'; + +type Cached$Name = string; +type Cached$ParamJson = string; + +type CachedMap = { + [Cached$Name]: { + [Cached$ParamJson]: rxjs$BehaviorSubject<*> + } +}; + +const subject = require('./subject'); + +const cacheMap: CachedMap = {}; + +module.exports = function cached (subName: string, name: string, section: ApiInterface$Section): (...params: Array) => rxjs$BehaviorSubject<*> { + if (!cacheMap[subName]) { + cacheMap[subName] = {}; + } + + return (...params: Array): rxjs$BehaviorSubject<*> => { + const paramStr = JSON.stringify(params); + + if (!cacheMap[subName][paramStr]) { + cacheMap[subName][paramStr] = subject(name, params, section); + } + + return cacheMap[subName][paramStr]; + }; +}; diff --git a/packages/api-rx/src/observable/cached.spec.js b/packages/api-rx/src/observable/cached.spec.js new file mode 100644 index 000000000000..09d6753bcb14 --- /dev/null +++ b/packages/api-rx/src/observable/cached.spec.js @@ -0,0 +1,80 @@ +// Copyright 2017-2018 Jaco Greeff +// This software may be modified and distributed under the terms +// of the ISC license. See the LICENSE file for details. + +const cachedSubscription = require('./cached'); + +describe('cached', () => { + let creator; + let section; + + beforeEach(() => { + const subMethod = jest.fn((name, ...params) => { + return Promise.resolve(12345); + }); + + subMethod.unsubscribe = jest.fn(() => { + return Promise.resolve(true); + }); + + const resMethod = jest.fn((name, ...params) => { + return Promise.resolve(12345); + }); + + section = { + resMethod, + subMethod + }; + + creator = cachedSubscription('test', 'subMethod', section); + }); + + it('creates a single observable', () => { + creator(123).subscribe((value) => {}); + + expect( + section.subMethod + ).toHaveBeenCalledWith(123, expect.anything()); + }); + + it('creates a single observable (multiple calls)', () => { + const observable1 = creator(123); + + observable1.subscribe((value) => {}); + + const observable2 = creator(123); + + observable2.subscribe((value) => {}); + + expect( + observable2 + ).toEqual(observable1); + }); + + it('creates multiple observers for different values', () => { + const observable1 = creator(123); + + observable1.subscribe((value) => {}); + + const observable2 = creator(456); + + observable2.subscribe((value) => {}); + + expect( + observable2 + ).not.toEqual(observable1); + }); + + it('functions as an subject', (done) => { + const subject = creator(123); + + subject.subscribe((value) => { + if (value) { + expect(value).toEqual('test'); + done(); + } + }); + + subject.next('test'); + }); +}); diff --git a/packages/api-rx/src/observable/index.js b/packages/api-rx/src/observable/index.js new file mode 100644 index 000000000000..c7a3d1847293 --- /dev/null +++ b/packages/api-rx/src/observable/index.js @@ -0,0 +1,22 @@ +// Copyright 2017-2018 Jaco Greeff +// This software may be modified and distributed under the terms +// of the ISC license. See the LICENSE file for details. +// @flow + +import type { ApiInterface$Section } from '@polkadot/api/types'; + +const { fromPromise } = require('rxjs/observable/fromPromise'); +const isFunction = require('@polkadot/util/is/function'); + +const cached = require('./cached'); + +module.exports = function observable (subName: string, name: string, section: ApiInterface$Section): (...params: Array) => rxjs$Observable<*> | rxjs$BehaviorSubject<*> { + if (isFunction(section[name].unsubscribe)) { + return cached(subName, name, section); + } + + return (...params: Array): rxjs$Observable<*> => + fromPromise( + section[name].apply(null, params) + ); +}; diff --git a/packages/api-rx/src/observable/index.spec.js b/packages/api-rx/src/observable/index.spec.js new file mode 100644 index 000000000000..8102e0a24916 --- /dev/null +++ b/packages/api-rx/src/observable/index.spec.js @@ -0,0 +1,38 @@ +// Copyright 2017-2018 Jaco Greeff +// This software may be modified and distributed under the terms +// of the ISC license. See the LICENSE file for details. + +const createObservable = require('./index'); + +describe('observable', () => { + let section; + + beforeEach(() => { + const withSub = () => {}; + + withSub.unsubscribe = jest.fn(); + + section = { + 'test_noSub': jest.fn(() => Promise.resolve(true)), + 'test_withSub': withSub, + subscribe: jest.fn((name, params, _callback) => { + return Promise.resolve(12345); + }), + unsubscribe: jest.fn(() => { + return Promise.resolve(true); + }) + }; + }); + + it('creates a single-shot observable', () => { + expect( + createObservable('test_noSub', 'test_noSub', section)() + ).toBeDefined(); + }); + + it('creates a subscription observable', () => { + expect( + createObservable('test_withSub', 'test_withSub', section)() + ).toBeDefined(); + }); +}); diff --git a/packages/api-rx/src/observable/subject.js b/packages/api-rx/src/observable/subject.js new file mode 100644 index 000000000000..e44a26b3180b --- /dev/null +++ b/packages/api-rx/src/observable/subject.js @@ -0,0 +1,44 @@ +// Copyright 2017-2018 Jaco Greeff +// This software may be modified and distributed under the terms +// of the ISC license. See the LICENSE file for details. +// @flow + +import type { ApiInterface$Section } from '@polkadot/api/types'; + +const { BehaviorSubject } = require('rxjs/BehaviorSubject'); +const { Observable } = require('rxjs/Observable'); + +// flowlint-next-line unclear-type:off +module.exports = function subscription (name: string, params: Array, section: ApiInterface$Section, unsubCallback?: () => void): rxjs$BehaviorSubject { + const subject = new BehaviorSubject(); + + Observable + // flowlint-next-line unclear-type:off + .create((observer: rxjs$IObserver): Function => { + const callback = (error, result) => { + if (error) { + return; + } + + observer.next(result); + }; + + const fn = section[name]; + const subParams: Array = [].concat(params, [callback]); + const subscribe = fn.apply(null, subParams); + + return (): void => { + subscribe + // flowlint-next-line unclear-type:off + .then((subscriptionId) => fn.unsubscribe(((subscriptionId: any): number))) + .then(() => { + if (unsubCallback) { + unsubCallback(); + } + }); + }; + }) + .subscribe(subject); + + return subject; +}; diff --git a/packages/api-rx/src/observable/subject.spec.js b/packages/api-rx/src/observable/subject.spec.js new file mode 100644 index 000000000000..a17f854232bf --- /dev/null +++ b/packages/api-rx/src/observable/subject.spec.js @@ -0,0 +1,73 @@ +// Copyright 2017-2018 Jaco Greeff +// This software may be modified and distributed under the terms +// of the ISC license. See the LICENSE file for details. + +const createObservable = require('./subject'); + +describe('subject', () => { + const params = [123, false]; + let callback; + let section; + let subscription; + let observable; + + beforeEach(() => { + const subMethod = jest.fn((name, ...params) => { + callback = params.pop(); + + return Promise.resolve(12345); + }); + + subMethod.unsubscribe = jest.fn(() => { + return Promise.resolve(true); + }); + + section = { + subMethod + }; + + observable = createObservable('subMethod', params, section); + }); + + afterEach(() => { + if (subscription) { + subscription.unsubscribe(); + } + }); + + it('subscribes via the api section', (done) => { + observable.subscribe((value) => { + if (value) { + expect( + section.subMethod + ).toHaveBeenCalledWith(123, false, expect.anything()); + + done(); + } + }); + callback(null, 'test'); + }); + + it('returns the observable value', (done) => { + subscription = observable.subscribe((value) => { + if (value) { + expect(value).toEqual('test'); + done(); + } + }); + + callback(null, 'test'); + }); + + it('ignores errors, returns the observable value', (done) => { + subscription = observable.subscribe((value) => { + if (value) { + expect(value).toEqual('test'); + done(); + } + }); + + callback(new Error('error')); + callback(null, 'test'); + }); +}); diff --git a/packages/api-rx/src/types.js b/packages/api-rx/src/types.js new file mode 100644 index 000000000000..6f97a29f0124 --- /dev/null +++ b/packages/api-rx/src/types.js @@ -0,0 +1,17 @@ +// Copyright 2017-2018 Jaco Greeff +// This software may be modified and distributed under the terms +// of the ISC license. See the LICENSE file for details. +// @flow + +import type { InterfaceTypes } from '@polkadot/api-jsonrpc/types'; + +export type RxApiInterface$Method = (...params: Array) => rxjs$Observable<*> | rxjs$BehaviorSubject<*>; + +export type RxApiInterface$Section = { + [string]: RxApiInterface$Method +}; + +export type RxApiInterface = { + isConnected: () => rxjs$BehaviorSubject; + [InterfaceTypes]: RxApiInterface$Section; +};