Skip to content

Commit

Permalink
Improve flexibility of dynamic objects (segmentio#2122)
Browse files Browse the repository at this point in the history
* support for more complex dynamic objects

* tests

* minor cleanup
  • Loading branch information
pooyaj authored Jul 2, 2024
1 parent b6a76dc commit 76e2f52
Show file tree
Hide file tree
Showing 3 changed files with 115 additions and 16 deletions.
60 changes: 56 additions & 4 deletions packages/core/src/__tests__/destination-kit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,21 @@ const destinationWithDynamicFields: DestinationDefinition<JSONObject> = {
dynamic: true
}
}
},
testObjectArrays: {
label: 'Structured Array of Object',
description: 'A structured array of object',
type: 'object',
multiple: true,
properties: {
testDynamicSubfield: {
label: 'Test Field',
description: 'A test field',
type: 'string',
required: true,
dynamic: true
}
}
}
},
dynamicFields: {
Expand All @@ -223,9 +238,11 @@ const destinationWithDynamicFields: DestinationDefinition<JSONObject> = {
nextPage: ''
}
},
__values__: async () => {
__values__: async (_, input) => {
const { dynamicFieldContext } = input

return {
choices: [{ label: 'Im a value', value: '2️⃣' }],
choices: [{ label: `Im a value for ${dynamicFieldContext?.selectedKey}`, value: '2️⃣' }],
nextPage: ''
}
}
Expand All @@ -237,6 +254,18 @@ const destinationWithDynamicFields: DestinationDefinition<JSONObject> = {
nextPage: ''
}
}
},
testObjectArrays: {
testDynamicSubfield: async (_, input) => {
const { dynamicFieldContext } = input

return {
choices: [
{ label: `Im a subfield for element ${dynamicFieldContext?.selectedArrayIndex}`, value: 'nah' }
],
nextPage: ''
}
}
}
},
perform: (_request, { syncMode }) => {
Expand Down Expand Up @@ -872,11 +901,19 @@ describe('destination kit', () => {

test('fetches values for unstructured objects', async () => {
const destinationTest = new Destination(destinationWithDynamicFields)
const res = await destinationTest.executeDynamicField('customEvent', 'testUnstructuredObject.__values__', {
;('testUnstructuredObject.__values__')
let res = await destinationTest.executeDynamicField('customEvent', 'testUnstructuredObject.keyOne', {
settings: {},
payload: {}
})
expect(res).toEqual({ choices: [{ label: 'Im a value for keyOne', value: '2️⃣' }], nextPage: '' })

res = await destinationTest.executeDynamicField('customEvent', 'testUnstructuredObject.keyTwo', {
settings: {},
payload: {}
})
expect(res).toEqual({ choices: [{ label: 'Im a value', value: '2️⃣' }], nextPage: '' })

expect(res).toEqual({ choices: [{ label: 'Im a value for keyTwo', value: '2️⃣' }], nextPage: '' })
})

test('fetches values for structured object subfields', async () => {
Expand All @@ -888,6 +925,21 @@ describe('destination kit', () => {
expect(res).toEqual({ choices: [{ label: 'Im a subfield', value: 'nah' }], nextPage: '' })
})

test('fetches values for structured array of object', async () => {
const destinationTest = new Destination(destinationWithDynamicFields)
let res = await destinationTest.executeDynamicField('customEvent', 'testObjectArrays.[0].testDynamicSubfield', {
settings: {},
payload: {}
})
expect(res).toEqual({ choices: [{ label: 'Im a subfield for element 0', value: 'nah' }], nextPage: '' })

res = await destinationTest.executeDynamicField('customEvent', 'testObjectArrays.[113].testDynamicSubfield', {
settings: {},
payload: {}
})
expect(res).toEqual({ choices: [{ label: 'Im a subfield for element 113', value: 'nah' }], nextPage: '' })
})

test('returns 404 for invalid subfields', async () => {
const destinationTest = new Destination(destinationWithDynamicFields)
const res = await destinationTest.executeDynamicField('customEvent', 'testStructuredObject.ghostSubfield', {
Expand Down
62 changes: 50 additions & 12 deletions packages/core/src/destination-kit/action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ import type {
ExecuteInput,
Result,
SyncMode,
SyncModeDefinition
SyncModeDefinition,
DynamicFieldContext
} from './types'
import { syncModeTypes } from './types'
import { NormalizedOptions } from '../request-client'
Expand Down Expand Up @@ -81,16 +82,16 @@ export interface ActionDefinition<
* This is likely going to change as we productionalize the data model and definition object
*/
dynamicFields?: {
[K in keyof Payload]?:
| RequestFn<Settings, Payload, DynamicFieldResponse, AudienceSettings>
| {
[K in keyof Payload]?: Payload[K] extends object
? {
[ObjectProperty in keyof Payload[K] | '__keys__' | '__values__']?: RequestFn<
Settings,
Payload[K],
DynamicFieldResponse,
AudienceSettings
>
}
: RequestFn<Settings, Payload, DynamicFieldResponse, AudienceSettings>
}

/** The operation to perform when this action is triggered */
Expand Down Expand Up @@ -175,6 +176,7 @@ export interface ExecuteDynamicFieldInput<Settings, Payload, AudienceSettings =
features?: Features | undefined
statsContext?: StatsContext | undefined
hookInputs?: GenericActionHookValues
dynamicFieldContext?: DynamicFieldContext
}

interface ExecuteBundle<T = unknown, Data = unknown, AudienceSettings = any, ActionHookValues = any> {
Expand Down Expand Up @@ -403,6 +405,41 @@ export class Action<Settings, Payload extends JSONLikeObject, AudienceSettings =
return results
}

/*
* Extract the dynamic field context and handler path from a field string. Examples:
* - "structured.first_name" => { dynamicHandlerPath: "structured.first_name" }
* - "unstructuredObject.testProperty" => { dynamicHandlerPath: "unstructuredObject.__values__", dynamicFieldContext: { selectedKey: "testProperty" } }
* - "structuredArray.[0].first_name" => { dynamicHandlerPath: "structuredArray.first_name", dynamicFieldContext: { selectedArrayIndex: 0 } }
*/
private extractFieldContextAndHandler(field: string): {
dynamicHandlerPath: string
dynamicFieldContext?: DynamicFieldContext
} {
const arrayRegex = /(.*)\.\[(\d+)\]\.(.*)/
const objectRegex = /(.*)\.(.*)/
let dynamicHandlerPath = field
let dynamicFieldContext: DynamicFieldContext | undefined

const match = arrayRegex.exec(field) || objectRegex.exec(field)
if (match) {
const [, parent, indexOrChild, child] = match
if (child) {
// It is an array, so we need to extract the index from parent.[index].child and call paret.child handler
dynamicFieldContext = { selectedArrayIndex: parseInt(indexOrChild, 10) }
dynamicHandlerPath = `${parent}.${child}`
} else {
// It is an object, if there is a dedicated fetcher for child we use it otherwise we use parent.__values__
const parentFetcher = this.definition.dynamicFields?.[parent]
if (parentFetcher && !(indexOrChild in parentFetcher)) {
dynamicHandlerPath = `${parent}.__values__`
dynamicFieldContext = { selectedKey: indexOrChild }
}
}
}

return { dynamicHandlerPath, dynamicFieldContext }
}

async executeDynamicField(
field: string,
data: ExecuteDynamicFieldInput<Settings, Payload, AudienceSettings>,
Expand All @@ -411,16 +448,17 @@ export class Action<Settings, Payload extends JSONLikeObject, AudienceSettings =
*/
dynamicFn?: RequestFn<Settings, Payload, DynamicFieldResponse, AudienceSettings>
): Promise<DynamicFieldResponse> {
let fn
if (dynamicFn && typeof dynamicFn === 'function') {
fn = dynamicFn
} else {
fn = get<RequestFn<Settings, Payload, DynamicFieldResponse, AudienceSettings>>(
this.definition.dynamicFields,
field
)
return (await this.performRequest(dynamicFn, { ...data })) as DynamicFieldResponse
}

const { dynamicHandlerPath, dynamicFieldContext } = this.extractFieldContextAndHandler(field)

const fn = get<RequestFn<Settings, Payload, DynamicFieldResponse, AudienceSettings>>(
this.definition.dynamicFields,
dynamicHandlerPath
)

if (typeof fn !== 'function') {
return Promise.resolve({
choices: [],
Expand All @@ -433,7 +471,7 @@ export class Action<Settings, Payload extends JSONLikeObject, AudienceSettings =
}

// fn will always be a dynamic field function, so we can safely cast it to DynamicFieldResponse
return (await this.performRequest(fn, data)) as DynamicFieldResponse
return (await this.performRequest(fn, { ...data, dynamicFieldContext })) as DynamicFieldResponse
}

async executeHook(
Expand Down
9 changes: 9 additions & 0 deletions packages/core/src/destination-kit/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@ export interface Result {
data?: JSONObject | null
}

export interface DynamicFieldContext {
/** The index of the item in an array of objects that we are requesting data for */
selectedArrayIndex?: number
/** The key within a dynamic object for which we are requesting values */
selectedKey?: string
}

export interface ExecuteInput<
Settings,
Payload,
Expand All @@ -35,6 +42,8 @@ export interface ExecuteInput<
hookInputs?: ActionHookInputs
/** Stored outputs from an invokation of an actions hook */
hookOutputs?: Partial<Record<ActionHookType, ActionHookOutputs>>
/** Context about dynamic fields */
dynamicFieldContext?: DynamicFieldContext
/** The page used in dynamic field requests */
page?: string
/** The subscription sync mode */
Expand Down

0 comments on commit 76e2f52

Please sign in to comment.