Skip to content

Commit a36f1ab

Browse files
author
Dr.J
committed
Parse Customer.io Track API batch multi-status responses
1 parent 42598d2 commit a36f1ab

2 files changed

Lines changed: 253 additions & 4 deletions

File tree

packages/destination-actions/src/destinations/customerio/__tests__/utils.test.ts

Lines changed: 128 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { resolveIdentifiers, isIsoDate } from '../utils'
1+
import { MultiStatusResponse } from '@segment/actions-core'
2+
import { isIsoDate, parseTrackApiErrors, parseTrackApiMultiStatusResponse, resolveIdentifiers, sendBatch } from '../utils'
23

34
describe('isIsoDate', () => {
45
it('should return true for valid ISO date with fractional seconds from 1-9 digits', () => {
@@ -79,3 +80,129 @@ describe('resolveIdentifiers', () => {
7980
expect(resolveIdentifiers({})).toBeUndefined()
8081
})
8182
})
83+
84+
describe('sendBatch', () => {
85+
it('should parse 207 multi-status Track API responses', async () => {
86+
const request = jest.fn().mockResolvedValue({
87+
status: 207,
88+
data: {
89+
errors: [
90+
{
91+
batch_index: 1,
92+
reason: 'invalid',
93+
message: 'Attribute value too long'
94+
}
95+
]
96+
}
97+
})
98+
99+
const response = await sendBatch(request, [
100+
{
101+
type: 'person',
102+
action: 'event',
103+
settings: {},
104+
payload: { person_id: 'user-1', name: 'First' }
105+
},
106+
{
107+
type: 'person',
108+
action: 'event',
109+
settings: {},
110+
payload: { person_id: 'user-2', name: 'Second' }
111+
}
112+
])
113+
114+
expect(response).toBeInstanceOf(MultiStatusResponse)
115+
expect((response as MultiStatusResponse).length()).toBe(2)
116+
expect((response as MultiStatusResponse).getResponseAtIndex(0).value()).toEqual({
117+
status: 200,
118+
body: {},
119+
sent: {}
120+
})
121+
expect((response as MultiStatusResponse).getResponseAtIndex(1).value()).toEqual({
122+
status: 400,
123+
errormessage: 'Attribute value too long',
124+
errortype: 'PAYLOAD_VALIDATION_FAILED',
125+
body: {
126+
batch_index: 1,
127+
reason: 'invalid',
128+
message: 'Attribute value too long'
129+
}
130+
})
131+
})
132+
133+
it('should parse 200 Track API responses that still contain batch errors', async () => {
134+
const request = jest.fn().mockResolvedValue({
135+
status: 200,
136+
data: {
137+
errors: [
138+
{
139+
batch_index: 0,
140+
reason: 'required',
141+
field: 'name',
142+
message: 'Name is required'
143+
}
144+
]
145+
}
146+
})
147+
148+
const response = await sendBatch(request, [
149+
{
150+
type: 'person',
151+
action: 'event',
152+
settings: {},
153+
payload: { person_id: 'user-1', name: 'First' }
154+
}
155+
])
156+
157+
expect(response).toBeInstanceOf(MultiStatusResponse)
158+
expect((response as MultiStatusResponse).getResponseAtIndex(0).value()).toEqual({
159+
status: 400,
160+
errormessage: 'Name is required',
161+
errortype: 'PAYLOAD_VALIDATION_FAILED',
162+
body: {
163+
batch_index: 0,
164+
reason: 'required',
165+
field: 'name',
166+
message: 'Name is required'
167+
}
168+
})
169+
})
170+
})
171+
172+
describe('parseTrackApiErrors', () => {
173+
it('should fill success entries for items without errors', () => {
174+
const response = parseTrackApiErrors(
175+
[
176+
{
177+
batch_index: 1,
178+
reason: 'required',
179+
field: 'name',
180+
message: 'Name is required'
181+
}
182+
],
183+
3
184+
)
185+
186+
expect(response.getAllResponses().map((result) => result.value())).toEqual([
187+
{ status: 200, body: {}, sent: {} },
188+
{
189+
status: 400,
190+
errormessage: 'Name is required',
191+
errortype: 'PAYLOAD_VALIDATION_FAILED',
192+
body: {
193+
batch_index: 1,
194+
reason: 'required',
195+
field: 'name',
196+
message: 'Name is required'
197+
}
198+
},
199+
{ status: 200, body: {}, sent: {} }
200+
])
201+
})
202+
})
203+
204+
describe('parseTrackApiMultiStatusResponse', () => {
205+
it('should return null for non-Track API response bodies', () => {
206+
expect(parseTrackApiMultiStatusResponse({ ok: true }, 1)).toBeNull()
207+
})
208+
})

packages/destination-actions/src/destinations/customerio/utils.ts

Lines changed: 125 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import dayjs from '../../lib/dayjs'
22
import isPlainObject from 'lodash/isPlainObject'
33
import { fullFormats } from 'ajv-formats/dist/formats'
4+
import { ErrorCodes, MultiStatusResponse, type RequestClient } from '@segment/actions-core'
45
import { CUSTOMERIO_TRACK_API_VERSION } from './versioning-info'
56

67
const isEmail = (value: string): boolean => {
@@ -196,23 +197,144 @@ export const resolveIdentifiers = ({
196197
}
197198
}
198199

199-
export const sendBatch = <Payload extends BasePayload>(request: Function, options: RequestPayload<Payload>[]) => {
200+
export const sendBatch = async <Payload extends BasePayload>(
201+
request: RequestClient,
202+
options: RequestPayload<Payload>[]
203+
) => {
200204
if (!options?.length) {
201205
return
202206
}
203207

204208
const [{ settings }] = options
205209
const batch = options.map((opts) => buildPayload(opts))
206210

207-
return request(`${trackApiEndpoint(settings)}/api/${CUSTOMERIO_TRACK_API_VERSION}/batch`, {
211+
const response = await request(`${trackApiEndpoint(settings)}/api/${CUSTOMERIO_TRACK_API_VERSION}/batch`, {
208212
method: 'post',
209213
json: {
210214
batch
211215
}
212216
})
217+
218+
const responseBody = getResponseBody(response)
219+
220+
if (response?.status === 207 && responseBody) {
221+
const parsedResults = parseTrackApiMultiStatusResponse(responseBody, batch.length)
222+
if (parsedResults) {
223+
return parsedResults
224+
}
225+
}
226+
227+
if (response?.status === 200 && responseBody) {
228+
const parsedResults = parseTrackApiMultiStatusResponse(responseBody, batch.length)
229+
if (parsedResults) {
230+
return parsedResults
231+
}
232+
}
233+
234+
return response
235+
}
236+
237+
interface TrackApiError {
238+
batch_index?: number
239+
reason?: string
240+
field?: string
241+
message?: string
242+
}
243+
244+
interface TrackApiResponse {
245+
errors?: TrackApiError[]
246+
}
247+
248+
interface RequestResponse {
249+
status?: number
250+
data?: unknown
251+
content?: unknown
252+
body?: unknown
253+
}
254+
255+
function mapTrackApiReasonToErrorCode(reason: string | undefined) {
256+
if (!reason) {
257+
return undefined
258+
}
259+
260+
switch (reason.toLowerCase()) {
261+
case 'invalid':
262+
case 'required':
263+
return ErrorCodes.PAYLOAD_VALIDATION_FAILED
264+
default:
265+
return undefined
266+
}
267+
}
268+
269+
function getResponseBody(response: RequestResponse): unknown {
270+
const body = response.data ?? response.content ?? response.body
271+
272+
if (typeof body !== 'string') {
273+
return body
274+
}
275+
276+
try {
277+
return JSON.parse(body)
278+
} catch {
279+
try {
280+
const decoded = Buffer.from(body, 'base64').toString('utf-8')
281+
return JSON.parse(decoded)
282+
} catch {
283+
return body
284+
}
285+
}
286+
}
287+
288+
export function parseTrackApiErrors(errors: TrackApiError[], totalItems: number): MultiStatusResponse {
289+
const multiStatusResponse = new MultiStatusResponse()
290+
const errorMap = new Map<number, TrackApiError>()
291+
292+
for (const error of errors) {
293+
if (typeof error.batch_index === 'number') {
294+
errorMap.set(error.batch_index, error)
295+
}
296+
}
297+
298+
for (let i = 0; i < totalItems; i++) {
299+
const error = errorMap.get(i)
300+
301+
if (!error) {
302+
multiStatusResponse.setSuccessResponseAtIndex(i, {
303+
status: 200,
304+
body: {},
305+
sent: {}
306+
})
307+
continue
308+
}
309+
310+
multiStatusResponse.setErrorResponseAtIndex(i, {
311+
status: 400,
312+
errormessage: error.message || `${error.reason || 'ERROR'}: ${error.field || 'unknown field'}`,
313+
errortype: mapTrackApiReasonToErrorCode(error.reason),
314+
body: error
315+
})
316+
}
317+
318+
return multiStatusResponse
319+
}
320+
321+
export function parseTrackApiMultiStatusResponse(
322+
responseBody: unknown,
323+
totalItems: number
324+
): MultiStatusResponse | null {
325+
if (!isRecord(responseBody)) {
326+
return null
327+
}
328+
329+
const { errors } = responseBody as TrackApiResponse
330+
if (!Array.isArray(errors) || errors.length === 0) {
331+
return null
332+
}
333+
334+
return parseTrackApiErrors(errors, totalItems)
213335
}
214336

215-
export const sendSingle = <Payload extends BasePayload>(request: Function, options: RequestPayload<Payload>) => {
337+
export const sendSingle = <Payload extends BasePayload>(request: RequestClient, options: RequestPayload<Payload>) => {
216338
const json = buildPayload(options)
217339
return request(`${trackApiEndpoint(options.settings)}/api/${CUSTOMERIO_TRACK_API_VERSION}/entity`, {
218340
method: 'post',

0 commit comments

Comments
 (0)