Skip to content

Commit

Permalink
refactor: adjust webhook v2 (#4079)
Browse files Browse the repository at this point in the history
  • Loading branch information
vinayteki95 authored Feb 17, 2025
2 parents 711e18b + c200518 commit 77bcd2f
Show file tree
Hide file tree
Showing 11 changed files with 367 additions and 177 deletions.
7 changes: 1 addition & 6 deletions src/controllers/util/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import { getValueFromMessage } from '../../v0/util';
import genericFieldMap from '../../v0/util/data/GenericFieldMapping.json';
import { EventType, MappedToDestinationKey } from '../../constants';
import { versionConversionFactory } from './versionConversion';
import defaultFeaturesConfig from '../../features';

export class ControllerUtility {
private static sourceVersionMap: Map<string, string> = new Map();
Expand All @@ -42,11 +41,7 @@ export class ControllerUtility {
return this.sourceVersionMap;
}

const versions = ['v0', 'v1'];
if (defaultFeaturesConfig.upgradedToSourceTransformV2) {
// this makes it easy to revert to v0,v1 spec if something doesn't work out using ENV variables
versions.push('v2');
}
const versions = ['v0', 'v1', 'v2'];

versions.forEach((version) => {
const files = fs.readdirSync(this.getSourceDirPath(version), {
Expand Down
File renamed without changes.
38 changes: 38 additions & 0 deletions src/sources/adjust/core.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
const path = require('path');
const fs = require('fs');
const Message = require('../../v0/sources/message');
const { excludedFieldList } = require('./config');
const { extractCustomFields, generateUUID } = require('../../v0/util');
const { convertToISODate } = require('./utils');

// ref : https://help.adjust.com/en/article/global-callbacks#general-recommended-placeholders
// import mapping json using JSON.parse to preserve object key order
const mapping = JSON.parse(fs.readFileSync(path.resolve(__dirname, './mapping.json'), 'utf-8'));

const processPayload = (payload) => {
const message = new Message(`Adjust`);

// event type is always track
const eventType = 'track';
message.setEventType(eventType);
message.setPropertiesV2(payload, mapping);
let customProperties = {};

// to remove writeKey from custom properties we can add it to excludedFieldList
excludedFieldList.push('writeKey');

customProperties = extractCustomFields(payload, customProperties, 'root', excludedFieldList);

message.properties = { ...message.properties, ...customProperties };

if (payload.created_at) {
const ts = convertToISODate(payload.created_at);
message.setProperty('originalTimestamp', ts);
message.setProperty('timestamp', ts);
}
// adjust does not has the concept of user but we need to set some random anonymousId in order to make the server accept the message
message.anonymousId = generateUUID();
return message;
};

module.exports = { processPayload };
File renamed without changes.
44 changes: 36 additions & 8 deletions src/sources/adjust/transform.js
Original file line number Diff line number Diff line change
@@ -1,18 +1,46 @@
const { processEvent: processV0Event } = require('../../v0/sources/adjust/transform');
const { TransformationError } = require('@rudderstack/integrations-lib');
const { CommonUtils } = require('../../util/common');
const logger = require('../../logger');
const { flattenParams } = require('./utils');
const { processPayload } = require('./core');

const convertV2ToV0 = (sourceEvent) => {
const v0Event = JSON.parse(sourceEvent.request.body);
if (sourceEvent.request.query_parameters) {
v0Event.query_parameters = sourceEvent.request.query_parameters;
/**
* Extracts and flattens query parameters from the webhook request
* @param {Object} inputRequest - The incoming webhook request object
* @returns {Object} Flattened query parameters
* @throws {TransformationError} If request or query_parameters are missing
*/
const getPayloadFromRequest = (inputRequest) => {
const { request } = inputRequest;
if (!request) {
throw new TransformationError('request field is missing from webhook V2 payload');
}
return v0Event;

const { query_parameters: qParams } = request;
logger.debug(`[Adjust] Input event: query_params: ${JSON.stringify(qParams)}`);
if (!qParams || Object.keys(qParams).length === 0) {
throw new TransformationError('Query_parameters is missing');
}

return flattenParams(qParams);
};

/**
* Processes incoming webhook requests from Adjust
* @param {Object|Array} requests - Single request object or array of webhook requests
* @returns {Array} Array of transformed payloads ready to be sent to rudder-server
* @description
* This function:
* - converts incoming payload to array
* - extracts params and constructs payload
* - sends it to processPayload for transformation
*/
const process = (requests) => {
const requestsArray = CommonUtils.toArray(requests);
const v0Events = requestsArray.map(convertV2ToV0);
return v0Events.map(processV0Event);
return requestsArray.map((inputRequest) => {
const formattedPayload = getPayloadFromRequest(inputRequest);
return processPayload(formattedPayload);
});
};

module.exports = { process };
56 changes: 56 additions & 0 deletions src/sources/adjust/utils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
const { TransformationError, isDefinedAndNotNull } = require('@rudderstack/integrations-lib');

/**
* Converts a raw timestamp to ISO 8601 date string format
* @param {number|string} rawTimestamp - The timestamp to convert (expects Unix timestamp in seconds)
* @returns {string} The timestamp converted to ISO 8601 format
* @throws {TransformationError} If the timestamp is invalid or cannot be parsed
*/
const convertToISODate = (rawTimestamp) => {
if (typeof rawTimestamp !== 'number' && typeof rawTimestamp !== 'string') {
throw new TransformationError(
`Invalid timestamp type: expected number or string, received ${typeof rawTimestamp}`,
);
}

const createdAt = Number(rawTimestamp);

if (Number.isNaN(createdAt)) {
throw new TransformationError(`Failed to parse timestamp: "${rawTimestamp}"`);
}

const date = new Date(createdAt * 1000);

if (Number.isNaN(date.getTime())) {
throw new TransformationError(`Failed to create valid date for timestamp "${rawTimestamp}"`);
}

return date.toISOString();
};

/**
* Flattens an object containing array parameters into a simple key-value object with first element of array as value
* @param {Object.<string, Array>} qParams - Object containing parameters where values are arrays
* @returns {Object.<string, *>} Flattened object where array values are replaced with their first element
* @description
* This function flattens an object containing array parameters into a simple key-value object
* with first element of array as value if it is an array, otherwise the value is returned as is
* In case of empty array, the key is removed from the output
*/
const flattenParams = (qParams) => {
const formattedOutput = {};
if (qParams) {
Object.entries(qParams).forEach(([key, value]) => {
const finalValue = Array.isArray(value) ? value[0] : value;
if (isDefinedAndNotNull(finalValue)) {
formattedOutput[key] = finalValue;
}
});
}
return formattedOutput;
};

module.exports = {
convertToISODate,
flattenParams,
};
207 changes: 207 additions & 0 deletions src/sources/adjust/utils.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
const { convertToISODate } = require('./utils');
const { TransformationError } = require('@rudderstack/integrations-lib');

describe('convertToISODate', () => {
const testCases = [
{
name: 'valid numeric timestamp',
input: 1633072800,
expected: '2021-10-01T07:20:00.000Z',
shouldThrow: false,
},
{
name: 'non-numeric string',
input: 'invalid',
shouldThrow: true,
errorType: TransformationError,
},
{
name: 'valid numeric string timestamp',
input: '1633072800',
expected: '2021-10-01T07:20:00.000Z',
shouldThrow: false,
},
{
name: 'object input',
input: {},
shouldThrow: true,
errorType: TransformationError,
},
{
name: 'array input',
input: [],
shouldThrow: true,
errorType: TransformationError,
},
{
name: 'null input',
input: null,
shouldThrow: true,
errorType: TransformationError,
},
{
name: 'undefined input',
input: undefined,
shouldThrow: true,
errorType: TransformationError,
},
{
name: 'huge timestamp that becomes invalid',
input: 999999999999999,
shouldThrow: true,
errorType: TransformationError,
},
];

testCases.forEach(({ name, input, expected, shouldThrow, errorType }) => {
it(`should handle ${name}`, () => {
if (shouldThrow) {
expect(() => convertToISODate(input)).toThrow(errorType);
} else {
const result = convertToISODate(input);
expect(result).toBe(expected);
}
});
});
});

describe('flattenParams', () => {
const { flattenParams } = require('./utils');

const testCases = [
{
name: 'flatten array values to first elements',
input: {
key1: ['value1'],
key2: ['value2'],
key3: [123],
},
expected: {
key1: 'value1',
key2: 'value2',
key3: 123,
},
},
{
name: 'handle null input',
input: null,
expected: {},
},
{
name: 'handle undefined input',
input: undefined,
expected: {},
},
{
name: 'handle empty object',
input: {},
expected: {},
},
{
name: 'ignore null/undefined array elements',
input: {
key1: [undefined],
key2: [null],
key3: [undefined, 'value'],
key4: [null, 'value'],
},
expected: {},
},
{
name: 'handle mixed type values in arrays',
input: {
number: [42],
string: ['test'],
boolean: [true],
object: [{ nested: 'value' }],
date: [new Date('2024-01-01')],
},
expected: {
number: 42,
string: 'test',
boolean: true,
object: { nested: 'value' },
date: new Date('2024-01-01'),
},
},
{
name: 'handle empty arrays',
input: {
key1: [],
key2: ['value'],
key3: [],
},
expected: {
key2: 'value',
},
},
{
name: 'keep non-array values unchanged',
input: {
string: 'direct string',
number: 42,
boolean: true,
object: { test: 'value' },
array: ['first', 'second'],
},
expected: {
string: 'direct string',
number: 42,
boolean: true,
object: { test: 'value' },
array: 'first',
},
},
{
name: 'handle mixed array and non-array values',
input: {
arrayValue: ['first'],
emptyArray: [],
directValue: 'string',
nullValue: null,
undefinedValue: undefined,
},
expected: {
arrayValue: 'first',
directValue: 'string',
},
},
{
name: 'handle nested arrays',
input: {
nested: [
[1, 2],
[3, 4],
],
mixed: [['a', 'b'], 'c'],
},
expected: {
nested: [1, 2],
mixed: ['a', 'b'],
},
},
{
name: 'handle special values in arrays',
input: {
nan: [NaN],
infinity: [Infinity],
negInfinity: [-Infinity],
zero: [0],
negZero: [-0],
},
expected: {
nan: NaN,
infinity: Infinity,
negInfinity: -Infinity,
zero: 0,
negZero: -0,
},
},
];

testCases.forEach(({ name, input, expected }) => {
it(`should ${name}`, () => {
expect(flattenParams(input)).toEqual(expected);
});
});
});
Loading

0 comments on commit 77bcd2f

Please sign in to comment.