Skip to content

Commit

Permalink
New API for parsing nested fields (#502)
Browse files Browse the repository at this point in the history
* move NestedFieldToProcess in the our event transform pipeline

* poc for the new api for nested fields

* remove process, update parsedNested to avoid going deep unless necessary

* fix notation in realtime-api

* simplify access

* wip for adjusting the logic for parsing nested fields

* add ability to execute the top level transform

* add condition to avoid running on an infinite loop with parsing proxies

* add test for checking nested transforms

* add max depth

* add logic to dynamically detect the maxDepth

* minify json

* add listeners and emitter on test

* changeset

Co-authored-by: Edoardo Gallo <edo91.gallo@gmail.com>
  • Loading branch information
framini and edolix authored Apr 22, 2022
1 parent 05bb3c3 commit b36970a
Show file tree
Hide file tree
Showing 8 changed files with 214 additions and 100 deletions.
7 changes: 7 additions & 0 deletions .changeset/moody-walls-trade.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@signalwire/core': patch
'@signalwire/js': patch
'@signalwire/realtime-api': patch
---

[internal] Review parsing of nested fields
98 changes: 97 additions & 1 deletion packages/core/src/BaseComponent.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { BaseComponent } from './BaseComponent'
import { configureJestStore } from './testUtils'
import { EventEmitter } from './utils/EventEmitter'
import { toLocalEvent } from './utils'
import { toExternalJSON, toLocalEvent } from './utils'
import { EventTransform } from './utils/interfaces'

describe('BaseComponent', () => {
describe('as an event emitter', () => {
Expand Down Expand Up @@ -230,6 +231,101 @@ describe('BaseComponent', () => {
})
})

it('should properly apply nested transforms', () => {
const instanceFactoryMock0 = jest.fn((_payload: any) => {
return {
__jestKey: 'mock0',
}
})
const instanceFactoryMock1 = jest.fn((_payload: any) => {
return {
__jestKey: 'mock1',
}
})
const instanceFactoryMock2 = jest.fn((_payload: any) => {
return {
__jestKey: 'mock2',
}
})
class CustomComponent extends JestComponent {
protected getEmitterTransforms() {
return new Map<string | string[], EventTransform>([
[
['video.jest.withNestedTransforms'],
{
type: 'roomSession',
instanceFactory: instanceFactoryMock0,
payloadTransform: (payload: any) => {
return toExternalJSON({
...payload.room_session,
})
},
nestedFieldsToProcess: {
recordings: {
eventTransformType: 'roomSessionRecording',
processInstancePayload: (payload) => ({
recording: payload,
}),
},
members: {
eventTransformType: 'roomSessionMember',
processInstancePayload: (payload) => ({ member: payload }),
},
},
},
],
[
['video.jest.unusedEventOne'],
{
type: 'roomSessionRecording',
instanceFactory: instanceFactoryMock1,
payloadTransform: (payload: any) => {
return toExternalJSON({
...payload.recording,
})
},
},
],
[
['video.jest.unusedEventTwo'],
{
type: 'roomSessionMember',
instanceFactory: instanceFactoryMock2,
payloadTransform: (payload: any) => {
return toExternalJSON({
...payload.member,
})
},
},
],
])
}
}

const instance = new CustomComponent()
// prettier-ignore
const payload = {"call_id":"28a20c71-43d4-443f-85d6-8ce3199b192b","member_id":"28a20c71-43d4-443f-85d6-8ce3199b192b","room_session":{"room_id":"b5bc21f7-ccd1-41c8-9c72-db4d16783048","id":"2aeca1a3-c22d-4a29-b422-cf562bcc6853","event_channel":"EC_78b616ce-214f-427f-bf82-af4c0bfffadc","name":"testing-positions","recording":true,"hide_video_muted":false,"layout_name":"grid-responsive","display_name":"testing-positions","meta":{},"recordings":[{"id":"9580c366-f438-42ac-ab94-c6bbeca29d51","state":"recording","duration":null,"started_at":1650449237.568,"ended_at":null}],"members":[{"id":"ca9976e9-4caa-4bb4-8be1-b8e66ada641a","room_id":"b5bc21f7-ccd1-41c8-9c72-db4d16783048","room_session_id":"2aeca1a3-c22d-4a29-b422-cf562bcc6853","name":"fran","type":"member","parent_id":"","requested_position":"auto","visible":false,"audio_muted":false,"video_muted":true,"deaf":false,"input_volume":0,"output_volume":0,"input_sensitivity":11.11111111111111,"meta":null},{"id":"28a20c71-43d4-443f-85d6-8ce3199b192b","room_id":"b5bc21f7-ccd1-41c8-9c72-db4d16783048","room_session_id":"2aeca1a3-c22d-4a29-b422-cf562bcc6853","name":"fran","type":"member","parent_id":"","requested_position":"auto","visible":false,"audio_muted":false,"video_muted":false,"deaf":false,"input_volume":0,"output_volume":0,"input_sensitivity":11.11111111111111,"meta":null}]},"room":{"room_id":"b5bc21f7-ccd1-41c8-9c72-db4d16783048","event_channel":"EC_78b616ce-214f-427f-bf82-af4c0bfffadc","name":"testing-positions","recording":true,"hide_video_muted":false,"layout_name":"grid-responsive","display_name":"testing-positions","meta":{},"recordings":[{"id":"9580c366-f438-42ac-ab94-c6bbeca29d51","state":"recording","duration":null,"started_at":1650449237.568,"ended_at":null}],"members":[{"id":"ca9976e9-4caa-4bb4-8be1-b8e66ada641a","room_id":"b5bc21f7-ccd1-41c8-9c72-db4d16783048","room_session_id":"2aeca1a3-c22d-4a29-b422-cf562bcc6853","name":"fran","type":"member","parent_id":"","requested_position":"auto","visible":false,"audio_muted":false,"video_muted":true,"deaf":false,"input_volume":0,"output_volume":0,"input_sensitivity":11.11111111111111,"meta":null},{"id":"28a20c71-43d4-443f-85d6-8ce3199b192b","room_id":"b5bc21f7-ccd1-41c8-9c72-db4d16783048","room_session_id":"2aeca1a3-c22d-4a29-b422-cf562bcc6853","name":"fran","type":"member","parent_id":"","requested_position":"auto","visible":false,"audio_muted":false,"video_muted":false,"deaf":false,"input_volume":0,"output_volume":0,"input_sensitivity":11.11111111111111,"meta":null}],"room_session_id":"2aeca1a3-c22d-4a29-b422-cf562bcc6853"}}

instance.on('jest.withNestedTransforms', () => {})
instance.on('jest.withNestedTransforms', () => {})
instance.on('jest.withNestedTransforms', () => {})

instance.on('jest.withNestedTransforms', (obj: any) => {
expect(instanceFactoryMock0).toHaveBeenCalledTimes(1)
expect(instanceFactoryMock1).toHaveBeenCalledTimes(1)
expect(instanceFactoryMock2).toHaveBeenCalledTimes(1)

expect(obj).toHaveProperty('__jestKey', 'mock0')
expect(obj.recordings[0]).toHaveProperty('__jestKey', 'mock1')
expect(obj.members[0]).toHaveProperty('__jestKey', 'mock2')
})

// @ts-expect-error
instance.applyEmitterTransforms()
instance.emit('jest.withNestedTransforms', payload)
instance.emit('jest.withNestedTransforms', payload)
})

it('should properly apply local and remote emitter transforms when needed', () => {
const mockInstanceFactoryRegistered = jest.fn(() => ({}))
const mockPayloadTransformRegistered = jest.fn()
Expand Down
116 changes: 87 additions & 29 deletions packages/core/src/BaseComponent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -401,10 +401,7 @@ export class BaseComponent<
payload,
})

const transformedPayload = this._parseNestedFields({
transform,
payload,
})
const transformedPayload = this._parseNestedFields(payload, transform)

const proxiedObj = proxyFactory({
instance: cachedInstance,
Expand All @@ -422,32 +419,93 @@ export class BaseComponent<
>
}

private _parseNestedFields({
transform,
payload,
}: {
transform: EventTransform
payload: unknown
}) {
let transformedPayload = transform.payloadTransform(payload)
const fieldsToProcess = transform?.nestedFieldsToProcess?.() ?? []

fieldsToProcess.forEach(
({ process, processInstancePayload, eventTransformType }) => {
const transformToUse = this._emitterTransforms.get(eventTransformType)
if (!transformToUse) {
return
}
const instanceFactory = (jsonPayload: any) => {
return instanceProxyFactory({
transform: transformToUse,
payload: processInstancePayload(jsonPayload),
})
private _parseNestedFields(
obj: unknown,
transform: EventTransform,
process = (p: any) => p,
result: any = undefined,
// `level` keeps track of how deep we are in the
// processing line.
level = 0,
// max depth level we'll get into. this value could
// change depending on how soon we detect the first
// Proxy.
maxDepth = 5
): any {
if (!transform.nestedFieldsToProcess) {
return transform.payloadTransform(obj)
}

// It's non trivial to detect when the end of the line
// is since we can be dealing with multiple levels of
// nested Proxies at a time.
// 4 = Max Depth
if (level > maxDepth) {
return result
// @ts-expect-error
} else if (obj.__sw_proxy) {
maxDepth = level
}

// First time we ran this util we'll apply the top level
// transform
if (!result) {
const r = transform.payloadTransform(obj)
return this._parseNestedFields(r, transform, process, r, level + 1, maxDepth)
}

if (Array.isArray(obj)) {
result = obj.map((item: any, index: number) => {
return this._parseNestedFields(
process(item),
transform,
process,
// At this point we don't have a key so we can't
// reference a transform. This process comes from
// a previous iteration (since we don't support
// top level arrays)
obj[index],
level + 1,
maxDepth
)
})
} else if (obj && typeof obj === 'object') {
Object.entries(obj).forEach(([key, value]) => {
const nestedTransform = transform.nestedFieldsToProcess?.[key]
const transformToUse = nestedTransform
? this._emitterTransforms.get(nestedTransform.eventTransformType)
: undefined

if (value && typeof value === 'object') {
result[key] = this._parseNestedFields(
value,
transform,
(p) => {
if (
nestedTransform &&
transformToUse &&
p &&
typeof p === 'object'
) {
return instanceProxyFactory({
transform: transformToUse,
payload: process(nestedTransform.processInstancePayload(p)),
})
}

return p
},
result[key],
level + 1,
maxDepth
)
} else {
result[key] = process(value)
}
transformedPayload = process(transformedPayload, instanceFactory)
}
)
return transformedPayload
})
}

return result
}

private getOrCreateStableEventHandler(
Expand Down
11 changes: 1 addition & 10 deletions packages/core/src/utils/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -344,15 +344,6 @@ export type EventTransformType =
| MessagingTransformType

export interface NestedFieldToProcess {
/**
* It's responsible for digging into the `transformedPayload` and mutating it with
* the correct logic and using the `instanceFactory` function to create child objects
* for the nested fields. For example: members/recordings/playbacks.
*/
process: (
transformedPayload: any,
instanceFactory: (payload: any) => any
) => any
/**
* Allow us to update the nested `payload` to match the shape we already
* treat consuming other events from the server.
Expand Down Expand Up @@ -431,7 +422,7 @@ export interface EventTransform {
* This allow us to target the fields and apply transform those
* into stateless object following our EventTranform pattern.
*/
nestedFieldsToProcess?: () => NestedFieldToProcess[]
nestedFieldsToProcess?: Record<string, NestedFieldToProcess>
/**
* Allow us to define what property to use to namespace
* our events (_eventsNamespace).
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/utils/proxyUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,10 @@ export const proxyFactory = ({
}: ProxyFactoryOptions) => {
const proxiedObj = new Proxy(instance, {
get(target: any, prop: any, receiver: any) {
if (prop === '__sw_proxy') {
return true
}

if (prop === 'toString') {
return proxyToString({
property: target[prop],
Expand Down
6 changes: 0 additions & 6 deletions packages/core/src/utils/toExternalJSON.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,12 +64,6 @@ export const toExternalJSON = <T>(
input: T,
options: typeof DEFAULT_OPTIONS = DEFAULT_OPTIONS
) => {
// @ts-expect-error
if (input?.__sw_symbol) {
// Return if the input is a BaseComponent
return input as unknown as ToExternalJSONResult<T>
}

return Object.entries(input).reduce((reducer, [key, value]) => {
const prop = fromSnakeToCamelCase(key) as any
const propType = typeof value
Expand Down
38 changes: 9 additions & 29 deletions packages/js/src/BaseRoomSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,35 +83,15 @@ export class RoomSessionConnection
payloadTransform: (payload: VideoRoomSubscribedEventParams) => {
return payload
},
nestedFieldsToProcess: () => {
return [
{
eventTransformType: 'roomSessionPlayback',
process: (transformedPayload, instanceFactory) => {
if (transformedPayload.room_session?.playbacks?.length) {
transformedPayload.room_session.playbacks =
transformedPayload.room_session.playbacks.map(
instanceFactory
)
}
return transformedPayload
},
processInstancePayload: (payload) => ({ member: payload }),
},
{
eventTransformType: 'roomSessionRecording',
process: (transformedPayload, instanceFactory) => {
if (transformedPayload.room_session?.recordings?.length) {
transformedPayload.room_session.recordings =
transformedPayload.room_session.recordings.map(
instanceFactory
)
}
return transformedPayload
},
processInstancePayload: (payload) => ({ recording: payload }),
},
]
nestedFieldsToProcess: {
recordings: {
eventTransformType: 'roomSessionRecording',
processInstancePayload: (payload) => ({ recording: payload }),
},
playbacks: {
eventTransformType: 'roomSessionPlayback',
processInstancePayload: (payload) => ({ member: payload }),
},
},
},
],
Expand Down
34 changes: 9 additions & 25 deletions packages/realtime-api/src/video/RoomSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -689,31 +689,15 @@ class RoomSessionConsumer extends BaseConsumer<RealTimeRoomApiEvents> {
payloadTransform: (payload: VideoRoomSubscribedEventParams) => {
return toExternalJSON(payload.room_session)
},
nestedFieldsToProcess: () => {
return [
{
process: (transformedPayload, instanceFactory) => {
if (transformedPayload?.members?.length) {
transformedPayload.members =
transformedPayload.members.map(instanceFactory)
}
return transformedPayload
},
processInstancePayload: (payload) => ({ member: payload }),
eventTransformType: 'roomSessionMember',
},
{
process: (transformedPayload, instanceFactory) => {
if (transformedPayload?.recordings?.length) {
transformedPayload.recordings =
transformedPayload.recordings.map(instanceFactory)
}
return transformedPayload
},
processInstancePayload: (payload) => ({ recording: payload }),
eventTransformType: 'roomSessionRecording',
},
]
nestedFieldsToProcess: {
members: {
eventTransformType: 'roomSessionMember',
processInstancePayload: (payload) => ({ member: payload }),
},
recordings: {
eventTransformType: 'roomSessionRecording',
processInstancePayload: (payload) => ({ recording: payload }),
},
},
getInstanceEventNamespace: (
payload: VideoRoomSubscribedEventParams
Expand Down

0 comments on commit b36970a

Please sign in to comment.