Skip to content

Commit

Permalink
chore: add communication client (#62)
Browse files Browse the repository at this point in the history
* chore: add communication client

* chore: allow to receive subject events
  • Loading branch information
ktsn authored Oct 7, 2018
1 parent 57f3f42 commit 1243180
Show file tree
Hide file tree
Showing 3 changed files with 366 additions and 4 deletions.
146 changes: 146 additions & 0 deletions src/view/communication/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import assert from 'assert'
import { Resolver, Mutator } from '@/infra/communication/types'

type ResolverModel<R extends Resolver<{}>> = R extends Resolver<infer T>
? T
: never

type Arguments<F extends Function> = F extends (...args: infer T) => any
? T
: never

export interface CommunicationClientObserver<T> {
onAdd?: (value: T) => void
onUpdate?: (value: T) => void
onRemove?: (value: T) => void
}

export interface CommunicationClientConfig {
ws: WebSocketClient
}

export interface WebSocketClient {
addEventListener(event: 'message', fn: (payload: string) => void): void
removeEventListener(event: 'message', fn: (payload: string) => void): void
send(payload: string): void
}

export class CommunicationClient<
R extends Resolver<{}>,
M extends Mutator,
T = ResolverModel<R>
> {
private ws: WebSocketClient
private nextRequestId = 1
private observers: Set<CommunicationClientObserver<T>> = new Set()

private onEvent = (payload: string) => {
const data = JSON.parse(payload)

assert(typeof data.type === 'string')
const [type, operation] = data.type.split(':')

if (type !== 'subject') {
return
}

assert('data' in data)

let getMethod: (
ob: CommunicationClientObserver<T>
) => ((value: T) => void) | undefined
switch (operation) {
case 'add':
getMethod = ob => ob.onAdd
break
case 'update':
getMethod = ob => ob.onUpdate
break
case 'remove':
getMethod = ob => ob.onRemove
break
default:
assert.fail('Unexpected type name: ' + data.type)
}

this.observers.forEach(ob => {
const f = getMethod(ob)
if (f) {
f(data.data)
}
})
}

constructor(config: CommunicationClientConfig) {
this.ws = config.ws

// Observe the events from subject
this.ws.addEventListener('message', this.onEvent)
}

resolve<K extends keyof R>(
key: K,
...args: Arguments<R[K]>
): Promise<ReturnType<R[K]>> {
return this.genericRequest('resolver', key, args, this.nextRequestId++)
}

mutate<K extends keyof M>(
key: K,
...args: Arguments<M[K]>
): Promise<ReturnType<M[K]>> {
return this.genericRequest('mutator', key, args, this.nextRequestId++)
}

observe(observer: CommunicationClientObserver<T>): () => void {
this.observers.add(observer)
return () => {
this.observers.delete(observer)
}
}

dispose(): void {
this.ws.removeEventListener('message', this.onEvent)
}

private genericRequest<
T extends Record<string, (...args: any[]) => any>,
K extends keyof T
>(
type: string,
key: K,
args: Arguments<T[K]>,
requestId: number
): Promise<ReturnType<T[K]>> {
return new Promise(resolve => {
const combinedType = type + ':' + key

const receive = (payload: string): void => {
const data = JSON.parse(payload)

assert(typeof data.type === 'string')
if (data.type !== combinedType) {
return
}

assert('data' in data)
assert('requestId' in data)

if (data.requestId === requestId) {
resolve(data.data)
this.ws.removeEventListener('message', receive)
}
}

this.ws.send(
JSON.stringify({
type: combinedType,
args,
requestId
})
)

this.ws.addEventListener('message', receive)
})
}
}
30 changes: 26 additions & 4 deletions tests/helpers/ws.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,38 @@
import { EventEmitter } from 'events'
import { WebSocket, WebSocketServer } from '@/infra/communication/connect'
import { WebSocketClient } from '@/view/communication/client'

export class MockWebSocketServer extends EventEmitter
implements WebSocketServer {
connectClient(): MockWebSocketClient {
const client = new MockWebSocketClient()
connectClient(): MockWebSocketClientForServer {
const client = new MockWebSocketClientForServer()
this.emit('connection', client.connection)
return client
}
}

class MockWebSocketClient {
export class MockWebSocketClient extends EventEmitter
implements WebSocketClient {
sent: any[] = []

send(payload: string): void {
this.sent.push(JSON.parse(payload))
}

receive(payload: any): void {
this.emit('message', JSON.stringify(payload))
}

addEventListener(event: 'message', cb: (payload: string) => void): void {
this.on(event, cb)
}

removeEventListener(event: 'message', cb: (payload: string) => void): void {
this.removeListener(event, cb)
}
}

class MockWebSocketClientForServer {
connection: MockWebSocket
received: any[] = []
private closed = false
Expand Down Expand Up @@ -43,7 +65,7 @@ class MockWebSocketClient {
}

class MockWebSocket extends EventEmitter implements WebSocket {
constructor(private client: MockWebSocketClient) {
constructor(private client: MockWebSocketClientForServer) {
super()
}

Expand Down
194 changes: 194 additions & 0 deletions tests/view/communication/client.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
import {
CommunicationClient,
CommunicationClientObserver
} from '@/view/communication/client'
import { MockWebSocketClient } from '../../helpers/ws'

describe('CommunicationClient', () => {
class Foo {
constructor(public id: number, public value: string) {}
}

const resolver = {
get(id: number): Foo | undefined {
return dummyData.find(d => d.id === id)
},

all(): Foo[] {
return dummyData
}
}

const mutator = {
update(id: number, value: string): Foo | undefined {
const f = dummyData.find(d => d.id === id)
if (!f) return
f.value = value
return f
}
}

let dummyData: Foo[]
let client: CommunicationClient<typeof resolver, typeof mutator>
let mockWs: MockWebSocketClient

beforeEach(() => {
dummyData = [new Foo(1, 'foo'), new Foo(2, 'bar'), new Foo(3, 'baz')]
mockWs = new MockWebSocketClient()
client = new CommunicationClient({
ws: mockWs
})
})

describe('resolver', () => {
it('fetches some data via resolve method', done => {
client.resolve('get', 2).then(res => {
expect(res).toEqual(dummyData[1])
done()
})

// Test request payload
expect(mockWs.sent.length).toBe(1)
const p = mockWs.sent[0]
expect(p.type).toBe('resolver:get')
expect(p.args).toEqual([2])

mockWs.receive({
type: 'resolver:get',
data: resolver.get(2),
requestId: p.requestId
})
})

it('does not react different request id', done => {
client.resolve('get', 2).then(res => {
expect(res).toEqual(dummyData[1])
done()
})

const p = mockWs.sent[0]

mockWs.receive({
type: 'resolver:get',
data: resolver.get(1),
requestId: 'dummy id'
})

mockWs.receive({
type: 'resolver:get',
data: resolver.get(2),
requestId: p.requestId
})
})
})

describe('mutator', () => {
it('updates remote data via mutate method', done => {
client.mutate('update', 2, 'updated').then(res => {
expect(res).toEqual({
id: 2,
value: 'updated'
})
done()
})

// Test request payload
expect(mockWs.sent.length).toBe(1)
const p = mockWs.sent[0]
expect(p.type).toBe('mutator:update')
expect(p.args).toEqual([2, 'updated'])

mockWs.receive({
type: 'mutator:update',
data: mutator.update(2, 'updated'),
requestId: p.requestId
})
})

it('does not react different request id', done => {
client.mutate('update', 2, 'updated').then(res => {
expect(res).toEqual({
id: 2,
value: 'updated'
})
done()
})

const p = mockWs.sent[0]

mockWs.receive({
type: 'mutator:update',
data: mutator.update(1, 'test'),
requestId: 'dummy id'
})

mockWs.receive({
type: 'mutator:update',
data: mutator.update(2, 'updated'),
requestId: p.requestId
})
})
})

describe('subject', () => {
let observer: Required<CommunicationClientObserver<Foo>>
let unobserve: () => void
beforeEach(() => {
observer = {
onAdd: jest.fn(),
onUpdate: jest.fn(),
onRemove: jest.fn()
}
unobserve = client.observe(observer)
})

afterEach(() => {
unobserve()
})

it('observes add event', () => {
const dummy = new Foo(4, 'new')

mockWs.receive({
type: 'subject:add',
data: dummy
})

expect(observer.onAdd).toHaveBeenCalledWith(dummy)
})

it('observes update event', () => {
const dummy = dummyData[1]

mockWs.receive({
type: 'subject:update',
data: dummy
})

expect(observer.onUpdate).toHaveBeenCalledWith(dummy)
})

it('observes remove event', () => {
const dummy = dummyData[0]

mockWs.receive({
type: 'subject:remove',
data: dummy
})

expect(observer.onRemove).toHaveBeenCalledWith(dummy)
})

it('never call observer after unsubscribed', () => {
const dummy = new Foo(4, 'new')
unobserve()

mockWs.receive({
type: 'subject:add',
data: dummy
})

expect(observer.onAdd).not.toHaveBeenCalled()
})
})
})

0 comments on commit 1243180

Please sign in to comment.