@fluentcommerce/fc-connect-sdk 0.1.48 → 0.1.52
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +506 -379
- package/README.md +343 -0
- package/dist/cjs/clients/fluent-client.js +110 -14
- package/dist/cjs/data-sources/s3-data-source.js +1 -1
- package/dist/cjs/data-sources/sftp-data-source.js +1 -1
- package/dist/cjs/index.d.ts +1 -1
- package/dist/cjs/services/extraction/extraction-orchestrator.d.ts +4 -1
- package/dist/cjs/services/extraction/extraction-orchestrator.js +84 -11
- package/dist/cjs/types/index.d.ts +79 -10
- package/dist/cjs/versori/fluent-versori-client.d.ts +4 -1
- package/dist/cjs/versori/fluent-versori-client.js +131 -13
- package/dist/esm/clients/fluent-client.js +110 -14
- package/dist/esm/data-sources/s3-data-source.js +1 -1
- package/dist/esm/data-sources/sftp-data-source.js +1 -1
- package/dist/esm/index.d.ts +1 -1
- package/dist/esm/services/extraction/extraction-orchestrator.d.ts +4 -1
- package/dist/esm/services/extraction/extraction-orchestrator.js +84 -11
- package/dist/esm/types/index.d.ts +79 -10
- package/dist/esm/versori/fluent-versori-client.d.ts +4 -1
- package/dist/esm/versori/fluent-versori-client.js +131 -13
- package/dist/tsconfig.esm.tsbuildinfo +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/tsconfig.types.tsbuildinfo +1 -1
- package/dist/types/index.d.ts +1 -1
- package/dist/types/services/extraction/extraction-orchestrator.d.ts +4 -1
- package/dist/types/types/index.d.ts +79 -10
- package/dist/types/versori/fluent-versori-client.d.ts +4 -1
- package/docs/02-CORE-GUIDES/api-reference/event-api-input-output-reference.md +478 -18
- package/docs/02-CORE-GUIDES/api-reference/modules/api-reference-01-client-api.md +83 -0
- package/docs/02-CORE-GUIDES/api-reference/modules/api-reference-08-types.md +52 -0
- package/docs/02-CORE-GUIDES/api-reference/modules/api-reference-12-partial-responses.md +212 -0
- package/docs/02-CORE-GUIDES/api-reference/readme.md +1 -1
- package/docs/02-CORE-GUIDES/extraction/modules/02-core-guides-extraction-08-extraction-orchestrator.md +68 -4
- package/docs/02-CORE-GUIDES/mapping/modules/mapping-01-foundations.md +450 -448
- package/docs/02-CORE-GUIDES/mapping/modules/mapping-02-quick-start.md +476 -474
- package/docs/02-CORE-GUIDES/mapping/modules/mapping-03-schema-validation.md +464 -462
- package/docs/02-CORE-GUIDES/mapping/modules/mapping-05-advanced-patterns.md +1366 -1364
- package/docs/readme.md +245 -245
- package/package.json +17 -6
- package/docs/versori-apis/ACTIVATIONS-AND-VARIABLES-GUIDE.md +0 -60
- package/docs/versori-apis/JWT-GENERATION-GUIDE.md +0 -94
- package/docs/versori-apis/QUICK-WORKFLOW.md +0 -293
- package/docs/versori-apis/README.md +0 -73
- package/docs/versori-apis/VERSORI-PLATFORM-ARCHITECTURE.md +0 -880
- package/docs/versori-apis/Versori-Platform-API.postman_collection.json +0 -2925
- package/docs/versori-apis/Versori-Platform-API.postman_environment.example.json +0 -62
- package/docs/versori-apis/Versori-Platform-API.postman_environment.json +0 -178
|
@@ -1,1364 +1,1366 @@
|
|
|
1
|
-
# Module 5: Advanced Patterns
|
|
2
|
-
|
|
3
|
-
**Master complex transformations**
|
|
4
|
-
|
|
5
|
-
**Level:** Advanced
|
|
6
|
-
**Time:** 40 minutes
|
|
7
|
-
**Prerequisites:** [Module 4: Use Cases](./mapping-04-use-cases.md)
|
|
8
|
-
|
|
9
|
-
---
|
|
10
|
-
|
|
11
|
-
## Learning Objectives
|
|
12
|
-
|
|
13
|
-
After completing this module, you will:
|
|
14
|
-
- ✅ Map complex nested object structures
|
|
15
|
-
- ✅ Process arrays with `isArray` configuration
|
|
16
|
-
- ✅ Implement conditional transformations
|
|
17
|
-
- ✅ Use async resolvers for external lookups
|
|
18
|
-
- ✅ Chain multiple transformations
|
|
19
|
-
- ✅ Handle GraphQL edges/nodes pagination patterns
|
|
20
|
-
|
|
21
|
-
---
|
|
22
|
-
|
|
23
|
-
## Nested Objects
|
|
24
|
-
|
|
25
|
-
Map nested structures using dot notation or nested `fields` configuration.
|
|
26
|
-
|
|
27
|
-
### Method 1: Dot Notation (Flat Mapping)
|
|
28
|
-
|
|
29
|
-
```json
|
|
30
|
-
{
|
|
31
|
-
"fields": {
|
|
32
|
-
"customer.id": {
|
|
33
|
-
"source": "customerId",
|
|
34
|
-
"resolver": "sdk.parseInt"
|
|
35
|
-
},
|
|
36
|
-
"customer.email": {
|
|
37
|
-
"source": "email",
|
|
38
|
-
"resolver": "sdk.lowercase"
|
|
39
|
-
},
|
|
40
|
-
"customer.address.city": {
|
|
41
|
-
"source": "city"
|
|
42
|
-
},
|
|
43
|
-
"customer.address.state": {
|
|
44
|
-
"source": "state"
|
|
45
|
-
},
|
|
46
|
-
"customer.address.zipCode": {
|
|
47
|
-
"source": "zip"
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
```
|
|
52
|
-
|
|
53
|
-
**Output:**
|
|
54
|
-
```json
|
|
55
|
-
{
|
|
56
|
-
"customer": {
|
|
57
|
-
"id": 12345,
|
|
58
|
-
"email": "john@example.com",
|
|
59
|
-
"address": {
|
|
60
|
-
"city": "New York",
|
|
61
|
-
"state": "NY",
|
|
62
|
-
"zipCode": "10001"
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
```
|
|
67
|
-
|
|
68
|
-
### Method 2: Nested Fields (Structured Mapping)
|
|
69
|
-
|
|
70
|
-
```json
|
|
71
|
-
{
|
|
72
|
-
"fields": {
|
|
73
|
-
"customer": {
|
|
74
|
-
"fields": {
|
|
75
|
-
"id": {
|
|
76
|
-
"source": "customerId",
|
|
77
|
-
"resolver": "sdk.parseInt"
|
|
78
|
-
},
|
|
79
|
-
"email": {
|
|
80
|
-
"source": "email",
|
|
81
|
-
"resolver": "sdk.lowercase"
|
|
82
|
-
},
|
|
83
|
-
"address": {
|
|
84
|
-
"fields": {
|
|
85
|
-
"city": {
|
|
86
|
-
"source": "city"
|
|
87
|
-
},
|
|
88
|
-
"state": {
|
|
89
|
-
"source": "state"
|
|
90
|
-
},
|
|
91
|
-
"zipCode": {
|
|
92
|
-
"source": "zip"
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
```
|
|
101
|
-
|
|
102
|
-
**Same output** as Method 1, but more structured and easier to read for complex objects.
|
|
103
|
-
|
|
104
|
-
### When to Use Each Method
|
|
105
|
-
|
|
106
|
-
| Method | Best For | Advantages | Disadvantages |
|
|
107
|
-
|--------|----------|------------|---------------|
|
|
108
|
-
| **Dot Notation** | Simple nesting (2-3 levels) | Concise, flat structure | Hard to read for deep nesting |
|
|
109
|
-
| **Nested Fields** | Complex objects, reusable components | Clear hierarchy, organized | More verbose |
|
|
110
|
-
|
|
111
|
-
### Deep Nesting Example
|
|
112
|
-
|
|
113
|
-
```json
|
|
114
|
-
{
|
|
115
|
-
"fields": {
|
|
116
|
-
"order": {
|
|
117
|
-
"fields": {
|
|
118
|
-
"ref": {
|
|
119
|
-
"source": "orderRef"
|
|
120
|
-
},
|
|
121
|
-
"customer": {
|
|
122
|
-
"fields": {
|
|
123
|
-
"id": {
|
|
124
|
-
"source": "customerId"
|
|
125
|
-
},
|
|
126
|
-
"profile": {
|
|
127
|
-
"fields": {
|
|
128
|
-
"firstName": {
|
|
129
|
-
"source": "firstName"
|
|
130
|
-
},
|
|
131
|
-
"lastName": {
|
|
132
|
-
"source": "lastName"
|
|
133
|
-
},
|
|
134
|
-
"preferences": {
|
|
135
|
-
"fields": {
|
|
136
|
-
"language": {
|
|
137
|
-
"source": "lang",
|
|
138
|
-
"defaultValue": "en"
|
|
139
|
-
},
|
|
140
|
-
"currency": {
|
|
141
|
-
"source": "currency",
|
|
142
|
-
"defaultValue": "USD"
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
```
|
|
155
|
-
|
|
156
|
-
---
|
|
157
|
-
|
|
158
|
-
## Static Values
|
|
159
|
-
|
|
160
|
-
Provide constant values at any nesting level.
|
|
161
|
-
|
|
162
|
-
### Simple Static Values
|
|
163
|
-
|
|
164
|
-
```json
|
|
165
|
-
{
|
|
166
|
-
"fields": {
|
|
167
|
-
"type": {
|
|
168
|
-
"value": "STANDARD"
|
|
169
|
-
},
|
|
170
|
-
"retailerId": {
|
|
171
|
-
"value": 1
|
|
172
|
-
},
|
|
173
|
-
"source": {
|
|
174
|
-
"value": "CSV_IMPORT"
|
|
175
|
-
},
|
|
176
|
-
"status": {
|
|
177
|
-
"value": "ACTIVE"
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
```
|
|
182
|
-
|
|
183
|
-
### Static Values in Nested Objects
|
|
184
|
-
|
|
185
|
-
```json
|
|
186
|
-
{
|
|
187
|
-
"fields": {
|
|
188
|
-
"retailer": {
|
|
189
|
-
"fields": {
|
|
190
|
-
"id": {
|
|
191
|
-
"value": "1",
|
|
192
|
-
"comment": "Static retailer ID"
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
},
|
|
196
|
-
"metadata": {
|
|
197
|
-
"fields": {
|
|
198
|
-
"importSource": {
|
|
199
|
-
"value": "SFCC"
|
|
200
|
-
},
|
|
201
|
-
"importVersion": {
|
|
202
|
-
"value": "2.0"
|
|
203
|
-
},
|
|
204
|
-
"importTimestamp": {
|
|
205
|
-
"resolver": "custom.currentTimestamp"
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
```
|
|
212
|
-
|
|
213
|
-
---
|
|
214
|
-
|
|
215
|
-
## Conditional Transformations
|
|
216
|
-
|
|
217
|
-
Use custom resolvers for complex business logic and conditional mapping.
|
|
218
|
-
|
|
219
|
-
### Simple Conditional Mapping
|
|
220
|
-
|
|
221
|
-
```json
|
|
222
|
-
{
|
|
223
|
-
"fields": {
|
|
224
|
-
"shippingMethod": {
|
|
225
|
-
"source": "deliverySpeed",
|
|
226
|
-
"resolver": "custom.mapShipping"
|
|
227
|
-
},
|
|
228
|
-
"priority": {
|
|
229
|
-
"source": "orderType",
|
|
230
|
-
"resolver": "custom.calculatePriority"
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
```
|
|
235
|
-
|
|
236
|
-
**Custom Resolvers:**
|
|
237
|
-
```typescript
|
|
238
|
-
const customResolvers = {
|
|
239
|
-
'custom.mapShipping': (value: string) => {
|
|
240
|
-
// Map delivery speed to shipping method
|
|
241
|
-
const shippingMap: Record<string, string> = {
|
|
242
|
-
'express': 'OVERNIGHT',
|
|
243
|
-
'fast': 'TWO_DAY',
|
|
244
|
-
'standard': 'GROUND',
|
|
245
|
-
'economy': 'STANDARD'
|
|
246
|
-
};
|
|
247
|
-
return shippingMap[value.toLowerCase()] || 'STANDARD';
|
|
248
|
-
},
|
|
249
|
-
|
|
250
|
-
'custom.calculatePriority': (value: string, sourceData: any) => {
|
|
251
|
-
// Calculate priority based on order type and total
|
|
252
|
-
const isPremium = sourceData.customerTier === 'PREMIUM';
|
|
253
|
-
const isUrgent = value === 'EXPRESS';
|
|
254
|
-
const isLargeOrder = sourceData.total > 1000;
|
|
255
|
-
|
|
256
|
-
if (isUrgent || isPremium) return 'HIGH';
|
|
257
|
-
if (isLargeOrder) return 'MEDIUM';
|
|
258
|
-
return 'LOW';
|
|
259
|
-
}
|
|
260
|
-
};
|
|
261
|
-
```
|
|
262
|
-
|
|
263
|
-
### Multi-Condition Resolver
|
|
264
|
-
|
|
265
|
-
```typescript
|
|
266
|
-
const customResolvers = {
|
|
267
|
-
'custom.determineOrderType': (value: any, sourceData: any, config: any, helpers: any) => {
|
|
268
|
-
const total = helpers.parseFloatSafe(sourceData.total, 0);
|
|
269
|
-
const itemCount = helpers.parseIntSafe(sourceData.itemCount, 0);
|
|
270
|
-
const isInternational = sourceData.country !== 'US';
|
|
271
|
-
|
|
272
|
-
// Complex business rules
|
|
273
|
-
if (isInternational && total > 500) {
|
|
274
|
-
return 'INTERNATIONAL_PREMIUM';
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
if (itemCount > 20 || total > 1000) {
|
|
278
|
-
return 'BULK';
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
if (sourceData.expedited === true) {
|
|
282
|
-
return 'EXPRESS';
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
return 'STANDARD';
|
|
286
|
-
}
|
|
287
|
-
};
|
|
288
|
-
```
|
|
289
|
-
|
|
290
|
-
---
|
|
291
|
-
|
|
292
|
-
## Arrays
|
|
293
|
-
|
|
294
|
-
Map arrays of items using the `isArray` field configuration.
|
|
295
|
-
|
|
296
|
-
### Array Mapping with `isArray`
|
|
297
|
-
|
|
298
|
-
```json
|
|
299
|
-
{
|
|
300
|
-
"fields": {
|
|
301
|
-
"items": {
|
|
302
|
-
"source": "orderItems",
|
|
303
|
-
"isArray": true,
|
|
304
|
-
"fields": {
|
|
305
|
-
"ref": {
|
|
306
|
-
"source": "id"
|
|
307
|
-
},
|
|
308
|
-
"productRef": {
|
|
309
|
-
"source": "sku"
|
|
310
|
-
},
|
|
311
|
-
"quantity": {
|
|
312
|
-
"source": "qty",
|
|
313
|
-
"resolver": "sdk.parseInt"
|
|
314
|
-
},
|
|
315
|
-
"price": {
|
|
316
|
-
"source": "unitPrice",
|
|
317
|
-
"resolver": "sdk.parseFloat"
|
|
318
|
-
},
|
|
319
|
-
"totalPrice": {
|
|
320
|
-
"resolver": "custom.calculateItemTotal"
|
|
321
|
-
}
|
|
322
|
-
}
|
|
323
|
-
}
|
|
324
|
-
}
|
|
325
|
-
}
|
|
326
|
-
```
|
|
327
|
-
|
|
328
|
-
### How `isArray` Works
|
|
329
|
-
|
|
330
|
-
1. **Extract Array:** `source: "orderItems"` extracts the array from source data
|
|
331
|
-
2. **Iterate:** `isArray: true` tells mapper to iterate over array elements
|
|
332
|
-
3. **Map Each Item:** `fields: {...}` defines mappings for each array element
|
|
333
|
-
4. **Context Switch:** ⚠️ **THIS IS THE KEY CONCEPT**
|
|
334
|
-
|
|
335
|
-
Once inside `fields` of an `isArray` mapping, the context changes:
|
|
336
|
-
|
|
337
|
-
```mermaid
|
|
338
|
-
flowchart TD
|
|
339
|
-
A[Root Context<br/>orderItems: [...],<br/>customerId: 123] -->|Iterate Array| B[Array Item Context<br/>id: 'ITEM-1'<br/>sku: 'PROD-001'<br/>qty: '2']
|
|
340
|
-
B -->|Map Fields| C[Field Mappings<br/>source: 'id'<br/>→ Reads from item,<br/>NOT root]
|
|
341
|
-
|
|
342
|
-
style A fill:#e1f5ff
|
|
343
|
-
style B fill:#fff4e1
|
|
344
|
-
style C fill:#e8f5e9
|
|
345
|
-
```
|
|
346
|
-
|
|
347
|
-
**Text Version:**
|
|
348
|
-
```
|
|
349
|
-
Root Context: { orderItems: [...], customerId: 123 }
|
|
350
|
-
↓ iterate
|
|
351
|
-
Array Item: { id: "ITEM-1", sku: "PROD-001", qty: "2" } ← NEW CONTEXT!
|
|
352
|
-
↓ map fields
|
|
353
|
-
Field Mappings: "source": "id" reads from THIS object, not root
|
|
354
|
-
```
|
|
355
|
-
|
|
356
|
-
**In other words:**
|
|
357
|
-
- `"source": "id"` → reads `currentArrayItem.id`
|
|
358
|
-
- `"source": "sku"` → reads `currentArrayItem.sku`
|
|
359
|
-
- NOT `sourceData.id` or `sourceData.sku`
|
|
360
|
-
|
|
361
|
-
**Source Data:**
|
|
362
|
-
```json
|
|
363
|
-
{
|
|
364
|
-
"orderItems": [
|
|
365
|
-
{ "id": "ITEM-1", "sku": "PROD-001", "qty": "2", "unitPrice": "49.99" },
|
|
366
|
-
{ "id": "ITEM-2", "sku": "PROD-002", "qty": "1", "unitPrice": "99.99" }
|
|
367
|
-
]
|
|
368
|
-
}
|
|
369
|
-
```
|
|
370
|
-
|
|
371
|
-
**Mapped Output:**
|
|
372
|
-
```json
|
|
373
|
-
{
|
|
374
|
-
"items": [
|
|
375
|
-
{ "ref": "ITEM-1", "productRef": "PROD-001", "quantity": 2, "price": 49.99, "totalPrice": 99.98 },
|
|
376
|
-
{ "ref": "ITEM-2", "productRef": "PROD-002", "quantity": 1, "price": 99.99, "totalPrice": 99.99 }
|
|
377
|
-
]
|
|
378
|
-
}
|
|
379
|
-
```
|
|
380
|
-
|
|
381
|
-
### Common Mistake: Don't Repeat Array Path
|
|
382
|
-
|
|
383
|
-
**❌ WRONG - Repeating array path in nested fields:**
|
|
384
|
-
```json
|
|
385
|
-
{
|
|
386
|
-
"items": {
|
|
387
|
-
"source": "orderItems",
|
|
388
|
-
"isArray": true,
|
|
389
|
-
"fields": {
|
|
390
|
-
"ref": {
|
|
391
|
-
"source": "orderItems[0].id" // ❌ Already inside array context!
|
|
392
|
-
}
|
|
393
|
-
}
|
|
394
|
-
}
|
|
395
|
-
}
|
|
396
|
-
```
|
|
397
|
-
|
|
398
|
-
**✅ CORRECT - Use paths relative to array item:**
|
|
399
|
-
```json
|
|
400
|
-
{
|
|
401
|
-
"items": {
|
|
402
|
-
"source": "orderItems",
|
|
403
|
-
"isArray": true,
|
|
404
|
-
"fields": {
|
|
405
|
-
"ref": {
|
|
406
|
-
"source": "id" // ✅ Reads from current array item
|
|
407
|
-
}
|
|
408
|
-
}
|
|
409
|
-
}
|
|
410
|
-
}
|
|
411
|
-
```
|
|
412
|
-
|
|
413
|
-
### Accessing Array Item Context
|
|
414
|
-
|
|
415
|
-
Within array field mappings, use `source` paths relative to the array item:
|
|
416
|
-
|
|
417
|
-
```json
|
|
418
|
-
{
|
|
419
|
-
"items": {
|
|
420
|
-
"source": "orderItems",
|
|
421
|
-
"isArray": true,
|
|
422
|
-
"fields": {
|
|
423
|
-
"ref": {
|
|
424
|
-
"source": "id" // ← Reads from current array item's 'id' field
|
|
425
|
-
},
|
|
426
|
-
"productRef": {
|
|
427
|
-
"source": "sku" // ← Reads from current array item's 'sku' field
|
|
428
|
-
}
|
|
429
|
-
}
|
|
430
|
-
}
|
|
431
|
-
}
|
|
432
|
-
```
|
|
433
|
-
|
|
434
|
-
### GraphQL Edges/Nodes Pattern
|
|
435
|
-
|
|
436
|
-
**IMPORTANT:** The asterisk `[*]` is optional. Both patterns work:
|
|
437
|
-
|
|
438
|
-
**Option 1: With wildcard (extracts nodes directly)**
|
|
439
|
-
```json
|
|
440
|
-
{
|
|
441
|
-
"fields": {
|
|
442
|
-
"products": {
|
|
443
|
-
"source": "products.edges[*].node", // Wildcard extracts nodes
|
|
444
|
-
"isArray": true,
|
|
445
|
-
"fields": {
|
|
446
|
-
"id": { "source": "id" }, // Direct access (no "node." prefix)
|
|
447
|
-
"ref": { "source": "ref" },
|
|
448
|
-
"name": { "source": "name" },
|
|
449
|
-
"price": { "source": "price", "resolver": "sdk.parseFloat" }
|
|
450
|
-
}
|
|
451
|
-
}
|
|
452
|
-
}
|
|
453
|
-
}
|
|
454
|
-
```
|
|
455
|
-
|
|
456
|
-
**Option 2: Without wildcard (extracts edges)**
|
|
457
|
-
```json
|
|
458
|
-
{
|
|
459
|
-
"fields": {
|
|
460
|
-
"products": {
|
|
461
|
-
"source": "products.edges", // Direct array access
|
|
462
|
-
"isArray": true,
|
|
463
|
-
"fields": {
|
|
464
|
-
"id": { "source": "node.id" }, // Need "node." prefix
|
|
465
|
-
"ref": { "source": "node.ref" },
|
|
466
|
-
"name": { "source": "node.name" },
|
|
467
|
-
"price": { "source": "node.price", "resolver": "sdk.parseFloat" }
|
|
468
|
-
}
|
|
469
|
-
}
|
|
470
|
-
}
|
|
471
|
-
}
|
|
472
|
-
```
|
|
473
|
-
|
|
474
|
-
**Option 3: Auto-detection (SDK detects GraphQL structure)**
|
|
475
|
-
```json
|
|
476
|
-
{
|
|
477
|
-
"fields": {
|
|
478
|
-
"products": {
|
|
479
|
-
"source": "products", // SDK auto-detects { edges: [...] }
|
|
480
|
-
"isArray": true,
|
|
481
|
-
"fields": {
|
|
482
|
-
"id": { "source": "id" }, // Direct access (nodes auto-extracted)
|
|
483
|
-
"ref": { "source": "ref" },
|
|
484
|
-
"name": { "source": "name" },
|
|
485
|
-
"price": { "source": "price", "resolver": "sdk.parseFloat" }
|
|
486
|
-
}
|
|
487
|
-
}
|
|
488
|
-
}
|
|
489
|
-
}
|
|
490
|
-
```
|
|
491
|
-
|
|
492
|
-
**All three produce the same result** - choose based on clarity for your use case
|
|
493
|
-
|
|
494
|
-
**Source Data (GraphQL):**
|
|
495
|
-
```json
|
|
496
|
-
{
|
|
497
|
-
"products": {
|
|
498
|
-
"edges": [
|
|
499
|
-
{ "cursor": "abc123", "node": { "id": "1", "ref": "PROD-001", "name": "Widget", "price": 19.99 } },
|
|
500
|
-
{ "cursor": "def456", "node": { "id": "2", "ref": "PROD-002", "name": "Gadget", "price": 29.99 } }
|
|
501
|
-
]
|
|
502
|
-
}
|
|
503
|
-
}
|
|
504
|
-
```
|
|
505
|
-
|
|
506
|
-
**Mapped Output:**
|
|
507
|
-
```json
|
|
508
|
-
{
|
|
509
|
-
"products": [
|
|
510
|
-
{ "id": "1", "ref": "PROD-001", "name": "Widget", "price": 19.99 },
|
|
511
|
-
{ "id": "2", "ref": "PROD-002", "name": "Gadget", "price": 29.99 }
|
|
512
|
-
]
|
|
513
|
-
}
|
|
514
|
-
```
|
|
515
|
-
|
|
516
|
-
### Nested Arrays
|
|
517
|
-
|
|
518
|
-
Arrays within arrays require nested `isArray` configuration. This is common when parsing XML with complex nested structures.
|
|
519
|
-
|
|
520
|
-
#### XML Example with Nested Arrays
|
|
521
|
-
|
|
522
|
-
**Sample XML:**
|
|
523
|
-
```xml
|
|
524
|
-
<?xml version="1.0" encoding="UTF-8"?>
|
|
525
|
-
<Orders>
|
|
526
|
-
<Order id="ORD-001" orderDate="2025-01-22">
|
|
527
|
-
<customer>
|
|
528
|
-
<name>John Doe</name>
|
|
529
|
-
<email>John@Example.com</email>
|
|
530
|
-
</customer>
|
|
531
|
-
<items>
|
|
532
|
-
<item sku="PROD-001" qty="2">
|
|
533
|
-
<price>29.99</price>
|
|
534
|
-
<attributes>
|
|
535
|
-
<attribute name="color" value="Blue"/>
|
|
536
|
-
<attribute name="size" value="M"/>
|
|
537
|
-
</attributes>
|
|
538
|
-
</item>
|
|
539
|
-
<item sku="PROD-002" qty="1">
|
|
540
|
-
<price>49.99</price>
|
|
541
|
-
<attributes>
|
|
542
|
-
<attribute name="color" value="Red"/>
|
|
543
|
-
<attribute name="size" value="L"/>
|
|
544
|
-
</attributes>
|
|
545
|
-
</item>
|
|
546
|
-
</items>
|
|
547
|
-
</Order>
|
|
548
|
-
<Order id="ORD-002" orderDate="2025-01-23">
|
|
549
|
-
<customer>
|
|
550
|
-
<name>Jane Smith</name>
|
|
551
|
-
<email>jane@example.com</email>
|
|
552
|
-
</customer>
|
|
553
|
-
<items>
|
|
554
|
-
<item sku="PROD-003" qty="3">
|
|
555
|
-
<price>19.99</price>
|
|
556
|
-
<attributes>
|
|
557
|
-
<attribute name="color" value="Green"/>
|
|
558
|
-
</attributes>
|
|
559
|
-
</item>
|
|
560
|
-
</items>
|
|
561
|
-
</Order>
|
|
562
|
-
</Orders>
|
|
563
|
-
```
|
|
564
|
-
|
|
565
|
-
**After XML Parsing (fast-xml-parser):**
|
|
566
|
-
```json
|
|
567
|
-
{
|
|
568
|
-
"Orders": {
|
|
569
|
-
"Order": [
|
|
570
|
-
{
|
|
571
|
-
"@id": "ORD-001",
|
|
572
|
-
"@orderDate": "2025-01-22",
|
|
573
|
-
"customer": {
|
|
574
|
-
"name": "John Doe",
|
|
575
|
-
"email": "John@Example.com"
|
|
576
|
-
},
|
|
577
|
-
"items": {
|
|
578
|
-
"item": [
|
|
579
|
-
{
|
|
580
|
-
"@sku": "PROD-001",
|
|
581
|
-
"@qty": "2",
|
|
582
|
-
"price": "29.99",
|
|
583
|
-
"attributes": {
|
|
584
|
-
"attribute": [
|
|
585
|
-
{ "@name": "color", "@value": "Blue" },
|
|
586
|
-
{ "@name": "size", "@value": "M" }
|
|
587
|
-
]
|
|
588
|
-
}
|
|
589
|
-
},
|
|
590
|
-
{
|
|
591
|
-
"@sku": "PROD-002",
|
|
592
|
-
"@qty": "1",
|
|
593
|
-
"price": "49.99",
|
|
594
|
-
"attributes": {
|
|
595
|
-
"attribute": [
|
|
596
|
-
{ "@name": "color", "@value": "Red" },
|
|
597
|
-
{ "@name": "size", "@value": "L" }
|
|
598
|
-
]
|
|
599
|
-
}
|
|
600
|
-
}
|
|
601
|
-
]
|
|
602
|
-
}
|
|
603
|
-
}
|
|
604
|
-
]
|
|
605
|
-
}
|
|
606
|
-
}
|
|
607
|
-
```
|
|
608
|
-
|
|
609
|
-
**Mapping Configuration:**
|
|
610
|
-
```json
|
|
611
|
-
{
|
|
612
|
-
"fields": {
|
|
613
|
-
"orders": {
|
|
614
|
-
"source": "Orders.Order",
|
|
615
|
-
"isArray": true,
|
|
616
|
-
"fields": {
|
|
617
|
-
"orderRef": {
|
|
618
|
-
"source": "@id",
|
|
619
|
-
"required": true
|
|
620
|
-
},
|
|
621
|
-
"orderDate": {
|
|
622
|
-
"source": "@orderDate",
|
|
623
|
-
"resolver": "sdk.parseDate"
|
|
624
|
-
},
|
|
625
|
-
"customerName": {
|
|
626
|
-
"source": "customer.name",
|
|
627
|
-
"resolver": "sdk.trim"
|
|
628
|
-
},
|
|
629
|
-
"customerEmail": {
|
|
630
|
-
"source": "customer.email",
|
|
631
|
-
"resolver": "sdk.lowercase"
|
|
632
|
-
},
|
|
633
|
-
"items": {
|
|
634
|
-
"source": "items.item",
|
|
635
|
-
"isArray": true,
|
|
636
|
-
"comment": "Nested array: items within orders",
|
|
637
|
-
"fields": {
|
|
638
|
-
"productRef": {
|
|
639
|
-
"source": "@sku",
|
|
640
|
-
"required": true
|
|
641
|
-
},
|
|
642
|
-
"quantity": {
|
|
643
|
-
"source": "@qty",
|
|
644
|
-
"resolver": "sdk.parseInt"
|
|
645
|
-
},
|
|
646
|
-
"price": {
|
|
647
|
-
"source": "price",
|
|
648
|
-
"resolver": "sdk.parseFloat"
|
|
649
|
-
},
|
|
650
|
-
"attributes": {
|
|
651
|
-
"source": "attributes.attribute",
|
|
652
|
-
"isArray": true,
|
|
653
|
-
"comment": "Nested array: attributes within items",
|
|
654
|
-
"fields": {
|
|
655
|
-
"name": {
|
|
656
|
-
"source": "@name",
|
|
657
|
-
"resolver": "sdk.trim"
|
|
658
|
-
},
|
|
659
|
-
"value": {
|
|
660
|
-
"source": "@value",
|
|
661
|
-
"resolver": "sdk.trim"
|
|
662
|
-
}
|
|
663
|
-
}
|
|
664
|
-
}
|
|
665
|
-
}
|
|
666
|
-
}
|
|
667
|
-
}
|
|
668
|
-
}
|
|
669
|
-
}
|
|
670
|
-
}
|
|
671
|
-
```
|
|
672
|
-
|
|
673
|
-
**TypeScript Implementation:**
|
|
674
|
-
```typescript
|
|
675
|
-
import { UniversalMapper } from '@fluentcommerce/fc-connect-sdk';
|
|
676
|
-
import { XMLParserService } from '@fluentcommerce/fc-connect-sdk';
|
|
677
|
-
|
|
678
|
-
// Parse XML
|
|
679
|
-
const parser = new XMLParserService();
|
|
680
|
-
const parsedXml = await parser.parse(xmlContent);
|
|
681
|
-
|
|
682
|
-
// Normalize array (XML parser returns object for single element, array for multiple)
|
|
683
|
-
const ordersData = Array.isArray(parsedXml?.Orders?.Order)
|
|
684
|
-
? parsedXml.Orders.Order
|
|
685
|
-
: parsedXml?.Orders?.Order
|
|
686
|
-
? [parsedXml.Orders.Order]
|
|
687
|
-
: [];
|
|
688
|
-
|
|
689
|
-
// Create mapper
|
|
690
|
-
const mapper = new UniversalMapper(mappingConfig);
|
|
691
|
-
|
|
692
|
-
// Map each order
|
|
693
|
-
const mappedOrders = [];
|
|
694
|
-
for (const order of ordersData) {
|
|
695
|
-
const result = await mapper.map({ Order: order });
|
|
696
|
-
mappedOrders.push(result.data);
|
|
697
|
-
}
|
|
698
|
-
```
|
|
699
|
-
|
|
700
|
-
**Mapped Output:**
|
|
701
|
-
```json
|
|
702
|
-
{
|
|
703
|
-
"orders": [
|
|
704
|
-
{
|
|
705
|
-
"orderRef": "ORD-001",
|
|
706
|
-
"orderDate": "2025-01-22T00:00:00.000Z",
|
|
707
|
-
"customerName": "John Doe",
|
|
708
|
-
"customerEmail": "john@example.com",
|
|
709
|
-
"items": [
|
|
710
|
-
{
|
|
711
|
-
"productRef": "PROD-001",
|
|
712
|
-
"quantity": 2,
|
|
713
|
-
"price": 29.99,
|
|
714
|
-
"attributes": [
|
|
715
|
-
{ "name": "color", "value": "Blue" },
|
|
716
|
-
{ "name": "size", "value": "M" }
|
|
717
|
-
]
|
|
718
|
-
},
|
|
719
|
-
{
|
|
720
|
-
"productRef": "PROD-002",
|
|
721
|
-
"quantity": 1,
|
|
722
|
-
"price": 49.99,
|
|
723
|
-
"attributes": [
|
|
724
|
-
{ "name": "color", "value": "Red" },
|
|
725
|
-
{ "name": "size", "value": "L" }
|
|
726
|
-
]
|
|
727
|
-
}
|
|
728
|
-
]
|
|
729
|
-
},
|
|
730
|
-
{
|
|
731
|
-
"orderRef": "ORD-002",
|
|
732
|
-
"orderDate": "2025-01-23T00:00:00.000Z",
|
|
733
|
-
"customerName": "Jane Smith",
|
|
734
|
-
"customerEmail": "jane@example.com",
|
|
735
|
-
"items": [
|
|
736
|
-
{
|
|
737
|
-
"productRef": "PROD-003",
|
|
738
|
-
"quantity": 3,
|
|
739
|
-
"price": 19.99,
|
|
740
|
-
"attributes": [
|
|
741
|
-
{ "name": "color", "value": "Green" }
|
|
742
|
-
]
|
|
743
|
-
}
|
|
744
|
-
]
|
|
745
|
-
}
|
|
746
|
-
]
|
|
747
|
-
}
|
|
748
|
-
```
|
|
749
|
-
|
|
750
|
-
**Key Points:**
|
|
751
|
-
1. **XML Attributes**: Use `@` prefix (`@id`, `@sku`, `@name`, `@value`)
|
|
752
|
-
2. **Nested Arrays**: Each level requires `isArray: true` with nested `fields`
|
|
753
|
-
3. **Resolvers**: Apply appropriate resolvers (`sdk.parseInt`, `sdk.parseFloat`, `sdk.parseDate`, `sdk.lowercase`, `sdk.trim`)
|
|
754
|
-
4. **Context Switching**: Within array `fields`, `source` paths are relative to each array item
|
|
755
|
-
5. **Array Normalization**: Always normalize XML arrays before mapping (single element → object, multiple → array)
|
|
756
|
-
|
|
757
|
-
---
|
|
758
|
-
|
|
759
|
-
## Async Resolvers
|
|
760
|
-
|
|
761
|
-
Resolvers can be async for external lookups and API calls.
|
|
762
|
-
|
|
763
|
-
### Customer Lookup Example
|
|
764
|
-
|
|
765
|
-
```typescript
|
|
766
|
-
const customResolvers = {
|
|
767
|
-
'custom.lookupCustomer': async (email: string, sourceData: any, config: any, helpers: any) => {
|
|
768
|
-
// Ensure fluentClient is available
|
|
769
|
-
if (!helpers.fluentClient) {
|
|
770
|
-
throw new Error('fluentClient not available - pass via UniversalMapper options');
|
|
771
|
-
}
|
|
772
|
-
|
|
773
|
-
// Query Fluent API to find customer by email
|
|
774
|
-
const query = `
|
|
775
|
-
query GetCustomerByEmail($email: String!) {
|
|
776
|
-
customers(email: $email) {
|
|
777
|
-
edges {
|
|
778
|
-
node {
|
|
779
|
-
id
|
|
780
|
-
}
|
|
781
|
-
}
|
|
782
|
-
}
|
|
783
|
-
}
|
|
784
|
-
`;
|
|
785
|
-
|
|
786
|
-
const result = await helpers.fluentClient.graphql({
|
|
787
|
-
query,
|
|
788
|
-
variables: { email }
|
|
789
|
-
});
|
|
790
|
-
|
|
791
|
-
const customerId = result.data.customers.edges[0]?.node.id;
|
|
792
|
-
|
|
793
|
-
if (!customerId) {
|
|
794
|
-
throw new Error(`Customer not found for email: ${email}`);
|
|
795
|
-
}
|
|
796
|
-
|
|
797
|
-
return customerId;
|
|
798
|
-
}
|
|
799
|
-
};
|
|
800
|
-
|
|
801
|
-
// Create mapper with fluentClient option
|
|
802
|
-
const mapper = new UniversalMapper(mappingConfig, {
|
|
803
|
-
customResolvers,
|
|
804
|
-
fluentClient: fluentClient // ✅ Makes client available in helpers
|
|
805
|
-
});
|
|
806
|
-
```
|
|
807
|
-
|
|
808
|
-
### Product Enrichment Example
|
|
809
|
-
|
|
810
|
-
```typescript
|
|
811
|
-
const customResolvers = {
|
|
812
|
-
'custom.enrichProduct': async (sku: string, sourceData: any, config: any, helpers: any) => {
|
|
813
|
-
// Fetch product details from external API
|
|
814
|
-
const response = await fetch(`https://api.example.com/products/${sku}`);
|
|
815
|
-
const product = await response.json();
|
|
816
|
-
|
|
817
|
-
return {
|
|
818
|
-
sku: product.sku,
|
|
819
|
-
name: product.name,
|
|
820
|
-
description: product.description,
|
|
821
|
-
category: product.category,
|
|
822
|
-
price: product.price
|
|
823
|
-
};
|
|
824
|
-
}
|
|
825
|
-
};
|
|
826
|
-
```
|
|
827
|
-
|
|
828
|
-
### Memoized Async Resolver
|
|
829
|
-
|
|
830
|
-
For repeated lookups, use memoization to cache results:
|
|
831
|
-
|
|
832
|
-
```typescript
|
|
833
|
-
const customResolvers = {
|
|
834
|
-
'custom.getCategoryName': helpers.memoize(async (categoryId: string) => {
|
|
835
|
-
// This will only be called once per unique categoryId
|
|
836
|
-
const response = await fetch(`https://api.example.com/categories/${categoryId}`);
|
|
837
|
-
const category = await response.json();
|
|
838
|
-
return category.name;
|
|
839
|
-
})
|
|
840
|
-
};
|
|
841
|
-
```
|
|
842
|
-
|
|
843
|
-
### Performance Consideration
|
|
844
|
-
|
|
845
|
-
**❌ Avoid:** Individual async calls in batch processing loops
|
|
846
|
-
|
|
847
|
-
```typescript
|
|
848
|
-
// ❌ Bad - Makes N API calls for N records
|
|
849
|
-
for (const record of records) {
|
|
850
|
-
const result = await mapper.map(record); // Each calls async resolver
|
|
851
|
-
}
|
|
852
|
-
```
|
|
853
|
-
|
|
854
|
-
**✅ Better:** Pre-fetch data, then map synchronously
|
|
855
|
-
|
|
856
|
-
```typescript
|
|
857
|
-
// ✅ Good - Single batch API call, then fast mapping
|
|
858
|
-
const customerIds = records.map(r => r.customerId);
|
|
859
|
-
const customers = await api.getCustomersBatch(customerIds);
|
|
860
|
-
const customersMap = new Map(customers.map(c => [c.id, c]));
|
|
861
|
-
|
|
862
|
-
const customResolvers = {
|
|
863
|
-
'custom.getCustomer': (id: string) => customersMap.get(id) // Synchronous lookup
|
|
864
|
-
};
|
|
865
|
-
|
|
866
|
-
for (const record of records) {
|
|
867
|
-
const result = await mapper.map(record); // Fast - no API calls
|
|
868
|
-
}
|
|
869
|
-
```
|
|
870
|
-
|
|
871
|
-
---
|
|
872
|
-
|
|
873
|
-
## Chaining Transformations
|
|
874
|
-
|
|
875
|
-
Combine multiple transformations via custom resolvers.
|
|
876
|
-
|
|
877
|
-
### Method 1: Manual Chaining
|
|
878
|
-
|
|
879
|
-
```typescript
|
|
880
|
-
const customResolvers = {
|
|
881
|
-
'custom.cleanAndFormat': (value: string, sourceData: any, config: any, helpers: any) => {
|
|
882
|
-
// Chain: trim → lowercase → capitalize first letter
|
|
883
|
-
const trimmed = helpers.normalizeWhitespace(value);
|
|
884
|
-
const lower = trimmed.toLowerCase();
|
|
885
|
-
return lower.charAt(0).toUpperCase() + lower.slice(1);
|
|
886
|
-
}
|
|
887
|
-
};
|
|
888
|
-
```
|
|
889
|
-
|
|
890
|
-
### Method 2: Using SDK Resolvers Within Custom
|
|
891
|
-
|
|
892
|
-
```typescript
|
|
893
|
-
import { getSdkResolver } from '@fluentcommerce/fc-connect-sdk';
|
|
894
|
-
|
|
895
|
-
const trim = getSdkResolver('sdk.trim');
|
|
896
|
-
const uppercase = getSdkResolver('sdk.uppercase');
|
|
897
|
-
|
|
898
|
-
const customResolvers = {
|
|
899
|
-
'custom.cleanAndUpper': (value: any, sourceData: any, config: any, helpers: any) => {
|
|
900
|
-
// Chain SDK resolvers
|
|
901
|
-
const trimmed = trim(value, sourceData, config, helpers);
|
|
902
|
-
return uppercase(trimmed, sourceData, config, helpers);
|
|
903
|
-
}
|
|
904
|
-
};
|
|
905
|
-
```
|
|
906
|
-
|
|
907
|
-
### Complex Chaining Example
|
|
908
|
-
|
|
909
|
-
```typescript
|
|
910
|
-
const customResolvers = {
|
|
911
|
-
'custom.processOrderRef': (value: string, sourceData: any, config: any, helpers: any) => {
|
|
912
|
-
// Step 1: Trim whitespace
|
|
913
|
-
const cleaned = helpers.normalizeWhitespace(value);
|
|
914
|
-
|
|
915
|
-
// Step 2: Uppercase
|
|
916
|
-
const upper = cleaned.toUpperCase();
|
|
917
|
-
|
|
918
|
-
// Step 3: Add prefix if not present
|
|
919
|
-
const withPrefix = upper.startsWith('ORD-') ? upper : `ORD-${upper}`;
|
|
920
|
-
|
|
921
|
-
// Step 4: Validate format
|
|
922
|
-
if (!/^ORD-[A-Z0-9]+$/.test(withPrefix)) {
|
|
923
|
-
throw new Error(`Invalid order ref format: ${withPrefix}`);
|
|
924
|
-
}
|
|
925
|
-
|
|
926
|
-
return withPrefix;
|
|
927
|
-
}
|
|
928
|
-
};
|
|
929
|
-
```
|
|
930
|
-
|
|
931
|
-
### Resolver Composition
|
|
932
|
-
|
|
933
|
-
Create reusable resolver building blocks:
|
|
934
|
-
|
|
935
|
-
```typescript
|
|
936
|
-
// Utility functions
|
|
937
|
-
const cleanString = (value: string) => value.trim().replace(/\s+/g, ' ');
|
|
938
|
-
const normalizeEmail = (value: string) => value.toLowerCase().trim();
|
|
939
|
-
const validateEmail = (value: string) => {
|
|
940
|
-
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
941
|
-
if (!emailRegex.test(value)) {
|
|
942
|
-
throw new Error(`Invalid email: ${value}`);
|
|
943
|
-
}
|
|
944
|
-
return value;
|
|
945
|
-
};
|
|
946
|
-
|
|
947
|
-
// Composed resolvers
|
|
948
|
-
const customResolvers = {
|
|
949
|
-
'custom.processEmail': (value: string) => {
|
|
950
|
-
return validateEmail(normalizeEmail(cleanString(value)));
|
|
951
|
-
},
|
|
952
|
-
|
|
953
|
-
'custom.processPhone': (value: string) => {
|
|
954
|
-
const cleaned = cleanString(value);
|
|
955
|
-
const digitsOnly = cleaned.replace(/\D/g, '');
|
|
956
|
-
return `(${digitsOnly.slice(0,3)}) ${digitsOnly.slice(3,6)}-${digitsOnly.slice(6)}`;
|
|
957
|
-
}
|
|
958
|
-
};
|
|
959
|
-
```
|
|
960
|
-
|
|
961
|
-
---
|
|
962
|
-
|
|
963
|
-
## Advanced Context Usage
|
|
964
|
-
|
|
965
|
-
Access workflow-specific data via `helpers.context`.
|
|
966
|
-
|
|
967
|
-
### Passing Context to Mapper
|
|
968
|
-
|
|
969
|
-
```typescript
|
|
970
|
-
// Create mapper with context
|
|
971
|
-
const mapper = new UniversalMapper(mappingConfig, {
|
|
972
|
-
customResolvers,
|
|
973
|
-
context: {
|
|
974
|
-
orderId: 'ORD-2025-001',
|
|
975
|
-
userId: '999',
|
|
976
|
-
importDate: new Date().toISOString(),
|
|
977
|
-
retailerId: '1'
|
|
978
|
-
}
|
|
979
|
-
});
|
|
980
|
-
```
|
|
981
|
-
|
|
982
|
-
### Using Context in Resolvers
|
|
983
|
-
|
|
984
|
-
```typescript
|
|
985
|
-
const customResolvers = {
|
|
986
|
-
'custom.addOrderContext': (value: any, sourceData: any, config: any, helpers: any) => {
|
|
987
|
-
const orderId = helpers.context?.orderId;
|
|
988
|
-
return `${value}_${orderId}`;
|
|
989
|
-
},
|
|
990
|
-
|
|
991
|
-
'custom.setRetailer': (value: any, sourceData: any, config: any, helpers: any) => {
|
|
992
|
-
return helpers.context?.retailerId || '1';
|
|
993
|
-
},
|
|
994
|
-
|
|
995
|
-
'custom.auditTrail': (value: any, sourceData: any, config: any, helpers: any) => {
|
|
996
|
-
return {
|
|
997
|
-
value,
|
|
998
|
-
importedBy: helpers.context?.userId,
|
|
999
|
-
importedAt: helpers.context?.importDate
|
|
1000
|
-
};
|
|
1001
|
-
}
|
|
1002
|
-
};
|
|
1003
|
-
```
|
|
1004
|
-
|
|
1005
|
-
---
|
|
1006
|
-
|
|
1007
|
-
## Versori Platform: File Processing with KV State Tracking
|
|
1008
|
-
|
|
1009
|
-
**Problem:** Prevent duplicate file processing in scheduled workflows
|
|
1010
|
-
|
|
1011
|
-
**Solution:** Use VersoriKVAdapter for state management
|
|
1012
|
-
|
|
1013
|
-
### Complete Implementation
|
|
1014
|
-
|
|
1015
|
-
```typescript
|
|
1016
|
-
import { schedule } from '@versori/run';
|
|
1017
|
-
import {
|
|
1018
|
-
SftpDataSource,
|
|
1019
|
-
UniversalMapper,
|
|
1020
|
-
VersoriKVAdapter,
|
|
1021
|
-
XMLParserService,
|
|
1022
|
-
createClient
|
|
1023
|
-
} from '@fluentcommerce/fc-connect-sdk';
|
|
1024
|
-
|
|
1025
|
-
export const inventorySync = schedule('inventory-sync', '0 */6 * * *', async (ctx) => {
|
|
1026
|
-
const { log, openKv, activation, credentials } = ctx;
|
|
1027
|
-
|
|
1028
|
-
// 1. Initialize KV state tracking
|
|
1029
|
-
const kv = new VersoriKVAdapter(openKv(':project:'));
|
|
1030
|
-
const processedFilesKey = ['inventory-sync', 'processed-files'];
|
|
1031
|
-
|
|
1032
|
-
// Get list of previously processed files
|
|
1033
|
-
const processed = (await kv.get(processedFilesKey))?.value || [];
|
|
1034
|
-
log.info('Previously processed files', { count: processed.length });
|
|
1035
|
-
|
|
1036
|
-
// 2. Connect to SFTP
|
|
1037
|
-
const sftpCreds = await credentials().getAccessToken('SFTP');
|
|
1038
|
-
const [username, password] = Buffer.from(sftpCreds.accessToken, 'base64')
|
|
1039
|
-
.toString('utf-8')
|
|
1040
|
-
.split(':');
|
|
1041
|
-
|
|
1042
|
-
const sftp = new SftpDataSource({
|
|
1043
|
-
type: 'SFTP_XML',
|
|
1044
|
-
sftpConfig: {
|
|
1045
|
-
host: activation.getVariable('SFTP_HOST'),
|
|
1046
|
-
port: 22,
|
|
1047
|
-
username,
|
|
1048
|
-
password,
|
|
1049
|
-
remotePath: '/inbound/inventory/',
|
|
1050
|
-
filePattern: '*.xml'
|
|
1051
|
-
}
|
|
1052
|
-
}, log);
|
|
1053
|
-
|
|
1054
|
-
await sftp.connect();
|
|
1055
|
-
|
|
1056
|
-
// 3. List files and filter out already processed
|
|
1057
|
-
const allFiles = await sftp.listFiles({
|
|
1058
|
-
remotePath: '/inbound/inventory/',
|
|
1059
|
-
filePattern: '*.xml'
|
|
1060
|
-
});
|
|
1061
|
-
|
|
1062
|
-
const newFiles = allFiles.filter(f => !processed.includes(f.name));
|
|
1063
|
-
log.info('New files to process', {
|
|
1064
|
-
total: allFiles.length,
|
|
1065
|
-
new: newFiles.length
|
|
1066
|
-
});
|
|
1067
|
-
|
|
1068
|
-
// 4. Process each new file
|
|
1069
|
-
const mapper = new UniversalMapper({
|
|
1070
|
-
name: 'inventory.xml.mapping',
|
|
1071
|
-
fields: {
|
|
1072
|
-
retailerId: { source: '$context.retailerId', resolver: 'sdk.parseInt' },
|
|
1073
|
-
locationRef: { source: 'facilityId', resolver: 'sdk.trim' },
|
|
1074
|
-
productRef: { source: 'sku', resolver: 'sdk.trim' },
|
|
1075
|
-
quantity: { source: 'qty', resolver: 'sdk.parseInt' }
|
|
1076
|
-
}
|
|
1077
|
-
}, log);
|
|
1078
|
-
|
|
1079
|
-
const client = await createClient({ ...ctx, log });
|
|
1080
|
-
const retailerId = activation.getVariable('fluentRetailerId');
|
|
1081
|
-
if (!retailerId) {
|
|
1082
|
-
throw new Error('fluentRetailerId required in activation variables');
|
|
1083
|
-
}
|
|
1084
|
-
client.setRetailerId(retailerId);
|
|
1085
|
-
|
|
1086
|
-
const results = [];
|
|
1087
|
-
|
|
1088
|
-
for (const file of newFiles) {
|
|
1089
|
-
try {
|
|
1090
|
-
// Download and parse
|
|
1091
|
-
const content = await sftp.downloadFile(file.path, { encoding: 'utf8' });
|
|
1092
|
-
const parser = new XMLParserService(log);
|
|
1093
|
-
const doc = await parser.parse(content);
|
|
1094
|
-
|
|
1095
|
-
// Map records
|
|
1096
|
-
const records = Array.isArray(doc.inventory.item)
|
|
1097
|
-
? doc.inventory.item
|
|
1098
|
-
: [doc.inventory.item];
|
|
1099
|
-
|
|
1100
|
-
const mappedRecords = [];
|
|
1101
|
-
for (const rec of records) {
|
|
1102
|
-
const result = await mapper.map({
|
|
1103
|
-
...rec,
|
|
1104
|
-
$context: { retailerId }
|
|
1105
|
-
});
|
|
1106
|
-
if (result.success) {
|
|
1107
|
-
mappedRecords.push(result.data);
|
|
1108
|
-
}
|
|
1109
|
-
}
|
|
1110
|
-
|
|
1111
|
-
// Send to Fluent Batch API
|
|
1112
|
-
await client.batch.submit('INVENTORY_QUANTITY', mappedRecords);
|
|
1113
|
-
|
|
1114
|
-
// Mark as processed
|
|
1115
|
-
processed.push(file.name);
|
|
1116
|
-
await kv.set(processedFilesKey, processed);
|
|
1117
|
-
|
|
1118
|
-
// Archive file
|
|
1119
|
-
await sftp.moveFile(
|
|
1120
|
-
file.path,
|
|
1121
|
-
`/archive/inventory/${file.name}`,
|
|
1122
|
-
true
|
|
1123
|
-
);
|
|
1124
|
-
|
|
1125
|
-
results.push({ file: file.name, status: 'success', records: mappedRecords.length });
|
|
1126
|
-
log.info('File processed successfully', { file: file.name });
|
|
1127
|
-
|
|
1128
|
-
} catch (error) {
|
|
1129
|
-
log.error('File processing failed', { file: file.name, error });
|
|
1130
|
-
results.push({ file: file.name, status: 'failed', error: error.message });
|
|
1131
|
-
}
|
|
1132
|
-
}
|
|
1133
|
-
|
|
1134
|
-
await sftp.disconnect();
|
|
1135
|
-
|
|
1136
|
-
return {
|
|
1137
|
-
success: true,
|
|
1138
|
-
filesProcessed: newFiles.length,
|
|
1139
|
-
results
|
|
1140
|
-
};
|
|
1141
|
-
});
|
|
1142
|
-
```
|
|
1143
|
-
|
|
1144
|
-
### Key Points
|
|
1145
|
-
|
|
1146
|
-
- **VersoriKVAdapter:** Persistent state management across workflow runs
|
|
1147
|
-
- **Credential Access:** Use `ctx.credentials()` for secure credential retrieval
|
|
1148
|
-
- **Environment Variables:** Access via `activation.getVariable()`
|
|
1149
|
-
- **File Tracking:** Prevent duplicate processing with KV storage
|
|
1150
|
-
- **Error Handling:** Per-file error handling prevents workflow failure
|
|
1151
|
-
- **Resource Cleanup:** Always call `sftp.disconnect()` to prevent leaks
|
|
1152
|
-
- **Schedule Timeout:** 5 minutes default (adjustable up to 30 minutes)
|
|
1153
|
-
|
|
1154
|
-
### Platform Constraints
|
|
1155
|
-
|
|
1156
|
-
| Resource | Limit | Impact | Mitigation |
|
|
1157
|
-
|----------|-------|--------|------------|
|
|
1158
|
-
| **Schedule Timeout** | 5 minutes (default) | Large file batches may timeout | Process in chunks, use continuation |
|
|
1159
|
-
| **Memory** | ~512MB | Large files may cause OOM | Use streaming parsers, batch processing |
|
|
1160
|
-
| **Concurrent Requests** | ~10 | Parallel API calls limited | Use SDK batch methods, queue processing |
|
|
1161
|
-
|
|
1162
|
-
### State Management Pattern
|
|
1163
|
-
|
|
1164
|
-
```typescript
|
|
1165
|
-
// KV Key Structure
|
|
1166
|
-
['workflow-name', 'state-type']
|
|
1167
|
-
// Examples:
|
|
1168
|
-
['inventory-sync', 'processed-files'] // File tracking
|
|
1169
|
-
['order-import', 'last-cursor'] // Pagination cursor
|
|
1170
|
-
['daily-extract', 'last-run-timestamp'] // Execution tracking
|
|
1171
|
-
```
|
|
1172
|
-
|
|
1173
|
-
### Testing in Versori
|
|
1174
|
-
|
|
1175
|
-
```bash
|
|
1176
|
-
# Trigger schedule manually in Versori UI
|
|
1177
|
-
# OR use CLI:
|
|
1178
|
-
versori schedule trigger inventory-sync
|
|
1179
|
-
|
|
1180
|
-
# Check logs for:
|
|
1181
|
-
# - Previously processed files count
|
|
1182
|
-
# - New files detected
|
|
1183
|
-
# - Processing results
|
|
1184
|
-
# - KV state updates
|
|
1185
|
-
```
|
|
1186
|
-
|
|
1187
|
-
---
|
|
1188
|
-
|
|
1189
|
-
## Key Takeaways
|
|
1190
|
-
|
|
1191
|
-
1. **Nested Objects:** Use dot notation for simple nesting, nested `fields` for complex structures
|
|
1192
|
-
2. **Static Values:** Use `value` property for constants at any nesting level
|
|
1193
|
-
3. **Conditional Logic:** Custom resolvers enable complex business rules
|
|
1194
|
-
4. **Arrays:** Use `isArray: true` to process array elements
|
|
1195
|
-
5. **Async Resolvers:** Enable external lookups but use sparingly for performance
|
|
1196
|
-
6. **Chaining:** Combine transformations via custom resolvers or SDK resolver composition
|
|
1197
|
-
7. **Context:** Pass workflow-specific data via `context` option
|
|
1198
|
-
8. **Versori State Management:** Use VersoriKVAdapter for persistent state across runs
|
|
1199
|
-
9. **Versori Credentials:** Use `ctx.credentials()` for secure access to connection credentials
|
|
1200
|
-
10. **Resource Cleanup:** Always dispose SFTP/database connections to prevent leaks
|
|
1201
|
-
|
|
1202
|
-
---
|
|
1203
|
-
|
|
1204
|
-
## Practice Exercise
|
|
1205
|
-
|
|
1206
|
-
**Task:** Create a mapping for complex order data with nested customer, multiple addresses, and line items.
|
|
1207
|
-
|
|
1208
|
-
**Source Data:**
|
|
1209
|
-
```json
|
|
1210
|
-
{
|
|
1211
|
-
"order_id": "12345",
|
|
1212
|
-
"customer_email": " JOHN@EXAMPLE.COM ",
|
|
1213
|
-
"customer_tier": "premium",
|
|
1214
|
-
"billing_address": {
|
|
1215
|
-
"street": "123 Main St",
|
|
1216
|
-
"city": "New York",
|
|
1217
|
-
"state": "NY",
|
|
1218
|
-
"zip": "10001"
|
|
1219
|
-
},
|
|
1220
|
-
"shipping_address": {
|
|
1221
|
-
"street": "456 Oak Ave",
|
|
1222
|
-
"city": "Boston",
|
|
1223
|
-
"state": "MA",
|
|
1224
|
-
"zip": "02101"
|
|
1225
|
-
},
|
|
1226
|
-
"line_items": [
|
|
1227
|
-
{ "sku": "PROD-001", "qty": "2", "price": "49.99" },
|
|
1228
|
-
{ "sku": "PROD-002", "qty": "1", "price": "99.99" }
|
|
1229
|
-
]
|
|
1230
|
-
}
|
|
1231
|
-
```
|
|
1232
|
-
|
|
1233
|
-
**Requirements:**
|
|
1234
|
-
1. Clean and normalize customer email
|
|
1235
|
-
2. Map both billing and shipping addresses
|
|
1236
|
-
3. Process line items array
|
|
1237
|
-
4. Calculate item totals
|
|
1238
|
-
5. Determine priority based on customer tier
|
|
1239
|
-
|
|
1240
|
-
<details>
|
|
1241
|
-
<summary>Click to see solution</summary>
|
|
1242
|
-
|
|
1243
|
-
```json
|
|
1244
|
-
{
|
|
1245
|
-
"fields": {
|
|
1246
|
-
"ref": {
|
|
1247
|
-
"source": "order_id"
|
|
1248
|
-
},
|
|
1249
|
-
"customer": {
|
|
1250
|
-
"fields": {
|
|
1251
|
-
"email": {
|
|
1252
|
-
"source": "customer_email",
|
|
1253
|
-
"resolver": "custom.cleanEmail"
|
|
1254
|
-
}
|
|
1255
|
-
}
|
|
1256
|
-
},
|
|
1257
|
-
"priority": {
|
|
1258
|
-
"source": "customer_tier",
|
|
1259
|
-
"resolver": "custom.calculatePriority"
|
|
1260
|
-
},
|
|
1261
|
-
"billingAddress": {
|
|
1262
|
-
"fields": {
|
|
1263
|
-
"street": {
|
|
1264
|
-
"source": "billing_address.street"
|
|
1265
|
-
},
|
|
1266
|
-
"city": {
|
|
1267
|
-
"source": "billing_address.city"
|
|
1268
|
-
},
|
|
1269
|
-
"state": {
|
|
1270
|
-
"source": "billing_address.state"
|
|
1271
|
-
},
|
|
1272
|
-
"zipCode": {
|
|
1273
|
-
"source": "billing_address.zip"
|
|
1274
|
-
}
|
|
1275
|
-
}
|
|
1276
|
-
},
|
|
1277
|
-
"shippingAddress": {
|
|
1278
|
-
"fields": {
|
|
1279
|
-
"street": {
|
|
1280
|
-
"source": "shipping_address.street"
|
|
1281
|
-
},
|
|
1282
|
-
"city": {
|
|
1283
|
-
"source": "shipping_address.city"
|
|
1284
|
-
},
|
|
1285
|
-
"state": {
|
|
1286
|
-
"source": "shipping_address.state"
|
|
1287
|
-
},
|
|
1288
|
-
"zipCode": {
|
|
1289
|
-
"source": "shipping_address.zip"
|
|
1290
|
-
}
|
|
1291
|
-
}
|
|
1292
|
-
},
|
|
1293
|
-
"items": {
|
|
1294
|
-
"source": "line_items",
|
|
1295
|
-
"isArray": true,
|
|
1296
|
-
"fields": {
|
|
1297
|
-
"productRef": {
|
|
1298
|
-
"source": "sku"
|
|
1299
|
-
},
|
|
1300
|
-
"quantity": {
|
|
1301
|
-
"source": "qty",
|
|
1302
|
-
"resolver": "sdk.parseInt"
|
|
1303
|
-
},
|
|
1304
|
-
"price": {
|
|
1305
|
-
"source": "price",
|
|
1306
|
-
"resolver": "sdk.parseFloat"
|
|
1307
|
-
},
|
|
1308
|
-
"totalPrice": {
|
|
1309
|
-
"resolver": "custom.calculateItemTotal"
|
|
1310
|
-
}
|
|
1311
|
-
}
|
|
1312
|
-
}
|
|
1313
|
-
}
|
|
1314
|
-
}
|
|
1315
|
-
```
|
|
1316
|
-
|
|
1317
|
-
**Custom Resolvers:**
|
|
1318
|
-
```typescript
|
|
1319
|
-
const customResolvers = {
|
|
1320
|
-
'custom.cleanEmail': (value: string, sourceData: any, config: any, helpers: any) => {
|
|
1321
|
-
return helpers.normalizeWhitespace(value).toLowerCase();
|
|
1322
|
-
},
|
|
1323
|
-
|
|
1324
|
-
'custom.calculatePriority': (tier: string) => {
|
|
1325
|
-
return tier === 'premium' ? 'HIGH' : 'NORMAL';
|
|
1326
|
-
},
|
|
1327
|
-
|
|
1328
|
-
'custom.calculateItemTotal': (value: any, sourceData: any, config: any, helpers: any) => {
|
|
1329
|
-
const qty = helpers.parseIntSafe(sourceData.qty, 0);
|
|
1330
|
-
const price = helpers.parseFloatSafe(sourceData.price, 0);
|
|
1331
|
-
return qty * price;
|
|
1332
|
-
}
|
|
1333
|
-
};
|
|
1334
|
-
```
|
|
1335
|
-
|
|
1336
|
-
</details>
|
|
1337
|
-
|
|
1338
|
-
---
|
|
1339
|
-
|
|
1340
|
-
## Next Steps
|
|
1341
|
-
|
|
1342
|
-
Now that you've mastered advanced patterns, explore the helper functions available:
|
|
1343
|
-
|
|
1344
|
-
→ Continue to [Module 6: Helpers & Resolvers](./mapping-06-helpers-resolvers.md)
|
|
1345
|
-
|
|
1346
|
-
**Alternative paths:**
|
|
1347
|
-
- Jump to [API Reference](../../auto-pagination/modules/auto-pagination-07-api-reference.md) for TypeScript usage
|
|
1348
|
-
- Review [GraphQL Mutation Mapping Quick Reference](../graphql-mutation-mapping/graphql-mutation-mapping-quick-reference.md) for a comprehensive cheat sheet
|
|
1349
|
-
|
|
1350
|
-
---
|
|
1351
|
-
|
|
1352
|
-
[← Back to Use Cases](./mapping-04-use-cases.md) | [Next: Helpers & Resolvers →](./mapping-06-helpers-resolvers.md)
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1
|
+
# Module 5: Advanced Patterns
|
|
2
|
+
|
|
3
|
+
**Master complex transformations**
|
|
4
|
+
|
|
5
|
+
**Level:** Advanced
|
|
6
|
+
**Time:** 40 minutes
|
|
7
|
+
**Prerequisites:** [Module 4: Use Cases](./mapping-04-use-cases.md)
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## Learning Objectives
|
|
12
|
+
|
|
13
|
+
After completing this module, you will:
|
|
14
|
+
- ✅ Map complex nested object structures
|
|
15
|
+
- ✅ Process arrays with `isArray` configuration
|
|
16
|
+
- ✅ Implement conditional transformations
|
|
17
|
+
- ✅ Use async resolvers for external lookups
|
|
18
|
+
- ✅ Chain multiple transformations
|
|
19
|
+
- ✅ Handle GraphQL edges/nodes pagination patterns
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## Nested Objects
|
|
24
|
+
|
|
25
|
+
Map nested structures using dot notation or nested `fields` configuration.
|
|
26
|
+
|
|
27
|
+
### Method 1: Dot Notation (Flat Mapping)
|
|
28
|
+
|
|
29
|
+
```json
|
|
30
|
+
{
|
|
31
|
+
"fields": {
|
|
32
|
+
"customer.id": {
|
|
33
|
+
"source": "customerId",
|
|
34
|
+
"resolver": "sdk.parseInt"
|
|
35
|
+
},
|
|
36
|
+
"customer.email": {
|
|
37
|
+
"source": "email",
|
|
38
|
+
"resolver": "sdk.lowercase"
|
|
39
|
+
},
|
|
40
|
+
"customer.address.city": {
|
|
41
|
+
"source": "city"
|
|
42
|
+
},
|
|
43
|
+
"customer.address.state": {
|
|
44
|
+
"source": "state"
|
|
45
|
+
},
|
|
46
|
+
"customer.address.zipCode": {
|
|
47
|
+
"source": "zip"
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
**Output:**
|
|
54
|
+
```json
|
|
55
|
+
{
|
|
56
|
+
"customer": {
|
|
57
|
+
"id": 12345,
|
|
58
|
+
"email": "john@example.com",
|
|
59
|
+
"address": {
|
|
60
|
+
"city": "New York",
|
|
61
|
+
"state": "NY",
|
|
62
|
+
"zipCode": "10001"
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### Method 2: Nested Fields (Structured Mapping)
|
|
69
|
+
|
|
70
|
+
```json
|
|
71
|
+
{
|
|
72
|
+
"fields": {
|
|
73
|
+
"customer": {
|
|
74
|
+
"fields": {
|
|
75
|
+
"id": {
|
|
76
|
+
"source": "customerId",
|
|
77
|
+
"resolver": "sdk.parseInt"
|
|
78
|
+
},
|
|
79
|
+
"email": {
|
|
80
|
+
"source": "email",
|
|
81
|
+
"resolver": "sdk.lowercase"
|
|
82
|
+
},
|
|
83
|
+
"address": {
|
|
84
|
+
"fields": {
|
|
85
|
+
"city": {
|
|
86
|
+
"source": "city"
|
|
87
|
+
},
|
|
88
|
+
"state": {
|
|
89
|
+
"source": "state"
|
|
90
|
+
},
|
|
91
|
+
"zipCode": {
|
|
92
|
+
"source": "zip"
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
**Same output** as Method 1, but more structured and easier to read for complex objects.
|
|
103
|
+
|
|
104
|
+
### When to Use Each Method
|
|
105
|
+
|
|
106
|
+
| Method | Best For | Advantages | Disadvantages |
|
|
107
|
+
|--------|----------|------------|---------------|
|
|
108
|
+
| **Dot Notation** | Simple nesting (2-3 levels) | Concise, flat structure | Hard to read for deep nesting |
|
|
109
|
+
| **Nested Fields** | Complex objects, reusable components | Clear hierarchy, organized | More verbose |
|
|
110
|
+
|
|
111
|
+
### Deep Nesting Example
|
|
112
|
+
|
|
113
|
+
```json
|
|
114
|
+
{
|
|
115
|
+
"fields": {
|
|
116
|
+
"order": {
|
|
117
|
+
"fields": {
|
|
118
|
+
"ref": {
|
|
119
|
+
"source": "orderRef"
|
|
120
|
+
},
|
|
121
|
+
"customer": {
|
|
122
|
+
"fields": {
|
|
123
|
+
"id": {
|
|
124
|
+
"source": "customerId"
|
|
125
|
+
},
|
|
126
|
+
"profile": {
|
|
127
|
+
"fields": {
|
|
128
|
+
"firstName": {
|
|
129
|
+
"source": "firstName"
|
|
130
|
+
},
|
|
131
|
+
"lastName": {
|
|
132
|
+
"source": "lastName"
|
|
133
|
+
},
|
|
134
|
+
"preferences": {
|
|
135
|
+
"fields": {
|
|
136
|
+
"language": {
|
|
137
|
+
"source": "lang",
|
|
138
|
+
"defaultValue": "en"
|
|
139
|
+
},
|
|
140
|
+
"currency": {
|
|
141
|
+
"source": "currency",
|
|
142
|
+
"defaultValue": "USD"
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
---
|
|
157
|
+
|
|
158
|
+
## Static Values
|
|
159
|
+
|
|
160
|
+
Provide constant values at any nesting level.
|
|
161
|
+
|
|
162
|
+
### Simple Static Values
|
|
163
|
+
|
|
164
|
+
```json
|
|
165
|
+
{
|
|
166
|
+
"fields": {
|
|
167
|
+
"type": {
|
|
168
|
+
"value": "STANDARD"
|
|
169
|
+
},
|
|
170
|
+
"retailerId": {
|
|
171
|
+
"value": 1
|
|
172
|
+
},
|
|
173
|
+
"source": {
|
|
174
|
+
"value": "CSV_IMPORT"
|
|
175
|
+
},
|
|
176
|
+
"status": {
|
|
177
|
+
"value": "ACTIVE"
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
### Static Values in Nested Objects
|
|
184
|
+
|
|
185
|
+
```json
|
|
186
|
+
{
|
|
187
|
+
"fields": {
|
|
188
|
+
"retailer": {
|
|
189
|
+
"fields": {
|
|
190
|
+
"id": {
|
|
191
|
+
"value": "1",
|
|
192
|
+
"comment": "Static retailer ID"
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
},
|
|
196
|
+
"metadata": {
|
|
197
|
+
"fields": {
|
|
198
|
+
"importSource": {
|
|
199
|
+
"value": "SFCC"
|
|
200
|
+
},
|
|
201
|
+
"importVersion": {
|
|
202
|
+
"value": "2.0"
|
|
203
|
+
},
|
|
204
|
+
"importTimestamp": {
|
|
205
|
+
"resolver": "custom.currentTimestamp"
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
---
|
|
214
|
+
|
|
215
|
+
## Conditional Transformations
|
|
216
|
+
|
|
217
|
+
Use custom resolvers for complex business logic and conditional mapping.
|
|
218
|
+
|
|
219
|
+
### Simple Conditional Mapping
|
|
220
|
+
|
|
221
|
+
```json
|
|
222
|
+
{
|
|
223
|
+
"fields": {
|
|
224
|
+
"shippingMethod": {
|
|
225
|
+
"source": "deliverySpeed",
|
|
226
|
+
"resolver": "custom.mapShipping"
|
|
227
|
+
},
|
|
228
|
+
"priority": {
|
|
229
|
+
"source": "orderType",
|
|
230
|
+
"resolver": "custom.calculatePriority"
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
**Custom Resolvers:**
|
|
237
|
+
```typescript
|
|
238
|
+
const customResolvers = {
|
|
239
|
+
'custom.mapShipping': (value: string) => {
|
|
240
|
+
// Map delivery speed to shipping method
|
|
241
|
+
const shippingMap: Record<string, string> = {
|
|
242
|
+
'express': 'OVERNIGHT',
|
|
243
|
+
'fast': 'TWO_DAY',
|
|
244
|
+
'standard': 'GROUND',
|
|
245
|
+
'economy': 'STANDARD'
|
|
246
|
+
};
|
|
247
|
+
return shippingMap[value.toLowerCase()] || 'STANDARD';
|
|
248
|
+
},
|
|
249
|
+
|
|
250
|
+
'custom.calculatePriority': (value: string, sourceData: any) => {
|
|
251
|
+
// Calculate priority based on order type and total
|
|
252
|
+
const isPremium = sourceData.customerTier === 'PREMIUM';
|
|
253
|
+
const isUrgent = value === 'EXPRESS';
|
|
254
|
+
const isLargeOrder = sourceData.total > 1000;
|
|
255
|
+
|
|
256
|
+
if (isUrgent || isPremium) return 'HIGH';
|
|
257
|
+
if (isLargeOrder) return 'MEDIUM';
|
|
258
|
+
return 'LOW';
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
### Multi-Condition Resolver
|
|
264
|
+
|
|
265
|
+
```typescript
|
|
266
|
+
const customResolvers = {
|
|
267
|
+
'custom.determineOrderType': (value: any, sourceData: any, config: any, helpers: any) => {
|
|
268
|
+
const total = helpers.parseFloatSafe(sourceData.total, 0);
|
|
269
|
+
const itemCount = helpers.parseIntSafe(sourceData.itemCount, 0);
|
|
270
|
+
const isInternational = sourceData.country !== 'US';
|
|
271
|
+
|
|
272
|
+
// Complex business rules
|
|
273
|
+
if (isInternational && total > 500) {
|
|
274
|
+
return 'INTERNATIONAL_PREMIUM';
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (itemCount > 20 || total > 1000) {
|
|
278
|
+
return 'BULK';
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (sourceData.expedited === true) {
|
|
282
|
+
return 'EXPRESS';
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return 'STANDARD';
|
|
286
|
+
}
|
|
287
|
+
};
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
---
|
|
291
|
+
|
|
292
|
+
## Arrays
|
|
293
|
+
|
|
294
|
+
Map arrays of items using the `isArray` field configuration.
|
|
295
|
+
|
|
296
|
+
### Array Mapping with `isArray`
|
|
297
|
+
|
|
298
|
+
```json
|
|
299
|
+
{
|
|
300
|
+
"fields": {
|
|
301
|
+
"items": {
|
|
302
|
+
"source": "orderItems",
|
|
303
|
+
"isArray": true,
|
|
304
|
+
"fields": {
|
|
305
|
+
"ref": {
|
|
306
|
+
"source": "id"
|
|
307
|
+
},
|
|
308
|
+
"productRef": {
|
|
309
|
+
"source": "sku"
|
|
310
|
+
},
|
|
311
|
+
"quantity": {
|
|
312
|
+
"source": "qty",
|
|
313
|
+
"resolver": "sdk.parseInt"
|
|
314
|
+
},
|
|
315
|
+
"price": {
|
|
316
|
+
"source": "unitPrice",
|
|
317
|
+
"resolver": "sdk.parseFloat"
|
|
318
|
+
},
|
|
319
|
+
"totalPrice": {
|
|
320
|
+
"resolver": "custom.calculateItemTotal"
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
### How `isArray` Works
|
|
329
|
+
|
|
330
|
+
1. **Extract Array:** `source: "orderItems"` extracts the array from source data
|
|
331
|
+
2. **Iterate:** `isArray: true` tells mapper to iterate over array elements
|
|
332
|
+
3. **Map Each Item:** `fields: {...}` defines mappings for each array element
|
|
333
|
+
4. **Context Switch:** ⚠️ **THIS IS THE KEY CONCEPT**
|
|
334
|
+
|
|
335
|
+
Once inside `fields` of an `isArray` mapping, the context changes:
|
|
336
|
+
|
|
337
|
+
```mermaid
|
|
338
|
+
flowchart TD
|
|
339
|
+
A[Root Context<br/>orderItems: [...],<br/>customerId: 123] -->|Iterate Array| B[Array Item Context<br/>id: 'ITEM-1'<br/>sku: 'PROD-001'<br/>qty: '2']
|
|
340
|
+
B -->|Map Fields| C[Field Mappings<br/>source: 'id'<br/>→ Reads from item,<br/>NOT root]
|
|
341
|
+
|
|
342
|
+
style A fill:#e1f5ff
|
|
343
|
+
style B fill:#fff4e1
|
|
344
|
+
style C fill:#e8f5e9
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
**Text Version:**
|
|
348
|
+
```
|
|
349
|
+
Root Context: { orderItems: [...], customerId: 123 }
|
|
350
|
+
↓ iterate
|
|
351
|
+
Array Item: { id: "ITEM-1", sku: "PROD-001", qty: "2" } ← NEW CONTEXT!
|
|
352
|
+
↓ map fields
|
|
353
|
+
Field Mappings: "source": "id" reads from THIS object, not root
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
**In other words:**
|
|
357
|
+
- `"source": "id"` → reads `currentArrayItem.id`
|
|
358
|
+
- `"source": "sku"` → reads `currentArrayItem.sku`
|
|
359
|
+
- NOT `sourceData.id` or `sourceData.sku`
|
|
360
|
+
|
|
361
|
+
**Source Data:**
|
|
362
|
+
```json
|
|
363
|
+
{
|
|
364
|
+
"orderItems": [
|
|
365
|
+
{ "id": "ITEM-1", "sku": "PROD-001", "qty": "2", "unitPrice": "49.99" },
|
|
366
|
+
{ "id": "ITEM-2", "sku": "PROD-002", "qty": "1", "unitPrice": "99.99" }
|
|
367
|
+
]
|
|
368
|
+
}
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
**Mapped Output:**
|
|
372
|
+
```json
|
|
373
|
+
{
|
|
374
|
+
"items": [
|
|
375
|
+
{ "ref": "ITEM-1", "productRef": "PROD-001", "quantity": 2, "price": 49.99, "totalPrice": 99.98 },
|
|
376
|
+
{ "ref": "ITEM-2", "productRef": "PROD-002", "quantity": 1, "price": 99.99, "totalPrice": 99.99 }
|
|
377
|
+
]
|
|
378
|
+
}
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
### Common Mistake: Don't Repeat Array Path
|
|
382
|
+
|
|
383
|
+
**❌ WRONG - Repeating array path in nested fields:**
|
|
384
|
+
```json
|
|
385
|
+
{
|
|
386
|
+
"items": {
|
|
387
|
+
"source": "orderItems",
|
|
388
|
+
"isArray": true,
|
|
389
|
+
"fields": {
|
|
390
|
+
"ref": {
|
|
391
|
+
"source": "orderItems[0].id" // ❌ Already inside array context!
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
**✅ CORRECT - Use paths relative to array item:**
|
|
399
|
+
```json
|
|
400
|
+
{
|
|
401
|
+
"items": {
|
|
402
|
+
"source": "orderItems",
|
|
403
|
+
"isArray": true,
|
|
404
|
+
"fields": {
|
|
405
|
+
"ref": {
|
|
406
|
+
"source": "id" // ✅ Reads from current array item
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
### Accessing Array Item Context
|
|
414
|
+
|
|
415
|
+
Within array field mappings, use `source` paths relative to the array item:
|
|
416
|
+
|
|
417
|
+
```json
|
|
418
|
+
{
|
|
419
|
+
"items": {
|
|
420
|
+
"source": "orderItems",
|
|
421
|
+
"isArray": true,
|
|
422
|
+
"fields": {
|
|
423
|
+
"ref": {
|
|
424
|
+
"source": "id" // ← Reads from current array item's 'id' field
|
|
425
|
+
},
|
|
426
|
+
"productRef": {
|
|
427
|
+
"source": "sku" // ← Reads from current array item's 'sku' field
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
### GraphQL Edges/Nodes Pattern
|
|
435
|
+
|
|
436
|
+
**IMPORTANT:** The asterisk `[*]` is optional. Both patterns work:
|
|
437
|
+
|
|
438
|
+
**Option 1: With wildcard (extracts nodes directly)**
|
|
439
|
+
```json
|
|
440
|
+
{
|
|
441
|
+
"fields": {
|
|
442
|
+
"products": {
|
|
443
|
+
"source": "products.edges[*].node", // Wildcard extracts nodes
|
|
444
|
+
"isArray": true,
|
|
445
|
+
"fields": {
|
|
446
|
+
"id": { "source": "id" }, // Direct access (no "node." prefix)
|
|
447
|
+
"ref": { "source": "ref" },
|
|
448
|
+
"name": { "source": "name" },
|
|
449
|
+
"price": { "source": "price", "resolver": "sdk.parseFloat" }
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
```
|
|
455
|
+
|
|
456
|
+
**Option 2: Without wildcard (extracts edges)**
|
|
457
|
+
```json
|
|
458
|
+
{
|
|
459
|
+
"fields": {
|
|
460
|
+
"products": {
|
|
461
|
+
"source": "products.edges", // Direct array access
|
|
462
|
+
"isArray": true,
|
|
463
|
+
"fields": {
|
|
464
|
+
"id": { "source": "node.id" }, // Need "node." prefix
|
|
465
|
+
"ref": { "source": "node.ref" },
|
|
466
|
+
"name": { "source": "node.name" },
|
|
467
|
+
"price": { "source": "node.price", "resolver": "sdk.parseFloat" }
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
**Option 3: Auto-detection (SDK detects GraphQL structure)**
|
|
475
|
+
```json
|
|
476
|
+
{
|
|
477
|
+
"fields": {
|
|
478
|
+
"products": {
|
|
479
|
+
"source": "products", // SDK auto-detects { edges: [...] }
|
|
480
|
+
"isArray": true,
|
|
481
|
+
"fields": {
|
|
482
|
+
"id": { "source": "id" }, // Direct access (nodes auto-extracted)
|
|
483
|
+
"ref": { "source": "ref" },
|
|
484
|
+
"name": { "source": "name" },
|
|
485
|
+
"price": { "source": "price", "resolver": "sdk.parseFloat" }
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
```
|
|
491
|
+
|
|
492
|
+
**All three produce the same result** - choose based on clarity for your use case
|
|
493
|
+
|
|
494
|
+
**Source Data (GraphQL):**
|
|
495
|
+
```json
|
|
496
|
+
{
|
|
497
|
+
"products": {
|
|
498
|
+
"edges": [
|
|
499
|
+
{ "cursor": "abc123", "node": { "id": "1", "ref": "PROD-001", "name": "Widget", "price": 19.99 } },
|
|
500
|
+
{ "cursor": "def456", "node": { "id": "2", "ref": "PROD-002", "name": "Gadget", "price": 29.99 } }
|
|
501
|
+
]
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
```
|
|
505
|
+
|
|
506
|
+
**Mapped Output:**
|
|
507
|
+
```json
|
|
508
|
+
{
|
|
509
|
+
"products": [
|
|
510
|
+
{ "id": "1", "ref": "PROD-001", "name": "Widget", "price": 19.99 },
|
|
511
|
+
{ "id": "2", "ref": "PROD-002", "name": "Gadget", "price": 29.99 }
|
|
512
|
+
]
|
|
513
|
+
}
|
|
514
|
+
```
|
|
515
|
+
|
|
516
|
+
### Nested Arrays
|
|
517
|
+
|
|
518
|
+
Arrays within arrays require nested `isArray` configuration. This is common when parsing XML with complex nested structures.
|
|
519
|
+
|
|
520
|
+
#### XML Example with Nested Arrays
|
|
521
|
+
|
|
522
|
+
**Sample XML:**
|
|
523
|
+
```xml
|
|
524
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
525
|
+
<Orders>
|
|
526
|
+
<Order id="ORD-001" orderDate="2025-01-22">
|
|
527
|
+
<customer>
|
|
528
|
+
<name>John Doe</name>
|
|
529
|
+
<email>John@Example.com</email>
|
|
530
|
+
</customer>
|
|
531
|
+
<items>
|
|
532
|
+
<item sku="PROD-001" qty="2">
|
|
533
|
+
<price>29.99</price>
|
|
534
|
+
<attributes>
|
|
535
|
+
<attribute name="color" value="Blue"/>
|
|
536
|
+
<attribute name="size" value="M"/>
|
|
537
|
+
</attributes>
|
|
538
|
+
</item>
|
|
539
|
+
<item sku="PROD-002" qty="1">
|
|
540
|
+
<price>49.99</price>
|
|
541
|
+
<attributes>
|
|
542
|
+
<attribute name="color" value="Red"/>
|
|
543
|
+
<attribute name="size" value="L"/>
|
|
544
|
+
</attributes>
|
|
545
|
+
</item>
|
|
546
|
+
</items>
|
|
547
|
+
</Order>
|
|
548
|
+
<Order id="ORD-002" orderDate="2025-01-23">
|
|
549
|
+
<customer>
|
|
550
|
+
<name>Jane Smith</name>
|
|
551
|
+
<email>jane@example.com</email>
|
|
552
|
+
</customer>
|
|
553
|
+
<items>
|
|
554
|
+
<item sku="PROD-003" qty="3">
|
|
555
|
+
<price>19.99</price>
|
|
556
|
+
<attributes>
|
|
557
|
+
<attribute name="color" value="Green"/>
|
|
558
|
+
</attributes>
|
|
559
|
+
</item>
|
|
560
|
+
</items>
|
|
561
|
+
</Order>
|
|
562
|
+
</Orders>
|
|
563
|
+
```
|
|
564
|
+
|
|
565
|
+
**After XML Parsing (fast-xml-parser):**
|
|
566
|
+
```json
|
|
567
|
+
{
|
|
568
|
+
"Orders": {
|
|
569
|
+
"Order": [
|
|
570
|
+
{
|
|
571
|
+
"@id": "ORD-001",
|
|
572
|
+
"@orderDate": "2025-01-22",
|
|
573
|
+
"customer": {
|
|
574
|
+
"name": "John Doe",
|
|
575
|
+
"email": "John@Example.com"
|
|
576
|
+
},
|
|
577
|
+
"items": {
|
|
578
|
+
"item": [
|
|
579
|
+
{
|
|
580
|
+
"@sku": "PROD-001",
|
|
581
|
+
"@qty": "2",
|
|
582
|
+
"price": "29.99",
|
|
583
|
+
"attributes": {
|
|
584
|
+
"attribute": [
|
|
585
|
+
{ "@name": "color", "@value": "Blue" },
|
|
586
|
+
{ "@name": "size", "@value": "M" }
|
|
587
|
+
]
|
|
588
|
+
}
|
|
589
|
+
},
|
|
590
|
+
{
|
|
591
|
+
"@sku": "PROD-002",
|
|
592
|
+
"@qty": "1",
|
|
593
|
+
"price": "49.99",
|
|
594
|
+
"attributes": {
|
|
595
|
+
"attribute": [
|
|
596
|
+
{ "@name": "color", "@value": "Red" },
|
|
597
|
+
{ "@name": "size", "@value": "L" }
|
|
598
|
+
]
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
]
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
]
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
```
|
|
608
|
+
|
|
609
|
+
**Mapping Configuration:**
|
|
610
|
+
```json
|
|
611
|
+
{
|
|
612
|
+
"fields": {
|
|
613
|
+
"orders": {
|
|
614
|
+
"source": "Orders.Order",
|
|
615
|
+
"isArray": true,
|
|
616
|
+
"fields": {
|
|
617
|
+
"orderRef": {
|
|
618
|
+
"source": "@id",
|
|
619
|
+
"required": true
|
|
620
|
+
},
|
|
621
|
+
"orderDate": {
|
|
622
|
+
"source": "@orderDate",
|
|
623
|
+
"resolver": "sdk.parseDate"
|
|
624
|
+
},
|
|
625
|
+
"customerName": {
|
|
626
|
+
"source": "customer.name",
|
|
627
|
+
"resolver": "sdk.trim"
|
|
628
|
+
},
|
|
629
|
+
"customerEmail": {
|
|
630
|
+
"source": "customer.email",
|
|
631
|
+
"resolver": "sdk.lowercase"
|
|
632
|
+
},
|
|
633
|
+
"items": {
|
|
634
|
+
"source": "items.item",
|
|
635
|
+
"isArray": true,
|
|
636
|
+
"comment": "Nested array: items within orders",
|
|
637
|
+
"fields": {
|
|
638
|
+
"productRef": {
|
|
639
|
+
"source": "@sku",
|
|
640
|
+
"required": true
|
|
641
|
+
},
|
|
642
|
+
"quantity": {
|
|
643
|
+
"source": "@qty",
|
|
644
|
+
"resolver": "sdk.parseInt"
|
|
645
|
+
},
|
|
646
|
+
"price": {
|
|
647
|
+
"source": "price",
|
|
648
|
+
"resolver": "sdk.parseFloat"
|
|
649
|
+
},
|
|
650
|
+
"attributes": {
|
|
651
|
+
"source": "attributes.attribute",
|
|
652
|
+
"isArray": true,
|
|
653
|
+
"comment": "Nested array: attributes within items",
|
|
654
|
+
"fields": {
|
|
655
|
+
"name": {
|
|
656
|
+
"source": "@name",
|
|
657
|
+
"resolver": "sdk.trim"
|
|
658
|
+
},
|
|
659
|
+
"value": {
|
|
660
|
+
"source": "@value",
|
|
661
|
+
"resolver": "sdk.trim"
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
```
|
|
672
|
+
|
|
673
|
+
**TypeScript Implementation:**
|
|
674
|
+
```typescript
|
|
675
|
+
import { UniversalMapper } from '@fluentcommerce/fc-connect-sdk';
|
|
676
|
+
import { XMLParserService } from '@fluentcommerce/fc-connect-sdk';
|
|
677
|
+
|
|
678
|
+
// Parse XML
|
|
679
|
+
const parser = new XMLParserService();
|
|
680
|
+
const parsedXml = await parser.parse(xmlContent);
|
|
681
|
+
|
|
682
|
+
// Normalize array (XML parser returns object for single element, array for multiple)
|
|
683
|
+
const ordersData = Array.isArray(parsedXml?.Orders?.Order)
|
|
684
|
+
? parsedXml.Orders.Order
|
|
685
|
+
: parsedXml?.Orders?.Order
|
|
686
|
+
? [parsedXml.Orders.Order]
|
|
687
|
+
: [];
|
|
688
|
+
|
|
689
|
+
// Create mapper
|
|
690
|
+
const mapper = new UniversalMapper(mappingConfig);
|
|
691
|
+
|
|
692
|
+
// Map each order
|
|
693
|
+
const mappedOrders = [];
|
|
694
|
+
for (const order of ordersData) {
|
|
695
|
+
const result = await mapper.map({ Order: order });
|
|
696
|
+
mappedOrders.push(result.data);
|
|
697
|
+
}
|
|
698
|
+
```
|
|
699
|
+
|
|
700
|
+
**Mapped Output:**
|
|
701
|
+
```json
|
|
702
|
+
{
|
|
703
|
+
"orders": [
|
|
704
|
+
{
|
|
705
|
+
"orderRef": "ORD-001",
|
|
706
|
+
"orderDate": "2025-01-22T00:00:00.000Z",
|
|
707
|
+
"customerName": "John Doe",
|
|
708
|
+
"customerEmail": "john@example.com",
|
|
709
|
+
"items": [
|
|
710
|
+
{
|
|
711
|
+
"productRef": "PROD-001",
|
|
712
|
+
"quantity": 2,
|
|
713
|
+
"price": 29.99,
|
|
714
|
+
"attributes": [
|
|
715
|
+
{ "name": "color", "value": "Blue" },
|
|
716
|
+
{ "name": "size", "value": "M" }
|
|
717
|
+
]
|
|
718
|
+
},
|
|
719
|
+
{
|
|
720
|
+
"productRef": "PROD-002",
|
|
721
|
+
"quantity": 1,
|
|
722
|
+
"price": 49.99,
|
|
723
|
+
"attributes": [
|
|
724
|
+
{ "name": "color", "value": "Red" },
|
|
725
|
+
{ "name": "size", "value": "L" }
|
|
726
|
+
]
|
|
727
|
+
}
|
|
728
|
+
]
|
|
729
|
+
},
|
|
730
|
+
{
|
|
731
|
+
"orderRef": "ORD-002",
|
|
732
|
+
"orderDate": "2025-01-23T00:00:00.000Z",
|
|
733
|
+
"customerName": "Jane Smith",
|
|
734
|
+
"customerEmail": "jane@example.com",
|
|
735
|
+
"items": [
|
|
736
|
+
{
|
|
737
|
+
"productRef": "PROD-003",
|
|
738
|
+
"quantity": 3,
|
|
739
|
+
"price": 19.99,
|
|
740
|
+
"attributes": [
|
|
741
|
+
{ "name": "color", "value": "Green" }
|
|
742
|
+
]
|
|
743
|
+
}
|
|
744
|
+
]
|
|
745
|
+
}
|
|
746
|
+
]
|
|
747
|
+
}
|
|
748
|
+
```
|
|
749
|
+
|
|
750
|
+
**Key Points:**
|
|
751
|
+
1. **XML Attributes**: Use `@` prefix (`@id`, `@sku`, `@name`, `@value`)
|
|
752
|
+
2. **Nested Arrays**: Each level requires `isArray: true` with nested `fields`
|
|
753
|
+
3. **Resolvers**: Apply appropriate resolvers (`sdk.parseInt`, `sdk.parseFloat`, `sdk.parseDate`, `sdk.lowercase`, `sdk.trim`)
|
|
754
|
+
4. **Context Switching**: Within array `fields`, `source` paths are relative to each array item
|
|
755
|
+
5. **Array Normalization**: Always normalize XML arrays before mapping (single element → object, multiple → array)
|
|
756
|
+
|
|
757
|
+
---
|
|
758
|
+
|
|
759
|
+
## Async Resolvers
|
|
760
|
+
|
|
761
|
+
Resolvers can be async for external lookups and API calls.
|
|
762
|
+
|
|
763
|
+
### Customer Lookup Example
|
|
764
|
+
|
|
765
|
+
```typescript
|
|
766
|
+
const customResolvers = {
|
|
767
|
+
'custom.lookupCustomer': async (email: string, sourceData: any, config: any, helpers: any) => {
|
|
768
|
+
// Ensure fluentClient is available
|
|
769
|
+
if (!helpers.fluentClient) {
|
|
770
|
+
throw new Error('fluentClient not available - pass via UniversalMapper options');
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
// Query Fluent API to find customer by email
|
|
774
|
+
const query = `
|
|
775
|
+
query GetCustomerByEmail($email: String!) {
|
|
776
|
+
customers(email: $email) {
|
|
777
|
+
edges {
|
|
778
|
+
node {
|
|
779
|
+
id
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
`;
|
|
785
|
+
|
|
786
|
+
const result = await helpers.fluentClient.graphql({
|
|
787
|
+
query,
|
|
788
|
+
variables: { email }
|
|
789
|
+
});
|
|
790
|
+
|
|
791
|
+
const customerId = result.data.customers.edges[0]?.node.id;
|
|
792
|
+
|
|
793
|
+
if (!customerId) {
|
|
794
|
+
throw new Error(`Customer not found for email: ${email}`);
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
return customerId;
|
|
798
|
+
}
|
|
799
|
+
};
|
|
800
|
+
|
|
801
|
+
// Create mapper with fluentClient option
|
|
802
|
+
const mapper = new UniversalMapper(mappingConfig, {
|
|
803
|
+
customResolvers,
|
|
804
|
+
fluentClient: fluentClient // ✅ Makes client available in helpers
|
|
805
|
+
});
|
|
806
|
+
```
|
|
807
|
+
|
|
808
|
+
### Product Enrichment Example
|
|
809
|
+
|
|
810
|
+
```typescript
|
|
811
|
+
const customResolvers = {
|
|
812
|
+
'custom.enrichProduct': async (sku: string, sourceData: any, config: any, helpers: any) => {
|
|
813
|
+
// Fetch product details from external API
|
|
814
|
+
const response = await fetch(`https://api.example.com/products/${sku}`);
|
|
815
|
+
const product = await response.json();
|
|
816
|
+
|
|
817
|
+
return {
|
|
818
|
+
sku: product.sku,
|
|
819
|
+
name: product.name,
|
|
820
|
+
description: product.description,
|
|
821
|
+
category: product.category,
|
|
822
|
+
price: product.price
|
|
823
|
+
};
|
|
824
|
+
}
|
|
825
|
+
};
|
|
826
|
+
```
|
|
827
|
+
|
|
828
|
+
### Memoized Async Resolver
|
|
829
|
+
|
|
830
|
+
For repeated lookups, use memoization to cache results:
|
|
831
|
+
|
|
832
|
+
```typescript
|
|
833
|
+
const customResolvers = {
|
|
834
|
+
'custom.getCategoryName': helpers.memoize(async (categoryId: string) => {
|
|
835
|
+
// This will only be called once per unique categoryId
|
|
836
|
+
const response = await fetch(`https://api.example.com/categories/${categoryId}`);
|
|
837
|
+
const category = await response.json();
|
|
838
|
+
return category.name;
|
|
839
|
+
})
|
|
840
|
+
};
|
|
841
|
+
```
|
|
842
|
+
|
|
843
|
+
### Performance Consideration
|
|
844
|
+
|
|
845
|
+
**❌ Avoid:** Individual async calls in batch processing loops
|
|
846
|
+
|
|
847
|
+
```typescript
|
|
848
|
+
// ❌ Bad - Makes N API calls for N records
|
|
849
|
+
for (const record of records) {
|
|
850
|
+
const result = await mapper.map(record); // Each calls async resolver
|
|
851
|
+
}
|
|
852
|
+
```
|
|
853
|
+
|
|
854
|
+
**✅ Better:** Pre-fetch data, then map synchronously
|
|
855
|
+
|
|
856
|
+
```typescript
|
|
857
|
+
// ✅ Good - Single batch API call, then fast mapping
|
|
858
|
+
const customerIds = records.map(r => r.customerId);
|
|
859
|
+
const customers = await api.getCustomersBatch(customerIds);
|
|
860
|
+
const customersMap = new Map(customers.map(c => [c.id, c]));
|
|
861
|
+
|
|
862
|
+
const customResolvers = {
|
|
863
|
+
'custom.getCustomer': (id: string) => customersMap.get(id) // Synchronous lookup
|
|
864
|
+
};
|
|
865
|
+
|
|
866
|
+
for (const record of records) {
|
|
867
|
+
const result = await mapper.map(record); // Fast - no API calls
|
|
868
|
+
}
|
|
869
|
+
```
|
|
870
|
+
|
|
871
|
+
---
|
|
872
|
+
|
|
873
|
+
## Chaining Transformations
|
|
874
|
+
|
|
875
|
+
Combine multiple transformations via custom resolvers.
|
|
876
|
+
|
|
877
|
+
### Method 1: Manual Chaining
|
|
878
|
+
|
|
879
|
+
```typescript
|
|
880
|
+
const customResolvers = {
|
|
881
|
+
'custom.cleanAndFormat': (value: string, sourceData: any, config: any, helpers: any) => {
|
|
882
|
+
// Chain: trim → lowercase → capitalize first letter
|
|
883
|
+
const trimmed = helpers.normalizeWhitespace(value);
|
|
884
|
+
const lower = trimmed.toLowerCase();
|
|
885
|
+
return lower.charAt(0).toUpperCase() + lower.slice(1);
|
|
886
|
+
}
|
|
887
|
+
};
|
|
888
|
+
```
|
|
889
|
+
|
|
890
|
+
### Method 2: Using SDK Resolvers Within Custom
|
|
891
|
+
|
|
892
|
+
```typescript
|
|
893
|
+
import { getSdkResolver } from '@fluentcommerce/fc-connect-sdk';
|
|
894
|
+
|
|
895
|
+
const trim = getSdkResolver('sdk.trim');
|
|
896
|
+
const uppercase = getSdkResolver('sdk.uppercase');
|
|
897
|
+
|
|
898
|
+
const customResolvers = {
|
|
899
|
+
'custom.cleanAndUpper': (value: any, sourceData: any, config: any, helpers: any) => {
|
|
900
|
+
// Chain SDK resolvers
|
|
901
|
+
const trimmed = trim(value, sourceData, config, helpers);
|
|
902
|
+
return uppercase(trimmed, sourceData, config, helpers);
|
|
903
|
+
}
|
|
904
|
+
};
|
|
905
|
+
```
|
|
906
|
+
|
|
907
|
+
### Complex Chaining Example
|
|
908
|
+
|
|
909
|
+
```typescript
|
|
910
|
+
const customResolvers = {
|
|
911
|
+
'custom.processOrderRef': (value: string, sourceData: any, config: any, helpers: any) => {
|
|
912
|
+
// Step 1: Trim whitespace
|
|
913
|
+
const cleaned = helpers.normalizeWhitespace(value);
|
|
914
|
+
|
|
915
|
+
// Step 2: Uppercase
|
|
916
|
+
const upper = cleaned.toUpperCase();
|
|
917
|
+
|
|
918
|
+
// Step 3: Add prefix if not present
|
|
919
|
+
const withPrefix = upper.startsWith('ORD-') ? upper : `ORD-${upper}`;
|
|
920
|
+
|
|
921
|
+
// Step 4: Validate format
|
|
922
|
+
if (!/^ORD-[A-Z0-9]+$/.test(withPrefix)) {
|
|
923
|
+
throw new Error(`Invalid order ref format: ${withPrefix}`);
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
return withPrefix;
|
|
927
|
+
}
|
|
928
|
+
};
|
|
929
|
+
```
|
|
930
|
+
|
|
931
|
+
### Resolver Composition
|
|
932
|
+
|
|
933
|
+
Create reusable resolver building blocks:
|
|
934
|
+
|
|
935
|
+
```typescript
|
|
936
|
+
// Utility functions
|
|
937
|
+
const cleanString = (value: string) => value.trim().replace(/\s+/g, ' ');
|
|
938
|
+
const normalizeEmail = (value: string) => value.toLowerCase().trim();
|
|
939
|
+
const validateEmail = (value: string) => {
|
|
940
|
+
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
941
|
+
if (!emailRegex.test(value)) {
|
|
942
|
+
throw new Error(`Invalid email: ${value}`);
|
|
943
|
+
}
|
|
944
|
+
return value;
|
|
945
|
+
};
|
|
946
|
+
|
|
947
|
+
// Composed resolvers
|
|
948
|
+
const customResolvers = {
|
|
949
|
+
'custom.processEmail': (value: string) => {
|
|
950
|
+
return validateEmail(normalizeEmail(cleanString(value)));
|
|
951
|
+
},
|
|
952
|
+
|
|
953
|
+
'custom.processPhone': (value: string) => {
|
|
954
|
+
const cleaned = cleanString(value);
|
|
955
|
+
const digitsOnly = cleaned.replace(/\D/g, '');
|
|
956
|
+
return `(${digitsOnly.slice(0,3)}) ${digitsOnly.slice(3,6)}-${digitsOnly.slice(6)}`;
|
|
957
|
+
}
|
|
958
|
+
};
|
|
959
|
+
```
|
|
960
|
+
|
|
961
|
+
---
|
|
962
|
+
|
|
963
|
+
## Advanced Context Usage
|
|
964
|
+
|
|
965
|
+
Access workflow-specific data via `helpers.context`.
|
|
966
|
+
|
|
967
|
+
### Passing Context to Mapper
|
|
968
|
+
|
|
969
|
+
```typescript
|
|
970
|
+
// Create mapper with context
|
|
971
|
+
const mapper = new UniversalMapper(mappingConfig, {
|
|
972
|
+
customResolvers,
|
|
973
|
+
context: {
|
|
974
|
+
orderId: 'ORD-2025-001',
|
|
975
|
+
userId: '999',
|
|
976
|
+
importDate: new Date().toISOString(),
|
|
977
|
+
retailerId: '1'
|
|
978
|
+
}
|
|
979
|
+
});
|
|
980
|
+
```
|
|
981
|
+
|
|
982
|
+
### Using Context in Resolvers
|
|
983
|
+
|
|
984
|
+
```typescript
|
|
985
|
+
const customResolvers = {
|
|
986
|
+
'custom.addOrderContext': (value: any, sourceData: any, config: any, helpers: any) => {
|
|
987
|
+
const orderId = helpers.context?.orderId;
|
|
988
|
+
return `${value}_${orderId}`;
|
|
989
|
+
},
|
|
990
|
+
|
|
991
|
+
'custom.setRetailer': (value: any, sourceData: any, config: any, helpers: any) => {
|
|
992
|
+
return helpers.context?.retailerId || '1';
|
|
993
|
+
},
|
|
994
|
+
|
|
995
|
+
'custom.auditTrail': (value: any, sourceData: any, config: any, helpers: any) => {
|
|
996
|
+
return {
|
|
997
|
+
value,
|
|
998
|
+
importedBy: helpers.context?.userId,
|
|
999
|
+
importedAt: helpers.context?.importDate
|
|
1000
|
+
};
|
|
1001
|
+
}
|
|
1002
|
+
};
|
|
1003
|
+
```
|
|
1004
|
+
|
|
1005
|
+
---
|
|
1006
|
+
|
|
1007
|
+
## Versori Platform: File Processing with KV State Tracking
|
|
1008
|
+
|
|
1009
|
+
**Problem:** Prevent duplicate file processing in scheduled workflows
|
|
1010
|
+
|
|
1011
|
+
**Solution:** Use VersoriKVAdapter for state management
|
|
1012
|
+
|
|
1013
|
+
### Complete Implementation
|
|
1014
|
+
|
|
1015
|
+
```typescript
|
|
1016
|
+
import { schedule } from '@versori/run';
|
|
1017
|
+
import {
|
|
1018
|
+
SftpDataSource,
|
|
1019
|
+
UniversalMapper,
|
|
1020
|
+
VersoriKVAdapter,
|
|
1021
|
+
XMLParserService,
|
|
1022
|
+
createClient
|
|
1023
|
+
} from '@fluentcommerce/fc-connect-sdk';
|
|
1024
|
+
|
|
1025
|
+
export const inventorySync = schedule('inventory-sync', '0 */6 * * *', async (ctx) => {
|
|
1026
|
+
const { log, openKv, activation, credentials } = ctx;
|
|
1027
|
+
|
|
1028
|
+
// 1. Initialize KV state tracking
|
|
1029
|
+
const kv = new VersoriKVAdapter(openKv(':project:'));
|
|
1030
|
+
const processedFilesKey = ['inventory-sync', 'processed-files'];
|
|
1031
|
+
|
|
1032
|
+
// Get list of previously processed files
|
|
1033
|
+
const processed = (await kv.get(processedFilesKey))?.value || [];
|
|
1034
|
+
log.info('Previously processed files', { count: processed.length });
|
|
1035
|
+
|
|
1036
|
+
// 2. Connect to SFTP
|
|
1037
|
+
const sftpCreds = await credentials().getAccessToken('SFTP');
|
|
1038
|
+
const [username, password] = Buffer.from(sftpCreds.accessToken, 'base64')
|
|
1039
|
+
.toString('utf-8')
|
|
1040
|
+
.split(':');
|
|
1041
|
+
|
|
1042
|
+
const sftp = new SftpDataSource({
|
|
1043
|
+
type: 'SFTP_XML',
|
|
1044
|
+
sftpConfig: {
|
|
1045
|
+
host: activation.getVariable('SFTP_HOST'),
|
|
1046
|
+
port: 22,
|
|
1047
|
+
username,
|
|
1048
|
+
password,
|
|
1049
|
+
remotePath: '/inbound/inventory/',
|
|
1050
|
+
filePattern: '*.xml'
|
|
1051
|
+
}
|
|
1052
|
+
}, log);
|
|
1053
|
+
|
|
1054
|
+
await sftp.connect();
|
|
1055
|
+
|
|
1056
|
+
// 3. List files and filter out already processed
|
|
1057
|
+
const allFiles = await sftp.listFiles({
|
|
1058
|
+
remotePath: '/inbound/inventory/',
|
|
1059
|
+
filePattern: '*.xml'
|
|
1060
|
+
});
|
|
1061
|
+
|
|
1062
|
+
const newFiles = allFiles.filter(f => !processed.includes(f.name));
|
|
1063
|
+
log.info('New files to process', {
|
|
1064
|
+
total: allFiles.length,
|
|
1065
|
+
new: newFiles.length
|
|
1066
|
+
});
|
|
1067
|
+
|
|
1068
|
+
// 4. Process each new file
|
|
1069
|
+
const mapper = new UniversalMapper({
|
|
1070
|
+
name: 'inventory.xml.mapping',
|
|
1071
|
+
fields: {
|
|
1072
|
+
retailerId: { source: '$context.retailerId', resolver: 'sdk.parseInt' },
|
|
1073
|
+
locationRef: { source: 'facilityId', resolver: 'sdk.trim' },
|
|
1074
|
+
productRef: { source: 'sku', resolver: 'sdk.trim' },
|
|
1075
|
+
quantity: { source: 'qty', resolver: 'sdk.parseInt' }
|
|
1076
|
+
}
|
|
1077
|
+
}, log);
|
|
1078
|
+
|
|
1079
|
+
const client = await createClient({ ...ctx, log });
|
|
1080
|
+
const retailerId = activation.getVariable('fluentRetailerId');
|
|
1081
|
+
if (!retailerId) {
|
|
1082
|
+
throw new Error('fluentRetailerId required in activation variables');
|
|
1083
|
+
}
|
|
1084
|
+
client.setRetailerId(retailerId);
|
|
1085
|
+
|
|
1086
|
+
const results = [];
|
|
1087
|
+
|
|
1088
|
+
for (const file of newFiles) {
|
|
1089
|
+
try {
|
|
1090
|
+
// Download and parse
|
|
1091
|
+
const content = await sftp.downloadFile(file.path, { encoding: 'utf8' });
|
|
1092
|
+
const parser = new XMLParserService(log);
|
|
1093
|
+
const doc = await parser.parse(content);
|
|
1094
|
+
|
|
1095
|
+
// Map records
|
|
1096
|
+
const records = Array.isArray(doc.inventory.item)
|
|
1097
|
+
? doc.inventory.item
|
|
1098
|
+
: [doc.inventory.item];
|
|
1099
|
+
|
|
1100
|
+
const mappedRecords = [];
|
|
1101
|
+
for (const rec of records) {
|
|
1102
|
+
const result = await mapper.map({
|
|
1103
|
+
...rec,
|
|
1104
|
+
$context: { retailerId }
|
|
1105
|
+
});
|
|
1106
|
+
if (result.success) {
|
|
1107
|
+
mappedRecords.push(result.data);
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
// Send to Fluent Batch API
|
|
1112
|
+
await client.batch.submit('INVENTORY_QUANTITY', mappedRecords);
|
|
1113
|
+
|
|
1114
|
+
// Mark as processed
|
|
1115
|
+
processed.push(file.name);
|
|
1116
|
+
await kv.set(processedFilesKey, processed);
|
|
1117
|
+
|
|
1118
|
+
// Archive file
|
|
1119
|
+
await sftp.moveFile(
|
|
1120
|
+
file.path,
|
|
1121
|
+
`/archive/inventory/${file.name}`,
|
|
1122
|
+
true
|
|
1123
|
+
);
|
|
1124
|
+
|
|
1125
|
+
results.push({ file: file.name, status: 'success', records: mappedRecords.length });
|
|
1126
|
+
log.info('File processed successfully', { file: file.name });
|
|
1127
|
+
|
|
1128
|
+
} catch (error) {
|
|
1129
|
+
log.error('File processing failed', { file: file.name, error });
|
|
1130
|
+
results.push({ file: file.name, status: 'failed', error: error.message });
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
await sftp.disconnect();
|
|
1135
|
+
|
|
1136
|
+
return {
|
|
1137
|
+
success: true,
|
|
1138
|
+
filesProcessed: newFiles.length,
|
|
1139
|
+
results
|
|
1140
|
+
};
|
|
1141
|
+
});
|
|
1142
|
+
```
|
|
1143
|
+
|
|
1144
|
+
### Key Points
|
|
1145
|
+
|
|
1146
|
+
- **VersoriKVAdapter:** Persistent state management across workflow runs
|
|
1147
|
+
- **Credential Access:** Use `ctx.credentials()` for secure credential retrieval
|
|
1148
|
+
- **Environment Variables:** Access via `activation.getVariable()`
|
|
1149
|
+
- **File Tracking:** Prevent duplicate processing with KV storage
|
|
1150
|
+
- **Error Handling:** Per-file error handling prevents workflow failure
|
|
1151
|
+
- **Resource Cleanup:** Always call `sftp.disconnect()` to prevent leaks
|
|
1152
|
+
- **Schedule Timeout:** 5 minutes default (adjustable up to 30 minutes)
|
|
1153
|
+
|
|
1154
|
+
### Platform Constraints
|
|
1155
|
+
|
|
1156
|
+
| Resource | Limit | Impact | Mitigation |
|
|
1157
|
+
|----------|-------|--------|------------|
|
|
1158
|
+
| **Schedule Timeout** | 5 minutes (default) | Large file batches may timeout | Process in chunks, use continuation |
|
|
1159
|
+
| **Memory** | ~512MB | Large files may cause OOM | Use streaming parsers, batch processing |
|
|
1160
|
+
| **Concurrent Requests** | ~10 | Parallel API calls limited | Use SDK batch methods, queue processing |
|
|
1161
|
+
|
|
1162
|
+
### State Management Pattern
|
|
1163
|
+
|
|
1164
|
+
```typescript
|
|
1165
|
+
// KV Key Structure
|
|
1166
|
+
['workflow-name', 'state-type']
|
|
1167
|
+
// Examples:
|
|
1168
|
+
['inventory-sync', 'processed-files'] // File tracking
|
|
1169
|
+
['order-import', 'last-cursor'] // Pagination cursor
|
|
1170
|
+
['daily-extract', 'last-run-timestamp'] // Execution tracking
|
|
1171
|
+
```
|
|
1172
|
+
|
|
1173
|
+
### Testing in Versori
|
|
1174
|
+
|
|
1175
|
+
```bash
|
|
1176
|
+
# Trigger schedule manually in Versori UI
|
|
1177
|
+
# OR use CLI:
|
|
1178
|
+
versori schedule trigger inventory-sync
|
|
1179
|
+
|
|
1180
|
+
# Check logs for:
|
|
1181
|
+
# - Previously processed files count
|
|
1182
|
+
# - New files detected
|
|
1183
|
+
# - Processing results
|
|
1184
|
+
# - KV state updates
|
|
1185
|
+
```
|
|
1186
|
+
|
|
1187
|
+
---
|
|
1188
|
+
|
|
1189
|
+
## Key Takeaways
|
|
1190
|
+
|
|
1191
|
+
1. **Nested Objects:** Use dot notation for simple nesting, nested `fields` for complex structures
|
|
1192
|
+
2. **Static Values:** Use `value` property for constants at any nesting level
|
|
1193
|
+
3. **Conditional Logic:** Custom resolvers enable complex business rules
|
|
1194
|
+
4. **Arrays:** Use `isArray: true` to process array elements
|
|
1195
|
+
5. **Async Resolvers:** Enable external lookups but use sparingly for performance
|
|
1196
|
+
6. **Chaining:** Combine transformations via custom resolvers or SDK resolver composition
|
|
1197
|
+
7. **Context:** Pass workflow-specific data via `context` option
|
|
1198
|
+
8. **Versori State Management:** Use VersoriKVAdapter for persistent state across runs
|
|
1199
|
+
9. **Versori Credentials:** Use `ctx.credentials()` for secure access to connection credentials
|
|
1200
|
+
10. **Resource Cleanup:** Always dispose SFTP/database connections to prevent leaks
|
|
1201
|
+
|
|
1202
|
+
---
|
|
1203
|
+
|
|
1204
|
+
## Practice Exercise
|
|
1205
|
+
|
|
1206
|
+
**Task:** Create a mapping for complex order data with nested customer, multiple addresses, and line items.
|
|
1207
|
+
|
|
1208
|
+
**Source Data:**
|
|
1209
|
+
```json
|
|
1210
|
+
{
|
|
1211
|
+
"order_id": "12345",
|
|
1212
|
+
"customer_email": " JOHN@EXAMPLE.COM ",
|
|
1213
|
+
"customer_tier": "premium",
|
|
1214
|
+
"billing_address": {
|
|
1215
|
+
"street": "123 Main St",
|
|
1216
|
+
"city": "New York",
|
|
1217
|
+
"state": "NY",
|
|
1218
|
+
"zip": "10001"
|
|
1219
|
+
},
|
|
1220
|
+
"shipping_address": {
|
|
1221
|
+
"street": "456 Oak Ave",
|
|
1222
|
+
"city": "Boston",
|
|
1223
|
+
"state": "MA",
|
|
1224
|
+
"zip": "02101"
|
|
1225
|
+
},
|
|
1226
|
+
"line_items": [
|
|
1227
|
+
{ "sku": "PROD-001", "qty": "2", "price": "49.99" },
|
|
1228
|
+
{ "sku": "PROD-002", "qty": "1", "price": "99.99" }
|
|
1229
|
+
]
|
|
1230
|
+
}
|
|
1231
|
+
```
|
|
1232
|
+
|
|
1233
|
+
**Requirements:**
|
|
1234
|
+
1. Clean and normalize customer email
|
|
1235
|
+
2. Map both billing and shipping addresses
|
|
1236
|
+
3. Process line items array
|
|
1237
|
+
4. Calculate item totals
|
|
1238
|
+
5. Determine priority based on customer tier
|
|
1239
|
+
|
|
1240
|
+
<details>
|
|
1241
|
+
<summary>Click to see solution</summary>
|
|
1242
|
+
|
|
1243
|
+
```json
|
|
1244
|
+
{
|
|
1245
|
+
"fields": {
|
|
1246
|
+
"ref": {
|
|
1247
|
+
"source": "order_id"
|
|
1248
|
+
},
|
|
1249
|
+
"customer": {
|
|
1250
|
+
"fields": {
|
|
1251
|
+
"email": {
|
|
1252
|
+
"source": "customer_email",
|
|
1253
|
+
"resolver": "custom.cleanEmail"
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
},
|
|
1257
|
+
"priority": {
|
|
1258
|
+
"source": "customer_tier",
|
|
1259
|
+
"resolver": "custom.calculatePriority"
|
|
1260
|
+
},
|
|
1261
|
+
"billingAddress": {
|
|
1262
|
+
"fields": {
|
|
1263
|
+
"street": {
|
|
1264
|
+
"source": "billing_address.street"
|
|
1265
|
+
},
|
|
1266
|
+
"city": {
|
|
1267
|
+
"source": "billing_address.city"
|
|
1268
|
+
},
|
|
1269
|
+
"state": {
|
|
1270
|
+
"source": "billing_address.state"
|
|
1271
|
+
},
|
|
1272
|
+
"zipCode": {
|
|
1273
|
+
"source": "billing_address.zip"
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
},
|
|
1277
|
+
"shippingAddress": {
|
|
1278
|
+
"fields": {
|
|
1279
|
+
"street": {
|
|
1280
|
+
"source": "shipping_address.street"
|
|
1281
|
+
},
|
|
1282
|
+
"city": {
|
|
1283
|
+
"source": "shipping_address.city"
|
|
1284
|
+
},
|
|
1285
|
+
"state": {
|
|
1286
|
+
"source": "shipping_address.state"
|
|
1287
|
+
},
|
|
1288
|
+
"zipCode": {
|
|
1289
|
+
"source": "shipping_address.zip"
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
},
|
|
1293
|
+
"items": {
|
|
1294
|
+
"source": "line_items",
|
|
1295
|
+
"isArray": true,
|
|
1296
|
+
"fields": {
|
|
1297
|
+
"productRef": {
|
|
1298
|
+
"source": "sku"
|
|
1299
|
+
},
|
|
1300
|
+
"quantity": {
|
|
1301
|
+
"source": "qty",
|
|
1302
|
+
"resolver": "sdk.parseInt"
|
|
1303
|
+
},
|
|
1304
|
+
"price": {
|
|
1305
|
+
"source": "price",
|
|
1306
|
+
"resolver": "sdk.parseFloat"
|
|
1307
|
+
},
|
|
1308
|
+
"totalPrice": {
|
|
1309
|
+
"resolver": "custom.calculateItemTotal"
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
```
|
|
1316
|
+
|
|
1317
|
+
**Custom Resolvers:**
|
|
1318
|
+
```typescript
|
|
1319
|
+
const customResolvers = {
|
|
1320
|
+
'custom.cleanEmail': (value: string, sourceData: any, config: any, helpers: any) => {
|
|
1321
|
+
return helpers.normalizeWhitespace(value).toLowerCase();
|
|
1322
|
+
},
|
|
1323
|
+
|
|
1324
|
+
'custom.calculatePriority': (tier: string) => {
|
|
1325
|
+
return tier === 'premium' ? 'HIGH' : 'NORMAL';
|
|
1326
|
+
},
|
|
1327
|
+
|
|
1328
|
+
'custom.calculateItemTotal': (value: any, sourceData: any, config: any, helpers: any) => {
|
|
1329
|
+
const qty = helpers.parseIntSafe(sourceData.qty, 0);
|
|
1330
|
+
const price = helpers.parseFloatSafe(sourceData.price, 0);
|
|
1331
|
+
return qty * price;
|
|
1332
|
+
}
|
|
1333
|
+
};
|
|
1334
|
+
```
|
|
1335
|
+
|
|
1336
|
+
</details>
|
|
1337
|
+
|
|
1338
|
+
---
|
|
1339
|
+
|
|
1340
|
+
## Next Steps
|
|
1341
|
+
|
|
1342
|
+
Now that you've mastered advanced patterns, explore the helper functions available:
|
|
1343
|
+
|
|
1344
|
+
→ Continue to [Module 6: Helpers & Resolvers](./mapping-06-helpers-resolvers.md)
|
|
1345
|
+
|
|
1346
|
+
**Alternative paths:**
|
|
1347
|
+
- Jump to [API Reference](../../auto-pagination/modules/auto-pagination-07-api-reference.md) for TypeScript usage
|
|
1348
|
+
- Review [GraphQL Mutation Mapping Quick Reference](../graphql-mutation-mapping/graphql-mutation-mapping-quick-reference.md) for a comprehensive cheat sheet
|
|
1349
|
+
|
|
1350
|
+
---
|
|
1351
|
+
|
|
1352
|
+
[← Back to Use Cases](./mapping-04-use-cases.md) | [Next: Helpers & Resolvers →](./mapping-06-helpers-resolvers.md)
|
|
1353
|
+
|
|
1354
|
+
|
|
1355
|
+
|
|
1356
|
+
|
|
1357
|
+
|
|
1358
|
+
|
|
1359
|
+
|
|
1360
|
+
|
|
1361
|
+
|
|
1362
|
+
|
|
1363
|
+
|
|
1364
|
+
|
|
1365
|
+
|
|
1366
|
+
|