Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: onboard launchdarkly audience #2529

Merged
merged 20 commits into from
Sep 13, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions src/cdk/v2/destinations/launchdarkly_audience/config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
const SUPPORTED_EVENT_TYPE = 'audiencelist';
const ACTION_TYPES = ['add', 'remove'];
const IDENTIFIER_KEY = 'identifier';
// ref:- https://docs.launchdarkly.com/guides/integrations/build-synced-segments?q=othercapabilities#manifest-details
// ref:- https://docs.launchdarkly.com/home/segments#targeting-users-in-segments
const ENDPOINT = 'https://app.launchdarkly.com/api/v2/segment-targets/rudderstack';
const MAX_IDENTIFIERS = 1000;

module.exports = {
SUPPORTED_EVENT_TYPE,
ACTION_TYPES,
IDENTIFIER_KEY,
ENDPOINT,
MAX_IDENTIFIERS,
};
67 changes: 67 additions & 0 deletions src/cdk/v2/destinations/launchdarkly_audience/procWorkflow.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
bindings:
- name: EventType
path: ../../../../constants
- path: ../../bindings/jsontemplate
exportAll: true
- name: defaultRequestConfig
path: ../../../../v0/util
- name: removeUndefinedNullEmptyExclBoolInt
path: ../../../../v0/util
- path: ./config
exportAll: true
- path: ./utils
exportAll: true

steps:
- name: validateInput
template: |
let messageType = .message.type;
$.assertConfig(.destination.Config.audienceId, "Audience Id is not present. Aborting");
$.assertConfig(.destination.Config.audienceName, "Audience Name is not present. Aborting");
$.assertConfig(.destination.Config.accessToken, "Access Token is not present. Aborting");
$.assertConfig(.destination.Config.clientSideId, "Launch Darkly Client Side is not present. Aborting");
$.assert(.message.type, "Message Type is not present. Aborting message.");
$.assert(.message.type.toLowerCase() === $.SUPPORTED_EVENT_TYPE, "Event type " + .message.type.toLowerCase() + " is not supported. Aborting message.");
$.assert(.message.properties, "Message properties is not present. Aborting message.");
$.assert(.message.properties.listData, "`listData` is not present inside properties. Aborting message.");
$.assert($.containsAll(Object.keys(.message.properties.listData), $.ACTION_TYPES), "Unsupported action type. Aborting message.")
$.assert(Object.keys(.message.properties.listData).length > 0, "`listData` is empty. Aborting message.")

- name: batchIdentifiersList
description: batch identifiers list
template: |
const batchedList = $.batchIdentifiersList(.message.properties.listData);
$.assert(batchedList.length > 0, "`listData` is empty. Aborting message.");
batchedList;

- name: prepareBasePayload
template: |
const payload = {
environmentId: .destination.Config.clientSideId,
cohortId: .destination.Config.audienceId,
cohortName: .destination.Config.audienceName,
contextKind: .destination.Config.audienceType
};
$.removeUndefinedNullEmptyExclBoolInt(payload);

- name: buildResponseForProcessTransformation
description: build multiplexed response depending upon batch size
template: |
$.outputs.batchIdentifiersList.().({
"body": {
"JSON": {...$.outputs.prepareBasePayload, listData: .},
"JSON_ARRAY": {},
"XML": {},
"FORM": {}
},
"version": "1",
"type": "REST",
"method": "POST",
"endpoint": {{$.ENDPOINT}},
"headers": {
"Authorization": ^.destination.Config.accessToken,
"Content-Type": "application/json"
},
"params": {},
"files": {}
})[]
87 changes: 87 additions & 0 deletions src/cdk/v2/destinations/launchdarkly_audience/utils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
const _ = require('lodash');
const { ACTION_TYPES, IDENTIFIER_KEY, MAX_IDENTIFIERS } = require('./config');

/**
* Prepares a list of identifiers based on the provided data and identifier key.
* @param {*} listData The data containing lists of members to be added or removed from the audience.
* @returns
* {
"add": [
{
"id": "[email protected]"
}
],
"remove": []
}
*/
const prepareIdentifiersList = (listData) => {
const list = {};
const processList = (actionData) =>
actionData
.filter((member) => member.hasOwnProperty(IDENTIFIER_KEY) && member[IDENTIFIER_KEY])
.map((member) => ({ id: member[IDENTIFIER_KEY] }));
krishna2020 marked this conversation as resolved.
Show resolved Hide resolved
ACTION_TYPES.forEach((action) => {
list[action] = listData?.[action] ? processList(listData[action]) : [];
});
return list;
};

/**
* Batch the identifiers list based on the maximum number of identifiers allowed per request.
* @param {*} listData The data containing lists of members to be added or removed from the audience.
* @returns
* For MAX_IDENTIFIERS = 2
* [
{
"add": [
{
"id": "[email protected]"
}
],
"remove": [
{
"id": "[email protected]"
}
]
},
{
"add": [
{
"id": "[email protected]"
}
],
"remove": []
}
]
*/
const batchIdentifiersList = (listData) => {
const audienceList = prepareIdentifiersList(listData);
const combinedList = [
...audienceList.add.map((item) => ({ ...item, type: 'add' })),
...audienceList.remove.map((item) => ({ ...item, type: 'remove' })),
];

const chunkedData = _.chunk(combinedList, MAX_IDENTIFIERS);

// Group the chunks by action type (add/remove)
const groupedData = chunkedData.map((chunk) => {
const groupedChunk = {
add: [],
remove: [],
};

chunk.forEach((item) => {
if (item.type === 'add') {
groupedChunk.add.push({ id: item.id });
} else if (item.type === 'remove') {
groupedChunk.remove.push({ id: item.id });
}
});

return groupedChunk;
});

return groupedData;
};

module.exports = { prepareIdentifiersList, batchIdentifiersList };
136 changes: 136 additions & 0 deletions src/cdk/v2/destinations/launchdarkly_audience/utils.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
const { prepareIdentifiersList, batchIdentifiersList } = require('./utils');

jest.mock(`./config`, () => {
const originalConfig = jest.requireActual(`./config`);
return {
...originalConfig,
MAX_IDENTIFIERS: 2,
};
});

describe('Unit test cases for prepareIdentifiersList', () => {
it('should return an object with empty "add" and "remove" properties when no data is provided', () => {
const result = prepareIdentifiersList({});
expect(result).toEqual({ add: [], remove: [] });
});

it('should handle null input and return an object with empty "add" and "remove" identifiers list', () => {
const result = prepareIdentifiersList(null);
expect(result).toEqual({ add: [], remove: [] });
});

it('should handle undefined input and return an object with empty "add" and "remove" identifiers list', () => {
const result = prepareIdentifiersList(undefined);
expect(result).toEqual({ add: [], remove: [] });
});

it('should handle input with missing "add" or "remove" identifiers list and return an object with empty "add" and "remove" identifiers list', () => {
const result = prepareIdentifiersList({ add: [], remove: undefined });
expect(result).toEqual({ add: [], remove: [] });
});

it('should handle input with empty "add" or "remove" identifiers list and return an object with empty "add" and "remove" identifiers list', () => {
const result = prepareIdentifiersList({ add: [], remove: [] });
expect(result).toEqual({ add: [], remove: [] });
});

it('should handle input with non empty "add" or "remove" identifiers list and return an object non empty "add" and "remove" identifiers list', () => {
const result = prepareIdentifiersList({
add: [{ identifier: '[email protected]' }, { identifier: '[email protected]' }],
remove: [{ identifier: '[email protected]' }],
});
expect(result).toEqual({
add: [{ id: '[email protected]' }, { id: '[email protected]' }],
remove: [{ id: '[email protected]' }],
});
});
});

describe('Unit test cases for batchIdentifiersList', () => {
it('should correctly batch a list containing both "add" and "remove" actions', () => {
const listData = {
add: [
{ identifier: '[email protected]' },
{ identifier: '[email protected]' },
{ identifier: '[email protected]' },
],
remove: [{ identifier: '[email protected]' }, { identifier: '[email protected]' }],
};

const expectedOutput = [
{
add: [{ id: '[email protected]' }, { id: '[email protected]' }],
remove: [],
},
{
add: [{ id: '[email protected]' }],
remove: [{ id: '[email protected]' }],
},
{
add: [],
remove: [{ id: '[email protected]' }],
},
];

expect(batchIdentifiersList(listData)).toEqual(expectedOutput);
});

it('should correctly batch a list containing only "add" actions', () => {
const listData = {
add: [
{ identifier: '[email protected]' },
{ identifier: '[email protected]' },
{ identifier: '[email protected]' },
],
remove: [],
};

const expectedOutput = [
{
add: [{ id: '[email protected]' }, { id: '[email protected]' }],
remove: [],
},
{
add: [{ id: '[email protected]' }],
remove: [],
},
];

expect(batchIdentifiersList(listData)).toEqual(expectedOutput);
});

it('should correctly batch a list containing only "remove" actions', () => {
const listData = {
add: [],
remove: [
{ identifier: '[email protected]' },
{ identifier: '[email protected]' },
{ identifier: '[email protected]' },
],
};

const expectedOutput = [
{
add: [],
remove: [{ id: '[email protected]' }, { id: '[email protected]' }],
},
{
add: [],
remove: [{ id: '[email protected]' }],
},
];

expect(batchIdentifiersList(listData)).toEqual(expectedOutput);
});

it('should return an empty list for empty input list data', () => {
const listData = {
add: [{ identifier: '' }],
remove: [],
};

const expectedOutput = [];

expect(batchIdentifiersList(listData)).toEqual(expectedOutput);
});
});
Loading