Skip to content

Commit

Permalink
retlOnMappingSave Hook (#1969)
Browse files Browse the repository at this point in the history
* Merge branch hooks/retlOnMappingSave

* Add unit tests

* Address comments
  • Loading branch information
maryamsharif authored Apr 5, 2024
1 parent 35def89 commit d3a85b1
Show file tree
Hide file tree
Showing 9 changed files with 377 additions and 155 deletions.
26 changes: 19 additions & 7 deletions packages/core/src/destination-kit/action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ type HookValueTypes = string | boolean | number | Array<string | boolean | numbe
type GenericActionHookValues = Record<string, HookValueTypes>

type GenericActionHookBundle = {
[K in ActionHookType]: {
[K in ActionHookType]?: {
inputs?: GenericActionHookValues
outputs?: GenericActionHookValues
}
Expand Down Expand Up @@ -85,17 +85,17 @@ export interface ActionDefinition<
* in the mapping for later use in the action.
*/
hooks?: {
[K in ActionHookType]: ActionHookDefinition<
[K in ActionHookType]?: ActionHookDefinition<
Settings,
Payload,
AudienceSettings,
GeneratedActionHookBundle[K]['outputs'],
GeneratedActionHookBundle[K]['inputs']
NonNullable<GeneratedActionHookBundle[K]>['outputs'],
NonNullable<GeneratedActionHookBundle[K]>['inputs']
>
}
}

export const hookTypeStrings = ['onMappingSave'] as const
export const hookTypeStrings = ['onMappingSave', 'retlOnMappingSave'] as const
/**
* The supported actions hooks.
* on-mapping-save: Called when a mapping is saved by the user. The return from this method is then stored in the mapping.
Expand Down Expand Up @@ -207,7 +207,7 @@ export class Action<Settings, Payload extends JSONLikeObject, AudienceSettings =
if (definition.hooks) {
for (const hookName in definition.hooks) {
const hook = definition.hooks[hookName as ActionHookType]
if (hook.inputFields) {
if (hook?.inputFields) {
if (!this.hookSchemas) {
this.hookSchemas = {}
}
Expand Down Expand Up @@ -313,6 +313,17 @@ export class Action<Settings, Payload extends JSONLikeObject, AudienceSettings =
.filter((payload) => validateSchema(payload, schema, validationOptions))
}

let hookOutputs = {}
if (this.definition.hooks) {
for (const hookType in this.definition.hooks) {
const hookOutputValues = bundle.mapping?.[hookType]

if (hookOutputValues) {
hookOutputs = { ...hookOutputs, [hookType]: hookOutputValues }
}
}
}

if (payloads.length === 0) {
return results
}
Expand All @@ -330,7 +341,8 @@ export class Action<Settings, Payload extends JSONLikeObject, AudienceSettings =
logger: bundle.logger,
dataFeedCache: bundle.dataFeedCache,
transactionContext: bundle.transactionContext,
stateContext: bundle.stateContext
stateContext: bundle.stateContext,
hookOutputs
}
const output = await this.performRequest(this.definition.performBatch, data)
results[0].data = output as JSONObject
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@ import { BULK_IMPORT_ENDPOINT } from '../../constants'
const testDestination = createTestIntegration(Destination)

const EXTERNAL_AUDIENCE_ID = '12345'
const API_ENDPOINT = 'https://marketo.com'
const API_ENDPOINT = 'https://123-ABC-456.mktorest.com'
const settings = {
client_id: '1234',
client_secret: '1234',
api_endpoint: 'https://marketo.com',
folder_name: 'Test Audience'
api_endpoint: API_ENDPOINT,
folder_name: 'Test Folder'
}

const event = createTestEvent({
Expand All @@ -28,6 +28,25 @@ const event = createTestEvent({
}
})

const audienceName = 'The Best Test Audience'
const listID = '1'

const hookInputNew = {
settings: settings,
hookInputs: {
list_name: audienceName
},
payload: {}
}

const hookInputExisting = {
settings: settings,
hookInputs: {
list_id: listID
},
payload: {}
}

describe('MarketoStaticLists.addToList', () => {
it('should succeed if response from Marketo is successful', async () => {
const bulkImport = API_ENDPOINT + BULK_IMPORT_ENDPOINT.replace('externalId', EXTERNAL_AUDIENCE_ID)
Expand Down Expand Up @@ -66,4 +85,95 @@ describe('MarketoStaticLists.addToList', () => {
})
).rejects.toThrow('Static list not found')
})

it('create a new list with hook', async () => {
nock(
`${API_ENDPOINT}/identity/oauth/token?grant_type=client_credentials&client_id=${settings.client_id}&client_secret=${settings.client_secret}`
)
.post(/.*/)
.reply(200, {
access_token: 'access_token'
})

nock(`${API_ENDPOINT}/rest/asset/v1/folder/byName.json?name=${encodeURIComponent(settings.folder_name)}`)
.get(/.*/)
.reply(200, {
success: true,
result: [
{
name: settings.folder_name,
id: listID
}
]
})

nock(`${API_ENDPOINT}/rest/asset/v1/staticLists.json?folder=12&name=${encodeURIComponent(audienceName)}`)
.post(/.*/)
.reply(200, {
success: true,
result: [
{
name: audienceName,
id: listID
}
]
})

const r = await testDestination.actions.addToList.executeHook('retlOnMappingSave', hookInputNew)

expect(r.savedData).toMatchObject({
id: listID,
name: audienceName
})
expect(r.successMessage).toMatchInlineSnapshot(`"List '${audienceName}' (id: ${listID}) created successfully!"`)
})

it('verify the existing list', async () => {
nock(
`${API_ENDPOINT}/identity/oauth/token?grant_type=client_credentials&client_id=${settings.client_id}&client_secret=${settings.client_secret}`
)
.post(/.*/)
.reply(200, {
access_token: 'access_token'
})
nock(`${API_ENDPOINT}/rest/asset/v1/staticList/${listID}.json`)
.get(/.*/)
.reply(200, {
success: true,
result: [
{
name: audienceName,
id: listID
}
]
})

const r = await testDestination.actions.addToList.executeHook('retlOnMappingSave', hookInputExisting)

expect(r.savedData).toMatchObject({
id: listID,
name: audienceName
})
expect(r.successMessage).toMatchInlineSnapshot(`"Using existing list '${audienceName}' (id: ${listID})"`)
})

it('fail if list id does not exist', async () => {
nock(
`${API_ENDPOINT}/identity/oauth/token?grant_type=client_credentials&client_id=${settings.client_id}&client_secret=${settings.client_secret}`
)
.post(/.*/)
.reply(200, {
access_token: 'access_token'
})
nock(`${API_ENDPOINT}/rest/asset/v1/staticList/782.json`)
.get(/.*/)
.reply(200, {
success: false,
errors: [{ code: 1013, message: 'Static list not found' }]
})

const r = await testDestination.actions.addToList.executeHook('retlOnMappingSave', hookInputExisting)

expect(r).toMatchObject({ error: { code: 'LIST_ID_VERIFICATION_FAILURE', message: 'Static list not found' } })
})
})

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { ActionDefinition } from '@segment/actions-core'
import type { Settings } from '../generated-types'
import type { Payload } from './generated-types'
import { external_id, lookup_field, data, enable_batching, batch_size, event_name } from '../properties'
import { addToList } from '../functions'
import { addToList, createList, getList } from '../functions'

const action: ActionDefinition<Settings, Payload> = {
title: 'Add to List',
Expand All @@ -16,13 +16,76 @@ const action: ActionDefinition<Settings, Payload> = {
batch_size: { ...batch_size },
event_name: { ...event_name }
},
perform: async (request, { settings, payload, statsContext }) => {
hooks: {
retlOnMappingSave: {
label: 'Connect to a static list in Marketo',
description: 'When saving this mapping, we will create a static list in Marketo using the fields you provided.',
inputFields: {
list_id: {
type: 'string',
label: 'Existing List ID',
description:
'The ID of the Marketo Static List that users will be synced to. If defined, we will not create a new list.',
required: false
},
list_name: {
type: 'string',
label: 'List Name',
description: 'The name of the Marketo Static List that you would like to create.',
required: false
}
},
outputTypes: {
id: {
type: 'string',
label: 'ID',
description: 'The ID of the created Marketo Static List that users will be synced to.',
required: false
},
name: {
type: 'string',
label: 'List Name',
description: 'The name of the created Marketo Static List that users will be synced to.',
required: false
}
},
performHook: async (request, { settings, hookInputs, statsContext }) => {
if (hookInputs.list_id) {
return getList(request, settings, hookInputs.list_id)
}

try {
const input = {
audienceName: hookInputs.list_name,
settings: settings
}
const listId = await createList(request, input, statsContext)

return {
successMessage: `List '${hookInputs.list_name}' (id: ${listId}) created successfully!`,
savedData: {
id: listId,
name: hookInputs.list_name
}
}
} catch (e) {
return {
error: {
message: 'Failed to create list',
code: 'CREATE_LIST_FAILURE'
}
}
}
}
}
},
perform: async (request, { settings, payload, statsContext, hookOutputs }) => {
statsContext?.statsClient?.incr('addToAudience', 1, statsContext?.tags)
return addToList(request, settings, [payload], statsContext)
return addToList(request, settings, [payload], statsContext, hookOutputs?.retlOnMappingSave?.outputs)
},
performBatch: async (request, { settings, payload, statsContext }) => {
performBatch: async (request, { settings, payload, statsContext, hookOutputs }) => {
statsContext?.statsClient?.incr('addToAudience.batch', 1, statsContext?.tags)
return addToList(request, settings, payload, statsContext)
return addToList(request, settings, payload, statsContext, hookOutputs?.retlOnMappingSave?.outputs)
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
import { RequestClient } from '@segment/actions-core'
import { Settings } from './generated-types'

const API_VERSION = 'v1'
const OAUTH_ENDPOINT = 'identity/oauth/token'
export const OAUTH_ENDPOINT = 'identity/oauth/token'
export const GET_FOLDER_ENDPOINT = `/rest/asset/${API_VERSION}/folder/byName.json?name=folderName`
export const CREATE_LIST_ENDPOINT = `/rest/asset/${API_VERSION}/staticLists.json?folder=folderId&name=listName`
export const GET_LIST_ENDPOINT = `/rest/asset/${API_VERSION}/staticList/listId.json`
Expand Down Expand Up @@ -67,15 +64,12 @@ export interface MarketoLeads {
createdAt: string
}

export async function getAccessToken(request: RequestClient, settings: Settings) {
const res = await request<RefreshTokenResponse>(`${settings.api_endpoint}/${OAUTH_ENDPOINT}`, {
method: 'POST',
body: new URLSearchParams({
client_id: settings.client_id,
client_secret: settings.client_secret,
grant_type: 'client_credentials'
})
})

return res.data.access_token
export interface CreateListInput {
audienceName: string
settings: {
client_id: string
client_secret: string
api_endpoint: string
folder_name: string
}
}
Loading

0 comments on commit d3a85b1

Please sign in to comment.