-
Notifications
You must be signed in to change notification settings - Fork 22
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
chore: add communication client (#62)
* chore: add communication client * chore: allow to receive subject events
- Loading branch information
Showing
3 changed files
with
366 additions
and
4 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
}) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() | ||
}) | ||
}) | ||
}) |