Add pubsub interface definition (#54)

* Add pubsub interface definition

* Functional JsonRpcCoder

* Functional HttpProvider

* Functional WsProvider

* Functional split completed

* Split actual method subscription

* Update flow

* Flatten no user-specified array in sub call
This commit is contained in:
Jaco Greeff
2018-03-15 12:59:49 +01:00
committed by GitHub
parent e84bd1b36b
commit 21c14bd85c
66 changed files with 2500 additions and 1113 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@polkadot/api-provider",
"version": "0.7.1",
"version": "0.8.0",
"description": "Transport providers for the API",
"main": "index.js",
"keywords": [
@@ -34,7 +34,7 @@
"nock": "^9.1.0"
},
"dependencies": {
"@polkadot/util": "^0.13.1",
"@polkadot/util": "^0.17.2",
"babel-runtime": "^6.26.0",
"isomorphic-fetch": "^2.2.1",
"websocket": "^1.0.25"
@@ -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 { JsonRpcResponse } from '../../types';
import type { RpcCoderState } from './types';
const assert = require('@polkadot/util/assert');
const isNumber = require('@polkadot/util/is/number');
const isUndefined = require('@polkadot/util/is/undefined');
module.exports = function decodeResponse (self: RpcCoderState, response: JsonRpcResponse): mixed {
assert(response, 'Empty response object received');
assert(response.jsonrpc === '2.0', 'Invalid jsonrpc field in decoded object');
assert(isNumber(response.id), 'Invalid id field in decoded object');
if (response.error) {
const { code, message } = response.error;
throw new Error(`[${code}]: ${message}`);
}
assert(!isUndefined(response.result), 'No result found in JsonRpc response');
return response.result;
};
@@ -0,0 +1,49 @@
// 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 createCoder = require('./index');
describe('decodeResponse', () => {
let coder;
beforeEach(() => {
coder = createCoder();
});
it('expects a non-empty input object', () => {
expect(
() => coder.decodeResponse()
).toThrow(/Empty response/);
});
it('expects a valid jsonrpc field', () => {
expect(
() => coder.decodeResponse({})
).toThrow(/Invalid jsonrpc/);
});
it('expects a valid id field', () => {
expect(
() => coder.decodeResponse({ jsonrpc: '2.0' })
).toThrow(/Invalid id/);
});
it('expects a valid result field', () => {
expect(
() => coder.decodeResponse({ id: 1, jsonrpc: '2.0' })
).toThrow(/No result/);
});
it('throws any error found', () => {
expect(
() => coder.decodeResponse({ id: 1, jsonrpc: '2.0', error: { code: 123, message: 'test error' } })
).toThrow(/\[123\]: test error/);
});
it('returns the result', () => {
expect(
coder.decodeResponse({ id: 1, jsonrpc: '2.0', result: 'some result' })
).toEqual('some result');
});
});
@@ -0,0 +1,14 @@
// 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 { RpcCoderState } from './types';
const encodeObject = require('./encodeObject');
module.exports = function encodeJson (self: RpcCoderState, method: string, params: Array<mixed>): string {
return JSON.stringify(
encodeObject(self, method, params)
);
};
@@ -0,0 +1,19 @@
// 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 createCoder = require('./index');
describe('encodeJson', () => {
let coder;
beforeEach(() => {
coder = createCoder();
});
it('encodes a valid JsonRPC JSON string', () => {
expect(
coder.encodeJson('method', 'params')
).toEqual('{"id":1,"jsonrpc":"2.0","method":"method","params":"params"}');
});
});
@@ -0,0 +1,16 @@
// 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 { JsonRpcRequest } from '../../types';
import type { RpcCoderState } from './types';
module.exports = function encodeObject (self: RpcCoderState, method: string, params: Array<mixed>): JsonRpcRequest {
return {
id: ++self.id,
jsonrpc: '2.0',
method,
params
};
};
@@ -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.
const createCoder = require('./index');
describe('encodeObject', () => {
let coder;
beforeEach(() => {
coder = createCoder();
});
it('starts with id === 0 (nothing sent)', () => {
expect(coder.getId()).toEqual(0);
});
it('encodes a valid JsonRPC object', () => {
expect(
coder.encodeObject('method', 'params')
).toEqual({
id: 1,
jsonrpc: '2.0',
method: 'method',
params: 'params'
});
expect(coder.getId()).toEqual(1);
});
});
@@ -0,0 +1,28 @@
// 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 { JsonRpcRequest, JsonRpcResponse } from '../../types';
import type { RpcCoder, RpcCoderState } from './types';
const decodeResponse = require('./decodeResponse');
const encodeJson = require('./encodeJson');
const encodeObject = require('./encodeObject');
module.exports = function rpcCoder (): RpcCoder {
const self: RpcCoderState = {
id: 0
};
return {
decodeResponse: (response: JsonRpcResponse): mixed =>
decodeResponse(self, response),
encodeJson: (method: string, params: Array<mixed>): string =>
encodeJson(self, method, params),
encodeObject: (method: string, params: Array<mixed>): JsonRpcRequest =>
encodeObject(self, method, params),
getId: (): number =>
self.id
};
};
@@ -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 { JsonRpcRequest, JsonRpcResponse } from '../../types';
export type RpcCoder = {
decodeResponse: (response: JsonRpcResponse) => mixed,
encodeObject: (method: string, params: Array<mixed>) => JsonRpcRequest,
encodeJson: (method: string, params: Array<mixed>) => string,
getId: () => number
};
export type RpcCoderState = {
id: number
};
+17 -36
View File
@@ -3,45 +3,26 @@
// of the ISC license. See the LICENSE file for details.
// @flow
import type { ProviderInterface } from '../types';
import type { ProviderInterface, ProviderInterface$Callback } from '../types';
require('./polyfill');
const assert = require('@polkadot/util/assert');
const JsonRpcCoder = require('../jsonRpcCoder');
const send = require('./send');
const subscribe = require('./subscribe');
const state = require('./state');
const unsubscribe = require('./unsubscribe');
module.exports = class HttpProvider extends JsonRpcCoder implements ProviderInterface {
_endpoint: string;
module.exports = function httpProvider (endpoint: string): ProviderInterface {
const self = state(endpoint);
constructor (endpoint: string) {
super();
assert(/^http:\/\//.test(endpoint), `Endpoint should start with 'http://', received '${endpoint}'`);
this._endpoint = endpoint;
}
// flowlint-next-line unsafe-getters-setters:off
get isConnected (): boolean {
return true;
}
async send (method: string, params: Array<mixed>): Promise<mixed> {
const body = this.encodeJson(method, params);
const response = await fetch(this._endpoint, {
body,
headers: {
'Accept': 'application/json',
'Content-Length': `${body.length}`,
'Content-Type': 'application/json'
},
method: 'POST'
});
assert(response.ok, `[${response.status}]: ${response.statusText}`);
const result = await response.json();
return this.decodeResponse(result);
}
return {
isConnected: (): boolean =>
true,
send: (method: string, params: Array<mixed>): Promise<mixed> =>
send(self, method, params),
subscribe: (method: string, params: Array<mixed>, cb: ProviderInterface$Callback): Promise<number> =>
subscribe(self, method, params, cb),
unsubscribe: (id: number): Promise<boolean> =>
unsubscribe(self, id)
};
};
+11 -92
View File
@@ -2,111 +2,30 @@
// This software may be modified and distributed under the terms
// of the ISC license. See the LICENSE file for details.
const { mockHttp, TEST_HTTP_URL } = require('../../test/mockHttp');
const { TEST_HTTP_URL } = require('../../test/mockHttp');
const Http = require('./index');
const createHttp = require('./index');
describe('Http', () => {
let http;
let mock;
let encodeSpy;
let decodeSpy;
beforeEach(() => {
http = new Http(TEST_HTTP_URL);
encodeSpy = jest.spyOn(http, 'encodeJson');
decodeSpy = jest.spyOn(http, 'decodeResponse');
http = createHttp(TEST_HTTP_URL);
});
afterEach(() => {
encodeSpy.mockRestore();
decodeSpy.mockRestore();
if (mock) {
mock.done();
mock = null;
}
it('always returns isConnected true', () => {
expect(http.isConnected()).toEqual(true);
});
it('requires an http:// prefixed endpoint', () => {
expect(
() => new Http('ws://')
).toThrow(/with 'http/);
});
describe('isConnected', () => {
it('always returns true', () => {
expect(http.isConnected).toEqual(true);
it('does not (yet) support subscribe', () => {
return http.subscribe().catch((error) => {
expect(error.message).toMatch(/has not been implemented/);
});
});
describe('send', () => {
it('encodes requests', () => {
mock = mockHttp([{
method: 'test_encoding',
reply: {
result: 'ok'
}
}]);
return http
.send('test_encoding', ['param'])
.then((result) => {
expect(encodeSpy).toHaveBeenCalledWith('test_encoding', ['param']);
});
});
it('decodes responses', () => {
mock = mockHttp([{
method: 'test_encoding',
reply: {
result: 'ok'
}
}]);
return http
.send('test_encoding', ['param'])
.then((result) => {
expect(decodeSpy).toHaveBeenCalledWith({
id: 1,
jsonrpc: '2.0',
result: 'ok'
});
});
});
it('passes the body through correctly', () => {
mock = mockHttp([{
method: 'test_body',
reply: {
result: 'ok'
}
}]);
return http
.send('test_body', ['param'])
.then((result) => {
expect(mock.body['test_body']).toEqual({
id: 1,
jsonrpc: '2.0',
method: 'test_body',
params: ['param']
});
});
});
it('throws error when !response.ok', () => {
mock = mockHttp([{
code: 500,
method: 'test_error'
}]);
return http
.send('test_error', [])
.catch((error) => {
expect(error.message).toMatch(/\[500\]: Internal Server/);
});
it('does not (yet) support unsubscribe', () => {
return http.unsubscribe().catch((error) => {
expect(error.message).toMatch(/has not been implemented/);
});
});
});
+27
View File
@@ -0,0 +1,27 @@
// 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 { HttpState } from './types';
const assert = require('@polkadot/util/assert');
module.exports = async function send ({ coder, endpoint }: HttpState, method: string, params: Array<mixed>): Promise<mixed> {
const body = coder.encodeJson(method, params);
const response = await fetch(endpoint, {
body,
headers: {
'Accept': 'application/json',
'Content-Length': `${body.length}`,
'Content-Type': 'application/json'
},
method: 'POST'
});
assert(response.ok, `[${response.status}]: ${response.statusText}`);
const result = await response.json();
return coder.decodeResponse(result);
};
@@ -0,0 +1,56 @@
// 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 { mockHttp, TEST_HTTP_URL } = require('../../test/mockHttp');
const createHttp = require('./index');
describe('send', () => {
let http;
let mock;
beforeEach(() => {
http = createHttp(TEST_HTTP_URL);
});
afterEach(() => {
if (mock) {
mock.done();
mock = null;
}
});
it('passes the body through correctly', () => {
mock = mockHttp([{
method: 'test_body',
reply: {
result: 'ok'
}
}]);
return http
.send('test_body', ['param'])
.then((result) => {
expect(mock.body['test_body']).toEqual({
id: 1,
jsonrpc: '2.0',
method: 'test_body',
params: ['param']
});
});
});
it('throws error when !response.ok', () => {
mock = mockHttp([{
code: 500,
method: 'test_error'
}]);
return http
.send('test_error', [])
.catch((error) => {
expect(error.message).toMatch(/\[500\]: Internal Server/);
});
});
});
+21
View File
@@ -0,0 +1,21 @@
// 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 { HttpState } from './types';
const assert = require('@polkadot/util/assert');
const l = require('@polkadot/util/logger')('api-http');
const coder = require('../coder/json');
module.exports = function state (endpoint: string): HttpState {
assert(/^http:\/\//.test(endpoint), `Endpoint should start with 'http://', received '${endpoint}'`);
return {
coder: coder(),
endpoint,
l
};
};
@@ -0,0 +1,13 @@
// 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 state = require('./state');
describe('state', () => {
it('requires an http:// prefixed endpoint', () => {
expect(
() => state('ws://')
).toThrow(/with 'http/);
});
});
@@ -0,0 +1,11 @@
// 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$Callback } from '../types';
import type { HttpState } from './types';
module.exports = async function subscribe (self: HttpState, method: string, params: Array<mixed>, cb: ProviderInterface$Callback): Promise<number> {
throw new Error('Subscriptions has not been implemented');
};
+13
View File
@@ -0,0 +1,13 @@
// 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 { Logger } from '@polkadot/util/types';
import type { RpcCoder } from '../coder/json/types';
export type HttpState = {
coder: RpcCoder,
endpoint: string,
l: Logger
};
@@ -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
import type { HttpState } from './types';
module.exports = async function unsubscribe (self: HttpState, id: number): Promise<boolean> {
throw new Error('Subscriptions has not been implemented');
};
+4 -4
View File
@@ -3,10 +3,10 @@
// of the ISC license. See the LICENSE file for details.
// @flow
const Http = require('./http');
const Ws = require('./ws');
const http = require('./http');
const ws = require('./ws');
module.exports = {
Http,
Ws
http,
ws
};
-52
View File
@@ -1,52 +0,0 @@
// 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 { JsonRpcRequest, JsonRpcResponse } from './types';
const assert = require('@polkadot/util/assert');
const isNumber = require('@polkadot/util/is/number');
const isUndefined = require('@polkadot/util/is/undefined');
module.exports = class JsonRpcCoder {
_id: number = 0;
decodeResponse (response: JsonRpcResponse): mixed {
assert(response, 'Empty response object received');
assert(response.jsonrpc === '2.0', 'Invalid jsonrpc field in decoded object');
assert(isNumber(response.id), 'Invalid id field in decoded object');
if (response.error) {
const { code, message } = response.error;
throw new Error(`[${code}]: ${message}`);
}
assert(!isUndefined(response.result), 'No result found in JsonRpc response');
return response.result;
}
encodeObject (method: string, params: Array<mixed>): JsonRpcRequest {
return {
id: ++this._id,
jsonrpc: '2.0',
method,
params
};
}
encodeJson (method: string, params: Array<mixed>): string {
return JSON.stringify(
this.encodeObject(method, params)
);
}
// flowlint-next-line unsafe-getters-setters:off
get id (): number {
return this._id;
}
};
@@ -1,79 +0,0 @@
// 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 JsonRpcCoder = require('./jsonRpcCoder');
describe('JsonRpcCoder', () => {
let coder;
beforeEach(() => {
coder = new JsonRpcCoder();
});
describe('id', () => {
it('starts with id === 0 (nothing sent)', () => {
expect(coder.id).toEqual(0);
});
});
describe('decodeResponse', () => {
it('expects a non-empty input object', () => {
expect(
() => coder.decodeResponse()
).toThrow(/Empty response/);
});
it('expects a valid jsonrpc field', () => {
expect(
() => coder.decodeResponse({})
).toThrow(/Invalid jsonrpc/);
});
it('expects a valid id field', () => {
expect(
() => coder.decodeResponse({ jsonrpc: '2.0' })
).toThrow(/Invalid id/);
});
it('expects a valid result field', () => {
expect(
() => coder.decodeResponse({ id: 1, jsonrpc: '2.0' })
).toThrow(/No result/);
});
it('throws any error found', () => {
expect(
() => coder.decodeResponse({ id: 1, jsonrpc: '2.0', error: { code: 123, message: 'test error' } })
).toThrow(/\[123\]: test error/);
});
it('returns the result', () => {
expect(
coder.decodeResponse({ id: 1, jsonrpc: '2.0', result: 'some result' })
).toEqual('some result');
});
});
describe('encodeObject', () => {
it('encodes a valid JsonRPC object', () => {
expect(
coder.encodeObject('method', 'params')
).toEqual({
id: 1,
jsonrpc: '2.0',
method: 'method',
params: 'params'
});
expect(coder.id).toEqual(1);
});
});
describe('encodeJson', () => {
it('encodes a valid JsonRPC JSON string', () => {
expect(
coder.encodeJson('method', 'params')
).toEqual('{"id":1,"jsonrpc":"2.0","method":"method","params":"params"}');
});
});
});
+7 -3
View File
@@ -23,7 +23,11 @@ export type JsonRpcResponseBase = {
export type JsonRpcResponse = JsonRpcObject & JsonRpcResponseBase;
export interface ProviderInterface {
+isConnected: boolean;
send (method: string, params: Array<mixed>): Promise<mixed>;
export type ProviderInterface$Callback = (error: ?Error, result: mixed) => void;
export type ProviderInterface = {
isConnected (): boolean,
send (method: string, params: Array<mixed>): Promise<mixed>,
subscribe (method: string, params: Array<mixed>, cb: ProviderInterface$Callback): Promise<number>,
unsubscribe (id: number): Promise<boolean>
}
+20
View File
@@ -0,0 +1,20 @@
// 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 { WsState } from './types';
const onClose = require('./onClose');
const onError = require('./onError');
const onMessage = require('./onMessage');
const onOpen = require('./onOpen');
module.exports = function connect (self: WsState): void {
self.websocket = new WebSocket(self.endpoint);
self.websocket.onclose = onClose(self);
self.websocket.onerror = onError(self);
self.websocket.onmessage = onMessage(self);
self.websocket.onopen = onOpen(self);
};
@@ -0,0 +1,34 @@
// 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 { mockWs, TEST_WS_URL } = require('../../test/mockWs');
const createState = require('./state');
const connect = require('./connect');
describe('onError', () => {
let mock;
beforeEach(() => {
mock = mockWs([]);
});
afterEach(() => {
if (mock) {
mock.done();
mock = null;
}
});
it('sets up the on* handlers', () => {
const state = createState(TEST_WS_URL, false);
connect(state);
expect(state.websocket.onclose[0]).toBeDefined();
expect(state.websocket.onerror[0]).toBeDefined();
expect(state.websocket.onmessage[0]).toBeDefined();
expect(state.websocket.onopen[0]).toBeDefined();
});
});
+22 -117
View File
@@ -3,128 +3,33 @@
// of the ISC license. See the LICENSE file for details.
// @flow
import type { JsonRpcResponse, ProviderInterface } from '../types';
type AwaitingType = {
callback: (error: ?Error, result: mixed) => void
};
import type { ProviderInterface, ProviderInterface$Callback } from '../types';
require('./polyfill');
const assert = require('@polkadot/util/assert');
const JsonRpcCoder = require('../jsonRpcCoder');
const connect = require('./connect');
const send = require('./send');
const state = require('./state');
const subscribe = require('./subscribe');
const unsubscribe = require('./unsubscribe');
module.exports = class WsProvider extends JsonRpcCoder implements ProviderInterface {
_autoConnect: boolean = true;
_endpoint: string;
_handlers: { [number]: AwaitingType } = {};
_isConnected: boolean = false;
_queued: { [number]: string } = {};
_websocket: WebSocket;
module.exports = function wsProvider (endpoint: string, autoConnect: boolean = true): ProviderInterface {
const self = state(endpoint, autoConnect);
constructor (endpoint: string, autoConnect: boolean = true) {
super();
assert(/^ws:\/\//.test(endpoint), `Endpoint should start with 'ws://', received '${endpoint}'`);
this._endpoint = endpoint;
this._autoConnect = autoConnect;
if (autoConnect) {
this.connect();
}
if (autoConnect) {
connect(self);
}
// flowlint-next-line unsafe-getters-setters:off
get isConnected (): boolean {
return this._isConnected;
}
connect = () => {
this._websocket = new WebSocket(this._endpoint);
this._websocket.onclose = this._onClose;
this._websocket.onerror = this._onError;
this._websocket.onmessage = this._onMessage;
this._websocket.onopen = this._onOpen;
}
_onClose = () => {
// console.log('disconnected from', this._endpoint);
this._isConnected = false;
if (this._autoConnect) {
setTimeout(this.connect, 1000);
}
}
_onError = (error: Event) => {
console.error(error);
}
_onOpen = () => {
// console.log('connected to', this._endpoint);
this._isConnected = true;
Object.keys(this._queued).forEach((id) => {
try {
this._websocket.send(
// flowlint-next-line unclear-type:off
this._queued[((id: any): number)]
);
// flowlint-next-line unclear-type:off
delete this._queued[((id: any): number)];
} catch (error) {
console.error(error);
}
});
}
_onMessage = (message: MessageEvent): void => {
// flowlint-next-line unclear-type:off
const response: JsonRpcResponse = JSON.parse(((message.data: any): string));
const handler = this._handlers[response.id];
if (!handler) {
console.error(`Unable to find handler for id=${response.id}`);
return;
}
try {
const result = this.decodeResponse(response);
handler.callback(null, result);
} catch (error) {
handler.callback(error);
}
delete this._handlers[response.id];
}
send (method: string, params: Array<mixed>): Promise<mixed> {
return new Promise((resolve, reject): void => {
try {
const json = this.encodeJson(method, params);
this._handlers[this.id] = {
callback: (error: ?Error, result: mixed) => {
if (error) {
reject(error);
} else {
resolve(result);
}
}
};
if (this._isConnected) {
this._websocket.send(json);
} else {
this._queued[this.id] = json;
}
} catch (error) {
reject(error);
}
});
}
return {
connect: (): void =>
connect(self),
isConnected: (): boolean =>
self.isConnected,
send: (method: string, params: Array<mixed>): Promise<mixed> =>
send(self, method, params),
subscribe: (method: string, params: Array<mixed>, cb: ProviderInterface$Callback): Promise<number> =>
subscribe(self, method, params, cb),
unsubscribe: (id: number): Promise<boolean> =>
unsubscribe(self, id)
};
};
+12 -189
View File
@@ -4,223 +4,46 @@
const { mockWs, TEST_WS_URL } = require('../../test/mockWs');
const Ws = require('./index');
const create = require('./index');
let ws;
let mock;
let encodeSpy;
let decodeSpy;
function createWs (requests, autoConnect) {
mock = mockWs(requests);
ws = new Ws(TEST_WS_URL, autoConnect);
encodeSpy = jest.spyOn(ws, 'encodeObject');
decodeSpy = jest.spyOn(ws, 'decodeResponse');
ws = create(TEST_WS_URL, autoConnect);
return ws;
}
describe('Ws', () => {
afterEach(() => {
if (encodeSpy) {
encodeSpy.mockRestore();
encodeSpy = null;
}
if (decodeSpy) {
decodeSpy.mockRestore();
decodeSpy = null;
}
if (mock) {
mock.done();
mock = null;
}
});
it('requires an ws:// prefixed endpoint', () => {
it('returns the connected state', () => {
expect(
() => new Ws('http://')
).toThrow(/with 'ws/);
createWs([]).isConnected()
).toEqual(false);
});
describe('websocket', () => {
let errorSpy;
describe('pubsub', () => {
beforeEach(() => {
ws = createWs([]);
errorSpy = jest.spyOn(console, 'error');
});
afterEach(() => {
errorSpy.mockRestore();
});
it('sets up the on* handlers', () => {
expect(ws._websocket.onclose[0]).toEqual(ws._onClose);
expect(ws._websocket.onerror[0]).toEqual(ws._onError);
expect(ws._websocket.onmessage[0]).toEqual(ws._onMessage);
expect(ws._websocket.onopen[0]).toEqual(ws._onOpen);
});
describe('_onClose', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it('reconnects after delay', () => {
ws.connect = jest.fn();
ws._onClose();
expect(ws.connect).not.toHaveBeenCalled();
jest.runAllTimers();
expect(ws.connect).toHaveBeenCalled();
});
it('sets isConnected false', () => {
expect(ws.isConnected).toEqual(false);
});
it('does not reconnect when autoConnect false', () => {
mock.done();
ws = createWs([], false);
ws.connect = jest.fn();
ws._onClose();
expect(ws.connect).not.toHaveBeenCalled();
jest.runAllTimers();
expect(ws.connect).not.toHaveBeenCalled();
it('does not (yet) support subscribe', () => {
return ws.subscribe().catch((error) => {
expect(error.message).toMatch(/has not been implemented/);
});
});
describe('_onError', () => {
it('logs the error', () => {
ws._onError('test error');
expect(errorSpy).toHaveBeenCalledWith('test error');
});
});
describe('_onMessage', () => {
it('fails with log when handler not found', () => {
ws._onMessage({ data: '{"id":2}' });
expect(errorSpy).toHaveBeenCalledWith('Unable to find handler for id=2');
});
});
});
describe('send', () => {
it('handles internal errors', () => {
ws = createWs([]);
ws._isConnected = true;
ws._websocket = null;
return ws
.send('test_encoding', ['param'])
.catch((error) => {
expect(error).toBeDefined();
});
});
it('encodes requests', () => {
ws = createWs([{
id: 1,
method: 'test_encoding',
reply: {
result: 'ok'
}
}]);
return ws
.send('test_encoding', ['param'])
.then((result) => {
expect(encodeSpy).toHaveBeenCalledWith('test_encoding', ['param']);
});
});
it('decodes responses', () => {
ws = createWs([{
id: 1,
method: 'test_encoding',
reply: {
result: 'ok'
}
}]);
return ws
.send('test_encoding', ['param'])
.then((result) => {
expect(decodeSpy).toHaveBeenCalledWith({
id: 1,
jsonrpc: '2.0',
result: 'ok'
});
});
});
it('passes the body through correctly', () => {
ws = createWs([{
id: 1,
method: 'test_body',
reply: {
result: 'ok'
}
}]);
return ws
.send('test_body', ['param'])
.then((result) => {
expect(
mock.body['test_body']
).toEqual('{"id":1,"jsonrpc":"2.0","method":"test_body","params":["param"]}');
});
});
it('throws error when !response.ok', () => {
ws = createWs([{
id: 1,
error: {
code: 666,
message: 'error'
}
}]);
return ws
.send('test_error', [])
.catch((error) => {
expect(error.message).toMatch(/\[666\]: error/);
});
});
});
describe('queued', () => {
it('sends messages when connected', () => {
const ws = createWs([{
id: 1,
method: 'test_queue',
reply: {
result: 'ok'
}
}], false);
const sendPromise = ws.send('test_queue', []);
ws.connect();
return sendPromise.then((result) => {
expect(result).toEqual('ok');
it('does not (yet) support unsubscribe', () => {
return ws.unsubscribe().catch((error) => {
expect(error.message).toMatch(/has not been implemented/);
});
});
});
+20
View File
@@ -0,0 +1,20 @@
// 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 { WsState } from './types';
const connect = require('./connect');
module.exports = function onClose (self: WsState): () => void {
return (): void => {
self.l.debug(() => ['disconnected from', self.endpoint]);
self.isConnected = false;
if (self.autoConnect) {
setTimeout(() => connect(self), 1000);
}
};
};
@@ -0,0 +1,42 @@
// 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 { TEST_WS_URL } = require('../../test/mockWs');
const createState = require('./state');
describe('onClose', () => {
let mockConnect;
beforeEach(() => {
mockConnect = jest.fn();
jest.mock('./connect', () => mockConnect);
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it('reconnects after delay', () => {
require('./onClose')(createState(TEST_WS_URL, true))();
expect(mockConnect).not.toHaveBeenCalled();
jest.runAllTimers();
expect(mockConnect).toHaveBeenCalled();
});
it('does not reconnect when autoConnect false', () => {
require('./onClose')(createState(TEST_WS_URL, false))();
expect(mockConnect).not.toHaveBeenCalled();
jest.runAllTimers();
expect(mockConnect).not.toHaveBeenCalled();
});
});
+12
View File
@@ -0,0 +1,12 @@
// 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 { WsState } from './types';
module.exports = function onError (self: WsState): (Event) => void {
return (error: Event): void => {
self.l.error(error);
};
};
@@ -0,0 +1,28 @@
// 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 onError = require('./onError');
const createState = require('./state');
describe('onError', () => {
let state;
let errorSpy;
beforeEach(() => {
state = createState('ws://127.0.0.1:1234', false);
errorSpy = jest.spyOn(console, 'error');
});
afterEach(() => {
errorSpy.mockRestore();
});
it('logs the error', () => {
onError(state)('test error');
expect(errorSpy).toHaveBeenCalledWith(
expect.anything(), expect.anything(), 'test error'
);
});
});
+30
View File
@@ -0,0 +1,30 @@
// 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 { JsonRpcResponse } from '../types';
import type { WsState } from './types';
module.exports = function onMessage (self: WsState): (MessageEvent) => void {
return (message: MessageEvent): void => {
// flowlint-next-line unclear-type:off
const response: JsonRpcResponse = JSON.parse(((message.data: any): string));
const handler = self.handlers[response.id];
if (!handler) {
self.l.error(`Unable to find handler for id=${response.id}`);
return;
}
try {
const result = self.coder.decodeResponse(response);
handler.callback(null, result);
} catch (error) {
handler.callback(error);
}
delete self.handlers[response.id];
};
};
@@ -0,0 +1,28 @@
// 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 onMessage = require('./onMessage');
const createState = require('./state');
describe('onError', () => {
let state;
let errorSpy;
beforeEach(() => {
state = createState('ws://127.0.0.1:1234', false);
errorSpy = jest.spyOn(console, 'error');
});
afterEach(() => {
errorSpy.mockRestore();
});
it('fails with log when handler not found', () => {
onMessage(state)({ data: '{"id":2}' });
expect(errorSpy).toHaveBeenCalledWith(
expect.anything(), expect.anything(), 'Unable to find handler for id=2'
);
});
});
+30
View File
@@ -0,0 +1,30 @@
// 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 { WsState } from './types';
module.exports = function onOpen (self: WsState): () => boolean {
return (): boolean => {
self.l.debug(() => ['connected to', self.endpoint]);
self.isConnected = true;
Object.keys(self.queued).forEach((id) => {
try {
self.websocket.send(
// flowlint-next-line unclear-type:off
self.queued[((id: any): number)]
);
// flowlint-next-line unclear-type:off
delete self.queued[((id: any): number)];
} catch (error) {
self.l.error(error);
}
});
return true;
};
};
@@ -0,0 +1,43 @@
// 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 { mockWs, TEST_WS_URL } = require('../../test/mockWs');
const create = require('./index');
let ws;
let mock;
function createWs (requests, autoConnect) {
mock = mockWs(requests);
ws = create(TEST_WS_URL, autoConnect);
return ws;
}
describe('onOpen', () => {
afterEach(() => {
if (mock) {
mock.done();
mock = null;
}
});
it('sends messages when connected', () => {
const ws = createWs([{
id: 1,
method: 'test_queue',
reply: {
result: 'ok'
}
}], false);
const sendPromise = ws.send('test_queue', []);
ws.connect();
return sendPromise.then((result) => {
expect(result).toEqual('ok');
});
});
});
+34
View File
@@ -0,0 +1,34 @@
// 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 { WsState } from './types';
module.exports = async function send (self: WsState, method: string, params: Array<mixed>): Promise<mixed> {
return new Promise((resolve, reject): void => {
try {
const json = self.coder.encodeJson(method, params);
const id = self.coder.getId();
const callback = (error: ?Error, result: mixed) => {
if (error) {
reject(error);
} else {
resolve(result);
}
};
self.handlers[id] = {
callback
};
if (self.isConnected) {
self.websocket.send(json);
} else {
self.queued[id] = json;
}
} catch (error) {
reject(error);
}
});
};
+94
View File
@@ -0,0 +1,94 @@
// 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 { mockWs, TEST_WS_URL } = require('../../test/mockWs');
let ws;
let mock;
function createMock (requests) {
mock = mockWs(requests);
}
function createWs (autoConnect) {
ws = require('./index')(TEST_WS_URL, autoConnect);
return ws;
}
describe('send', () => {
let globalWs;
beforeEach(() => {
globalWs = global.WebSocket;
});
afterEach(() => {
global.WebSocket = globalWs;
if (mock) {
mock.done();
mock = null;
}
});
it('handles internal errors', () => {
createMock([]);
let websocket;
global.WebSocket = class {
constructor () {
websocket = this;
}
send () {
throw new Error('send error');
}
};
ws = createWs();
websocket.onopen();
return ws
.send('test_encoding', ['param'])
.catch((error) => {
expect(error.message).toEqual('send error');
});
});
it('passes the body through correctly', () => {
createMock([{
id: 1,
method: 'test_body',
reply: {
result: 'ok'
}
}]);
return createWs()
.send('test_body', ['param'])
.then((result) => {
expect(
mock.body['test_body']
).toEqual('{"id":1,"jsonrpc":"2.0","method":"test_body","params":["param"]}');
});
});
it('throws error when !response.ok', () => {
createMock([{
id: 1,
error: {
code: 666,
message: 'error'
}
}]);
return createWs()
.send('test_error', [])
.catch((error) => {
expect(error.message).toMatch(/\[666\]: error/);
});
});
});
+25
View File
@@ -0,0 +1,25 @@
// 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 { WsState } from './types';
const assert = require('@polkadot/util/assert');
const l = require('@polkadot/util/logger')('api-ws');
const coder = require('../coder/json');
module.exports = function state (endpoint: string, autoConnect: boolean): $Shape<WsState> {
assert(/^ws:\/\//.test(endpoint), `Endpoint should start with 'ws://', received '${endpoint}'`);
return {
autoConnect,
coder: coder(),
endpoint,
handlers: {},
isConnected: false,
l,
queued: {}
};
};
@@ -0,0 +1,13 @@
// 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 state = require('./state');
describe('state', () => {
it('requires an ws:// prefixed endpoint', () => {
expect(
() => state('http://')
).toThrow(/with 'ws/);
});
});
+11
View File
@@ -0,0 +1,11 @@
// 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$Callback } from '../types';
import type { WsState } from './types';
module.exports = async function subscribe (self: WsState, method: string, params: Array<mixed>, cb: ProviderInterface$Callback): Promise<number> {
throw new Error('Subscriptions has not been implemented');
};
+22
View File
@@ -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 { Logger } from '@polkadot/util/types';
import type { RpcCoder } from '../coder/json/types';
export type WsState$Awaiting = {
callback: (error: ?Error, result: mixed) => void
};
export type WsState = {
autoConnect: boolean,
coder: RpcCoder,
endpoint: string,
handlers: { [number]: WsState$Awaiting },
isConnected: boolean,
l: Logger,
queued: { [number]: string },
websocket: WebSocket
};
@@ -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
import type { WsState } from './types';
module.exports = async function unsubscribe (self: WsState, id: number): Promise<boolean> {
throw new Error('Subscriptions has not been implemented');
};