diff --git a/src/cdk/v2/destinations/http/procWorkflow.yaml b/src/cdk/v2/destinations/http/procWorkflow.yaml index e8696eb7a76..57483f31831 100644 --- a/src/cdk/v2/destinations/http/procWorkflow.yaml +++ b/src/cdk/v2/destinations/http/procWorkflow.yaml @@ -30,7 +30,8 @@ steps: - name: deduceBodyFormat template: | - $.context.format = .destination.Config.format ?? 'JSON'; + const format = .destination.Config.format ?? 'JSON'; + $.context.format = $.CONTENT_TYPES_MAP[format]; - name: buildHeaders template: | @@ -45,8 +46,8 @@ steps: - name: prepareParams template: | - const params = $.getCustomMappings(.message, .destination.Config.queryParams) - $.context.params = $.encodeParamsObject(params) + const params = $.getCustomMappings(.message, .destination.Config.queryParams); + $.context.params = $.encodeParamsObject(params); - name: deduceEndPoint template: | @@ -55,13 +56,12 @@ steps: - name: prepareBody template: | const payload = $.getCustomMappings(.message, .destination.Config.propertiesMapping); - $.context.payload = $.excludeMappedFields(payload, .destination.Config.propertiesMapping) - $.context.format === "XML" && !$.isEmptyObject($.context.payload) ? $.context.payload = {payload: $.getXMLPayload($.context.payload)} : $.context.payload = $.removeUndefinedAndNullValues($.context.payload) + $.context.payload = $.prepareBody(payload, $.context.format); - name: buildResponseForProcessTransformation template: | const response = $.defaultRequestConfig(); - $.context.format === "JSON" ? response.body.JSON = $.context.payload: response.body.XML = $.context.payload; + response.body[$.context.format] = $.context.payload; response.endpoint = $.context.endpoint; response.headers = $.context.headers; response.method = $.context.method; diff --git a/src/cdk/v2/destinations/http/utils.js b/src/cdk/v2/destinations/http/utils.js index 6e53bbdc2f8..613e19d4531 100644 --- a/src/cdk/v2/destinations/http/utils.js +++ b/src/cdk/v2/destinations/http/utils.js @@ -8,9 +8,15 @@ const { base64Convertor, applyCustomMappings, isEmptyObject, - applyJSONStringTemplate, + removeUndefinedAndNullValues, } = require('../../../../v0/util'); +const CONTENT_TYPES_MAP = { + JSON: 'JSON', + XML: 'XML', + FORM: 'FORM', +}; + const getAuthHeaders = (config) => { let headers; switch (config.auth) { @@ -85,49 +91,14 @@ const getPathParamsSubString = (message, pathParamsArray) => { }; const prepareEndpoint = (message, apiUrl, pathParams) => { - let requestUrl; - try { - requestUrl = applyJSONStringTemplate(message, `\`${apiUrl}\``); - } catch (e) { - throw new ConfigurationError(`Error in api url template: ${e.message}`); - } if (!Array.isArray(pathParams)) { - return requestUrl; + return apiUrl; } + const requestUrl = apiUrl.replace(/\/{1,10}$/, ''); const pathParamsSubString = getPathParamsSubString(message, pathParams); return `${requestUrl}${pathParamsSubString}`; }; -const excludeMappedFields = (payload, mapping) => { - const rawPayload = { ...payload }; - if (mapping) { - mapping.forEach(({ from, to }) => { - // continue when from === to - if (from === to) return; - - // Remove the '$.' prefix and split the remaining string by '.' - const keys = from.replace(/^\$\./, '').split('.'); - let current = rawPayload; - - // Traverse to the parent of the key to be removed - keys.slice(0, -1).forEach((key) => { - if (current?.[key]) { - current = current[key]; - } else { - current = null; - } - }); - - if (current) { - // Remove the 'from' field from input payload - delete current[keys[keys.length - 1]]; - } - }); - } - - return rawPayload; -}; - const sanitizeKey = (key) => key .replace(/[^\w.-]/g, '_') // Replace invalid characters with underscores @@ -157,9 +128,20 @@ const getXMLPayload = (payload) => { const builderOptions = { ignoreAttributes: false, // Include attributes if they exist suppressEmptyNode: false, // Ensures that null or undefined values are not omitted + attributeNamePrefix: '@_', }; + + if (Object.keys(payload).length !== 1) { + throw new ConfigurationError( + `Error: XML supports only one root key. Please update request body mappings accordingly`, + ); + } + const rootKey = Object.keys(payload)[0]; + const builder = new XMLBuilder(builderOptions); - return `${builder.build(preprocessJson(payload))}`; + const processesPayload = preprocessJson(payload); + processesPayload[rootKey]['@_xmlns:xsi'] = 'http://www.w3.org/2001/XMLSchema-instance'; + return `${builder.build(processesPayload)}`; }; const getMergedEvents = (batch) => { @@ -172,10 +154,28 @@ const getMergedEvents = (batch) => { return events; }; -const metadataHeaders = (contentType) => - contentType === 'JSON' - ? { 'Content-Type': 'application/json' } - : { 'Content-Type': 'application/xml' }; +const metadataHeaders = (contentType) => { + switch (contentType) { + case CONTENT_TYPES_MAP.XML: + return { 'Content-Type': 'application/xml' }; + case CONTENT_TYPES_MAP.FORM: + return { 'Content-Type': 'application/x-www-form-urlencoded' }; + default: + return { 'Content-Type': 'application/json' }; + } +}; + +const prepareBody = (payload, contentType) => { + let responseBody; + if (contentType === CONTENT_TYPES_MAP.XML && !isEmptyObject(payload)) { + responseBody = { + payload: getXMLPayload(payload), + }; + } else { + responseBody = removeUndefinedAndNullValues(payload); + } + return responseBody; +}; const mergeMetadata = (batch) => batch.map((event) => event.metadata[0]); @@ -230,12 +230,12 @@ const batchSuccessfulEvents = (events, batchSize) => { }; module.exports = { + CONTENT_TYPES_MAP, getAuthHeaders, getCustomMappings, encodeParamsObject, prepareEndpoint, - excludeMappedFields, - getXMLPayload, metadataHeaders, + prepareBody, batchSuccessfulEvents, }; diff --git a/src/cdk/v2/destinations/http/utils.test.js b/src/cdk/v2/destinations/http/utils.test.js index cf8e875974c..64fc87b66b4 100644 --- a/src/cdk/v2/destinations/http/utils.test.js +++ b/src/cdk/v2/destinations/http/utils.test.js @@ -1,4 +1,4 @@ -const { encodeParamsObject, prepareEndpoint, getXMLPayload } = require('./utils'); +const { encodeParamsObject, prepareEndpoint, prepareBody } = require('./utils'); const { XMLBuilder } = require('fast-xml-parser'); const jsonpath = require('rs-jsonpath'); @@ -21,12 +21,12 @@ describe('Utils Functions', () => { describe('prepareEndpoint', () => { test('should replace template variables in API URL', () => { const message = { id: 123 }; - const apiUrl = 'https://api.example.com/resource/${$.id}'; - expect(prepareEndpoint(message, apiUrl, [])).toBe('https://api.example.com/resource/123'); + const apiUrl = 'https://api.example.com/resource/'; + expect(prepareEndpoint(message, apiUrl, [])).toBe('https://api.example.com/resource'); }); test('should replace template variables in API URL and add path params', () => { const message = { id: 123, p2: 'P2' }; - const apiUrl = 'https://api.example.com/resource/${$.id}'; + const apiUrl = 'https://api.example.com/resource/'; const pathParams = [ { path: 'p1', @@ -36,12 +36,12 @@ describe('Utils Functions', () => { }, ]; expect(prepareEndpoint(message, apiUrl, pathParams)).toBe( - 'https://api.example.com/resource/123/p1/P2', + 'https://api.example.com/resource/p1/P2', ); }); test('should add path params after uri encoding', () => { const message = { id: 123, p2: 'P2%&' }; - const apiUrl = 'https://api.example.com/resource/${$.id}'; + const apiUrl = 'https://api.example.com/resource/'; const pathParams = [ { path: 'p1', @@ -51,7 +51,7 @@ describe('Utils Functions', () => { }, ]; expect(prepareEndpoint(message, apiUrl, pathParams)).toBe( - 'https://api.example.com/resource/123/p1/P2%25%26', + 'https://api.example.com/resource/p1/P2%25%26', ); }); test('should throw error as path contains slash', () => { @@ -71,12 +71,27 @@ describe('Utils Functions', () => { }); }); - describe('getXMLPayload', () => { - test('should generate XML payload with correct structure', () => { - const payload = { key: null }; - const expectedXML = ''; - const result = getXMLPayload(payload); - expect(result).toBe(expectedXML); + describe('prepareBody', () => { + test('should prepare XML payload when content type is XML', () => { + const payload = { root: { key: 'value', key2: null } }; + const expectedXML = + 'value'; + const result = prepareBody(payload, 'XML'); + expect(result).toEqual({ payload: expectedXML }); + }); + + test('should prepare FORM-URLENCODED payload when content type is FORM-URLENCODED', () => { + const payload = { key1: 'value1', key2: 'value2' }; + const expectedFORM = { key1: 'value1', key2: 'value2' }; + const result = prepareBody(payload, 'FORM-URLENCODED'); + expect(result).toEqual(expectedFORM); + }); + + test('should return original payload without null or undefined values for other content types', () => { + const payload = { key1: 'value1', key2: null, key3: undefined, key4: 'value4' }; + const expected = { key1: 'value1', key4: 'value4' }; + const result = prepareBody(payload, 'JSON'); + expect(result).toEqual(expected); }); }); }); diff --git a/test/integrations/destinations/http/common.ts b/test/integrations/destinations/http/common.ts index 8f4ca5d048f..4dc9d4daf27 100644 --- a/test/integrations/destinations/http/common.ts +++ b/test/integrations/destinations/http/common.ts @@ -92,13 +92,18 @@ const destinations: Destination[] = [ }, { Config: { - apiUrl: 'http://abc.com/contacts/{{$.traits.email}}/', + apiUrl: 'http://abc.com/contacts/', auth: 'apiKeyAuth', apiKeyName: 'x-api-key', apiKeyValue: 'test-api-key', method: 'DELETE', isBatchingEnabled: true, maxBatchSize: 4, + pathParams: [ + { + path: '$.traits.email', + }, + ], }, DestinationDefinition: { DisplayName: displayName, @@ -114,13 +119,18 @@ const destinations: Destination[] = [ }, { Config: { - apiUrl: 'http://abc.com/contacts/{{$.traits.email}}/', + apiUrl: 'http://abc.com/contacts/', auth: 'apiKeyAuth', apiKeyName: 'x-api-key', apiKeyValue: 'test-api-key', method: 'GET', isBatchingEnabled: true, maxBatchSize: 4, + pathParams: [ + { + path: '$.traits.email', + }, + ], }, DestinationDefinition: { DisplayName: displayName, @@ -158,27 +168,27 @@ const destinations: Destination[] = [ propertiesMapping: [ { from: '$.event', - to: '$.event', + to: '$.body.event', }, { from: '$.properties.currency', - to: '$.currency', + to: '$.body.currency', }, { from: '$.userId', - to: '$.userId', + to: '$.body.userId', }, { from: '$.properties.products[*].product_id', - to: '$.properties.items[*].item_id', + to: '$.body.properties.items[*].item_id', }, { from: '$.properties.products[*].name', - to: '$.properties.items[*].name', + to: '$.body.properties.items[*].name', }, { from: '$.properties.products[*].price', - to: '$.properties.items[*].price', + to: '$.body.properties.items[*].price', }, ], }, @@ -249,7 +259,7 @@ const destinations: Destination[] = [ }, { Config: { - apiUrl: 'http://abc.com/contacts/{{$.traits.phone}}', + apiUrl: 'http://abc.com/contacts/', auth: 'noAuth', method: 'POST', format: 'JSON', @@ -265,6 +275,11 @@ const destinations: Destination[] = [ from: '.traits.key', }, ], + pathParams: [ + { + path: '$.traits.phone', + }, + ], }, DestinationDefinition: { DisplayName: displayName, @@ -436,6 +451,142 @@ const destinations: Destination[] = [ Transformations: [], WorkspaceID: 'test-workspace-id', }, + { + Config: { + apiUrl: 'http://abc.com/events', + auth: 'bearerTokenAuth', + bearerToken: 'test-token', + method: 'POST', + format: 'FORM', + headers: [ + { + to: '$.h1', + from: "'val1'", + }, + { + to: '$.h2', + from: '$.key1', + }, + { + to: "$.'content-type'", + from: "'application/json'", + }, + ], + propertiesMapping: [ + { + from: '$.event', + to: '$.event', + }, + { + from: '$.properties.currency', + to: '$.currency', + }, + { + from: '$.userId', + to: '$.userId', + }, + ], + }, + DestinationDefinition: { + DisplayName: displayName, + ID: '123', + Name: destTypeInUpperCase, + Config: { cdkV2Enabled: true }, + }, + Enabled: true, + ID: '123', + Name: destTypeInUpperCase, + Transformations: [], + WorkspaceID: 'test-workspace-id', + }, + { + Config: { + apiUrl: 'http://abc.com/events', + auth: 'bearerTokenAuth', + bearerToken: 'test-token', + method: 'POST', + format: 'FORM', + headers: [ + { + to: '$.h1', + from: "'val1'", + }, + { + to: '$.h2', + from: '$.key1', + }, + ], + propertiesMapping: [ + { + from: '$.event', + to: '$.event', + }, + { + from: '$.properties.currency', + to: '$.currency', + }, + { + from: '$.userId', + to: '$.userId', + }, + ], + }, + DestinationDefinition: { + DisplayName: displayName, + ID: '123', + Name: destTypeInUpperCase, + Config: { cdkV2Enabled: true }, + }, + Enabled: true, + ID: '123', + Name: destTypeInUpperCase, + Transformations: [], + WorkspaceID: 'test-workspace-id', + }, + { + Config: { + apiUrl: 'http://abc.com/events', + auth: 'bearerTokenAuth', + bearerToken: 'test-token', + method: 'POST', + format: 'FORM', + headers: [ + { + to: '$.h1', + from: "'val1'", + }, + { + to: '$.h2', + from: '$.key1', + }, + ], + propertiesMapping: [ + { + from: '$.event', + to: '$.event', + }, + { + from: '$.properties.currency', + to: '$.currency', + }, + { + from: '$.userId', + to: '$.userId', + }, + ], + }, + DestinationDefinition: { + DisplayName: displayName, + ID: '123', + Name: destTypeInUpperCase, + Config: { cdkV2Enabled: true }, + }, + Enabled: true, + ID: '123', + Name: destTypeInUpperCase, + Transformations: [], + WorkspaceID: 'test-workspace-id', + }, ]; const traits = { @@ -487,7 +638,7 @@ const properties = { const processorInstrumentationErrorStatTags = { destType: destTypeInUpperCase, errorCategory: 'dataValidation', - errorType: 'instrumentation', + errorType: 'configuration', feature: 'processor', implementation: 'cdkV2', module: 'destination', diff --git a/test/integrations/destinations/http/processor/configuration.ts b/test/integrations/destinations/http/processor/configuration.ts index e2793fa90c3..54673bbae28 100644 --- a/test/integrations/destinations/http/processor/configuration.ts +++ b/test/integrations/destinations/http/processor/configuration.ts @@ -98,7 +98,7 @@ export const configuration: ProcessorTestData[] = [ output: transformResultBuilder({ method: 'DELETE', userId: '', - endpoint: 'http://abc.com/contacts/john.doe@example.com/', + endpoint: 'http://abc.com/contacts/john.doe%40example.com', headers: { 'Content-Type': 'application/json', 'x-api-key': 'test-api-key', @@ -209,7 +209,7 @@ export const configuration: ProcessorTestData[] = [ }, XML: { payload: - 'Order CompletedUSDuserId123622c6f5d5cf86a4c77358033Cones of Dunshire40577c6f5d5cf86a4c7735ba03Five Crowns5', + 'Order CompletedUSDuserId123622c6f5d5cf86a4c77358033Cones of Dunshire40577c6f5d5cf86a4c7735ba03Five Crowns5', }, }), statusCode: 200, @@ -328,9 +328,9 @@ export const configuration: ProcessorTestData[] = [ { id: 'http-configuration-test-7', name: destType, - description: 'Track call with xml format and payload with special characters in keys', + description: 'Track call with xml format with multiple keys', scenario: 'Business', - successCriteria: 'Response should be in xml format with the special characters handled', + successCriteria: 'Should throw error as the body have multiple root keys', feature: 'processor', module: 'destination', version: 'v0', @@ -355,6 +355,49 @@ export const configuration: ProcessorTestData[] = [ method: 'POST', }, }, + output: { + response: { + status: 200, + body: [ + { + error: + 'Error: XML supports only one root key. Please update request body mappings accordingly: Workflow: procWorkflow, Step: prepareBody, ChildStep: undefined, OriginalError: Error: XML supports only one root key. Please update request body mappings accordingly', + statusCode: 400, + metadata: generateMetadata(1), + statTags: { ...processorInstrumentationErrorStatTags }, + }, + ], + }, + }, + }, + { + id: 'http-configuration-test-8', + name: destType, + description: + 'Track call with bearer token, form format, post method, additional headers and properties mapping', + scenario: 'Business', + successCriteria: + 'Response should be in form format with post method, headers and properties mapping', + feature: 'processor', + module: 'destination', + version: 'v0', + input: { + request: { + method: 'POST', + body: [ + { + destination: destinations[10], + message: { + type: 'track', + userId: 'userId123', + event: 'Order Completed', + properties, + }, + metadata: generateMetadata(1), + }, + ], + }, + }, output: { response: { status: 200, @@ -363,17 +406,117 @@ export const configuration: ProcessorTestData[] = [ output: transformResultBuilder({ method: 'POST', userId: '', - endpoint: destinations[9].Config.apiUrl, + endpoint: destinations[10].Config.apiUrl, headers: { - 'Content-Type': 'application/xml', + 'Content-Type': 'application/x-www-form-urlencoded', Authorization: 'Bearer test-token', h1: 'val1', 'content-type': 'application/json', }, - XML: { - payload: - 'Rubik's Cube<_revenue-wdfqwe_>4.99', + FORM: { + currency: 'USD', + event: 'Order Completed', + userId: 'userId123', + }, + }), + statusCode: 200, + metadata: generateMetadata(1), + }, + ], + }, + }, + }, + { + id: 'http-configuration-test-9', + name: destType, + description: 'Track call with bearer token, form url encoded format', + scenario: 'Business', + successCriteria: + 'Response should be in form format with post method, headers and properties mapping', + feature: 'processor', + module: 'destination', + version: 'v0', + input: { + request: { + method: 'POST', + body: [ + { + destination: destinations[11], + message: { + type: 'track', + userId: 'userId123', + event: 'Order Completed', + properties, + }, + metadata: generateMetadata(1), + }, + ], + }, + }, + output: { + response: { + status: 200, + body: [ + { + output: transformResultBuilder({ + method: 'POST', + userId: '', + endpoint: destinations[11].Config.apiUrl, + headers: { + Authorization: 'Bearer test-token', + h1: 'val1', + 'Content-Type': 'application/x-www-form-urlencoded', + }, + FORM: { + currency: 'USD', + event: 'Order Completed', + userId: 'userId123', + }, + }), + statusCode: 200, + metadata: generateMetadata(1), + }, + ], + }, + }, + }, + { + id: 'http-configuration-test-10', + name: destType, + description: 'empty body', + scenario: 'Business', + successCriteria: + 'Response should be in form format with post method, headers and properties mapping', + feature: 'processor', + module: 'destination', + version: 'v0', + input: { + request: { + method: 'POST', + body: [ + { + destination: destinations[12], + message: {}, + metadata: generateMetadata(1), + }, + ], + }, + }, + output: { + response: { + status: 200, + body: [ + { + output: transformResultBuilder({ + method: 'POST', + userId: '', + endpoint: destinations[12].Config.apiUrl, + headers: { + Authorization: 'Bearer test-token', + h1: 'val1', + 'Content-Type': 'application/x-www-form-urlencoded', }, + FORM: {}, }), statusCode: 200, metadata: generateMetadata(1), diff --git a/test/integrations/destinations/http/router/data.ts b/test/integrations/destinations/http/router/data.ts index 284a80f1090..8fd3a1584cf 100644 --- a/test/integrations/destinations/http/router/data.ts +++ b/test/integrations/destinations/http/router/data.ts @@ -182,7 +182,7 @@ export const data = [ version: '1', type: 'REST', method: 'GET', - endpoint: 'http://abc.com/contacts/john.doe@example.com/', + endpoint: 'http://abc.com/contacts/john.doe%40example.com', headers: { 'x-api-key': 'test-api-key', 'Content-Type': 'application/json',