@alvera-ai/platform-sdk 0.17.0 → 0.18.0
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/.agent/AGENTS.md +82 -144
- package/.agent/account_management.md +2 -2
- package/.agent/action_logs.md +4 -4
- package/.agent/ai_agents.md +28 -21
- package/.agent/ai_sandbox.md +49 -39
- package/.agent/connected_apps.md +3 -3
- package/.agent/cookbook/_fixtures/README.md +1 -1
- package/.agent/cookbook/_fixtures/{foundation → organic-marketing}/_lead_submissions_foundation_generic_table.liquid +1 -1
- package/.agent/cookbook/_fixtures/organic-marketing/_lead_submissions_foundation_legal_entity.liquid +80 -0
- package/.agent/cookbook/_fixtures/{foundation → organic-marketing}/_lead_submissions_foundation_mdm.liquid +2 -1
- package/.agent/cookbook/_fixtures/payments-compliance/_compliance_screenings_generic_table.liquid +57 -0
- package/.agent/cookbook/_fixtures/payments-compliance/_compliance_screenings_legal_entity.liquid +30 -0
- package/.agent/cookbook/_fixtures/payments-compliance/_compliance_screenings_mdm.liquid +44 -0
- package/.agent/cookbook/_fixtures/payments-compliance/_payment_accounts_generic_table.liquid +57 -0
- package/.agent/cookbook/_fixtures/payments-compliance/_payment_accounts_legal_entity.liquid +36 -0
- package/.agent/cookbook/_fixtures/payments-compliance/_payment_accounts_mdm.liquid +41 -0
- package/.agent/cookbook/_fixtures/primary-care-feedback/_cahps_appointments_generic_table.liquid +70 -0
- package/.agent/cookbook/_fixtures/primary-care-feedback/_cahps_appointments_legal_entity.liquid +52 -0
- package/.agent/cookbook/_fixtures/primary-care-feedback/_cahps_appointments_mdm.liquid +42 -0
- package/.agent/cookbook/_fixtures/subscription-saas/_customers_subscription_generic_table.liquid +38 -0
- package/.agent/cookbook/_fixtures/subscription-saas/_customers_subscription_legal_entity.liquid +48 -0
- package/.agent/cookbook/_fixtures/subscription-saas/_customers_subscription_mdm.liquid +49 -0
- package/.agent/cookbook/organic-marketing.md +2801 -0
- package/.agent/cookbook/payments-compliance.md +2180 -0
- package/.agent/cookbook/primary-care.md +2175 -0
- package/.agent/cookbook/subscription-saas.md +2403 -0
- package/.agent/data_activation_clients.md +65 -52
- package/.agent/datalakes.md +338 -171
- package/.agent/errors.md +3 -3
- package/.agent/generic_tables.md +151 -62
- package/.agent/interoperability_contracts.md +57 -22
- package/.agent/mdm.md +136 -153
- package/.agent/messages.md +36 -34
- package/.agent/mock-services.md +1 -1
- package/.agent/mutations.md +2 -2
- package/.agent/templates.md +14 -13
- package/.agent/tools.md +63 -21
- package/.agent/type_naming.md +13 -13
- package/.agent/workflows.md +99 -53
- package/README.md +2 -2
- package/dist/bin/platform-sdk.mjs +33 -47
- package/dist/bin/platform-sdk.mjs.map +1 -1
- package/dist/index.d.mts +565 -379
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +494 -59
- package/dist/index.mjs.map +1 -1
- package/package.json +4 -3
- package/.agent/cookbook/_fixtures/foundation/_lead_submissions_foundation_legal_entity.liquid +0 -88
- package/.agent/cookbook/_fixtures/healthcare/_cahps_appointments_healthcare_appointment.liquid +0 -47
- package/.agent/cookbook/_fixtures/healthcare/_cahps_appointments_healthcare_mdm.liquid +0 -24
- package/.agent/cookbook/_fixtures/healthcare/_cahps_appointments_healthcare_patient.liquid +0 -38
- package/.agent/cookbook/_fixtures/payments/_compliance_screenings_payments_compliance_screening.liquid +0 -59
- package/.agent/cookbook/_fixtures/payments/_compliance_screenings_payments_mdm.liquid +0 -36
- package/.agent/cookbook/_fixtures/payments/_payment_accounts_payments_mdm.liquid +0 -30
- package/.agent/cookbook/_fixtures/payments/_payment_accounts_payments_payment_account.liquid +0 -55
- package/.agent/cookbook/_fixtures/subscription/_customers_subscription_mdm.liquid +0 -20
- package/.agent/cookbook/_setup/foundation.md +0 -359
- package/.agent/cookbook/_setup/healthcare.md +0 -361
- package/.agent/cookbook/_setup/payments.md +0 -365
- package/.agent/cookbook/_setup/subscription.md +0 -364
- package/.agent/cookbook/action-status-updaters.md +0 -278
- package/.agent/cookbook/ai-agent-invoke.md +0 -279
- package/.agent/cookbook/appointment-review-sms-workflow.md +0 -801
- package/.agent/cookbook/birthday-greeting-sms-trigger.md +0 -696
- package/.agent/cookbook/bulk-ingest.md +0 -302
- package/.agent/cookbook/contact-us-triage-with-llm.md +0 -663
- package/.agent/cookbook/dunning-sms-for-delinquent.md +0 -659
- package/.agent/cookbook/generic-tables.md +0 -244
- package/.agent/cookbook/invite-team.md +0 -200
- package/.agent/cookbook/kyc-notification-on-account-activation.md +0 -661
- package/.agent/cookbook/marketing-campaign-send.md +0 -1044
- package/.agent/cookbook/paginated-restapi-poller.md +0 -383
- package/.agent/cookbook/rest-fetch.md +0 -273
- package/.agent/cookbook/sanctions-screening-with-agent-review.md +0 -773
- package/.agent/cookbook/score-leads-with-llm-categorization.md +0 -665
- package/.agent/cookbook/system-templates.md +0 -165
- package/.agent/cookbook/talk-to-data.md +0 -178
- package/.agent/cookbook/triage-prospects-by-priority.md +0 -571
- package/.agent/cookbook/welcome-sms-for-customers.md +0 -647
- /package/.agent/cookbook/_fixtures/{healthcare → primary-care-feedback}/memorandum-of-association-01.png +0 -0
- /package/.agent/cookbook/_fixtures/{healthcare → primary-care-feedback}/sample_two_page.pdf +0 -0
- /package/.agent/cookbook/_fixtures/{subscription → subscription-saas}/_customers_subscription_customer.liquid +0 -0
- /package/.agent/cookbook/_fixtures/{subscription → subscription-saas}/stripe_customers_batch1.csv +0 -0
|
@@ -1,383 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
title: "Capability: poll a paginated provider API for delivery status (Mailgun-style)"
|
|
3
|
-
summary: A capability walk for restapi action status updaters. Register a REST poller tool against a provider's paginated events API (Mailgun-shaped here) and an action status updater whose request templates FOLLOW the pagination cursor — page 1 renders the collection path, later pages ride `msg.pagination_context.next`. The platform refuses a poller whose request can never advance (`api.actionStatusUpdaters.create` 422), so the guard is walked first, then the accepted shape.
|
|
4
|
-
industry: foundation
|
|
5
|
-
slug: paginated-restapi-poller
|
|
6
|
-
vitest_source:
|
|
7
|
-
- integration-tests/tests/foundation/action-status-updaters.test.ts
|
|
8
|
-
- integration-tests/tests/foundation/bootstrap.test.ts
|
|
9
|
-
status: green
|
|
10
|
-
---
|
|
11
|
-
|
|
12
|
-
# Capability
|
|
13
|
-
|
|
14
|
-
**What you get:** delivery outcomes pulled from a provider's REST events API —
|
|
15
|
-
paginated, cursor-driven — written back onto the messages you sent, on a cron,
|
|
16
|
-
with the platform enforcing that your poll can actually terminate.
|
|
17
|
-
|
|
18
|
-
A **restapi action status updater** polls an HTTP events endpoint instead of a
|
|
19
|
-
log group:
|
|
20
|
-
|
|
21
|
-
- a **REST poller tool** (`intent: 'status_poller'`, `tool_body_type:
|
|
22
|
-
'rest_api'`) supplies the base URL + auth,
|
|
23
|
-
- the ASU's `updater_body` renders the request per page: `path`, `params`,
|
|
24
|
-
an `events_template` that extracts the page's events as a JSON array, and a
|
|
25
|
-
`pagination_context_template` that captures `has_next` + the provider's
|
|
26
|
-
cursor,
|
|
27
|
-
- on every page after the first, the driver binds what your pagination
|
|
28
|
-
template captured as `msg.pagination_context` (and the page number as
|
|
29
|
-
`msg.page`) into your `path`/`params` — **your templates must read one of
|
|
30
|
-
them, or the request is byte-identical for every page and the poll can
|
|
31
|
-
never advance. The platform rejects that config at create.**
|
|
32
|
-
|
|
33
|
-
Mailgun's events API is the shape walked here (`GET /v3/<domain>/events` with
|
|
34
|
-
a `paging.next` cursor link), but any cursor- or page-numbered API fits. See
|
|
35
|
-
`action_status_updaters.md` §7 for the wire reference.
|
|
36
|
-
|
|
37
|
-
# Walkthrough
|
|
38
|
-
|
|
39
|
-
The `_setup/foundation.md` bootstrap left `api`, `tenantSlug`, `datalakeSlug`,
|
|
40
|
-
and `ctx.datalakeId` populated.
|
|
41
|
-
|
|
42
|
-
## 001 — register a data source for the tools
|
|
43
|
-
|
|
44
|
-
Both the sender and the poller attach to a data source — the origin
|
|
45
|
-
registration for the messaging provider.
|
|
46
|
-
|
|
47
|
-
```typescript
|
|
48
|
-
const { data: ds } = await api.dataSources.create(tenantSlug, datalakeSlug, {
|
|
49
|
-
name: `Poller Source ${runSuffix}`,
|
|
50
|
-
uri: 'mailgun.local',
|
|
51
|
-
description: 'Messaging-provider origin for the paginated delivery poller.',
|
|
52
|
-
status: 'active',
|
|
53
|
-
is_default: false,
|
|
54
|
-
})
|
|
55
|
-
dataSourceId = ds.id!
|
|
56
|
-
```
|
|
57
|
-
|
|
58
|
-
## 002 — create the sender tool whose messages get reconciled
|
|
59
|
-
|
|
60
|
-
The ASU updates messages a sender produced; `sender_tool_ids` will point here.
|
|
61
|
-
An SNS-backed SMS sender (LocalStack locally) plays that role.
|
|
62
|
-
|
|
63
|
-
```typescript
|
|
64
|
-
const { data: smsTool } = await api.tools.create(tenantSlug, datalakeSlug, {
|
|
65
|
-
name: `Poller SMS Sender ${runSuffix}`,
|
|
66
|
-
description: 'Sender whose delivery status the paginated poller reconciles.',
|
|
67
|
-
intent: 'sms',
|
|
68
|
-
status: 'active',
|
|
69
|
-
datalake_id: ctx.datalakeId,
|
|
70
|
-
data_source_id: dataSourceId,
|
|
71
|
-
body: {
|
|
72
|
-
tool_body_type: 'sns',
|
|
73
|
-
auth_method: 'access_key',
|
|
74
|
-
region: 'us-east-1',
|
|
75
|
-
phone_number: '+15551234567',
|
|
76
|
-
endpoint_url: 'http://localhost:4566',
|
|
77
|
-
access_key_id: 'test',
|
|
78
|
-
secret_access_key: 'test',
|
|
79
|
-
},
|
|
80
|
-
})
|
|
81
|
-
ctx.senderToolId = smsTool.id!
|
|
82
|
-
```
|
|
83
|
-
|
|
84
|
-
## 003 — create the REST poller tool
|
|
85
|
-
|
|
86
|
-
The poller supplies base URL + auth for the provider's events API —
|
|
87
|
-
Mailgun-shaped here (basic auth, `api` / API key). `intent:
|
|
88
|
-
'status_poller'` tags it as a reconciler source, `tool_body_type: 'rest_api'`
|
|
89
|
-
makes the ASU's requests ride this tool's HTTP client.
|
|
90
|
-
|
|
91
|
-
```typescript
|
|
92
|
-
const { data: restTool } = await api.tools.create(tenantSlug, datalakeSlug, {
|
|
93
|
-
name: `Mailgun Events Poller ${runSuffix}`,
|
|
94
|
-
description: 'REST poller — supplies auth for restapi ActionStatusUpdater delivery polling',
|
|
95
|
-
intent: 'status_poller',
|
|
96
|
-
status: 'active',
|
|
97
|
-
datalake_id: ctx.datalakeId,
|
|
98
|
-
data_source_id: dataSourceId,
|
|
99
|
-
body: {
|
|
100
|
-
tool_body_type: 'rest_api',
|
|
101
|
-
base_url: 'http://localhost:8080/mailgun/v3',
|
|
102
|
-
auth_method: 'basic',
|
|
103
|
-
username: 'api',
|
|
104
|
-
password: 'key-test',
|
|
105
|
-
request_type: 'json',
|
|
106
|
-
response_type: 'json',
|
|
107
|
-
timeout_ms: 30000,
|
|
108
|
-
},
|
|
109
|
-
})
|
|
110
|
-
ctx.restToolId = restTool.id!
|
|
111
|
-
```
|
|
112
|
-
|
|
113
|
-
## 004 — the pagination guard: a static request is refused at create
|
|
114
|
-
|
|
115
|
-
First, the shape that does NOT work — and why the platform refuses it. This
|
|
116
|
-
`path`/`params` pair reads neither `msg.pagination_context` nor `msg.page`,
|
|
117
|
-
so page 500's request would be byte-identical to page 1's: the window never
|
|
118
|
-
moves, `has_next` never goes false, and the run would re-apply the same
|
|
119
|
-
events forever. The create is a 422; the walk catches it to prove the gate.
|
|
120
|
-
|
|
121
|
-
```typescript
|
|
122
|
-
let rejected = false
|
|
123
|
-
try {
|
|
124
|
-
await api.actionStatusUpdaters.create(tenantSlug, datalakeSlug, {
|
|
125
|
-
name: `Mailgun Delivery Poller ${runSuffix} non-advancing`,
|
|
126
|
-
cron_expression: '*/30 * * * *',
|
|
127
|
-
updater_type: 'restapi',
|
|
128
|
-
updater_tool_id: ctx.restToolId,
|
|
129
|
-
sender_tool_ids: [ctx.senderToolId],
|
|
130
|
-
datalake_id: ctx.datalakeId,
|
|
131
|
-
// FLOOR the platform enforces: an array whose items are objects listing
|
|
132
|
-
// `external_id` in `required`. A bare `{ type: 'array' }` is a 422 at
|
|
133
|
-
// create — every event has to name the message it reconciles.
|
|
134
|
-
events_output_schema: {
|
|
135
|
-
type: 'array',
|
|
136
|
-
items: {
|
|
137
|
-
type: 'object',
|
|
138
|
-
required: ['external_id'],
|
|
139
|
-
properties: { external_id: { type: 'string' } },
|
|
140
|
-
},
|
|
141
|
-
},
|
|
142
|
-
pagination_context_output_schema: {
|
|
143
|
-
type: 'object',
|
|
144
|
-
required: ['has_next'],
|
|
145
|
-
properties: { has_next: { type: 'boolean' } },
|
|
146
|
-
},
|
|
147
|
-
updater_body: {
|
|
148
|
-
updater_body_type: 'restapi_request',
|
|
149
|
-
method: 'get',
|
|
150
|
-
// Static on both — the cursor is captured below and never read back.
|
|
151
|
-
path: { type: 'custom', body: '/wiremock.domain/events' },
|
|
152
|
-
params: { type: 'custom', body: '{"event": "delivered"}' },
|
|
153
|
-
// Providers name their own id — `message-id` here, `messageId` / `sid`
|
|
154
|
-
// elsewhere. The events_template is where that becomes `external_id`:
|
|
155
|
-
// the apply path POPS that key off every rendered event to find the row
|
|
156
|
-
// it updates, so the render PROJECTS each event rather than passing the
|
|
157
|
-
// provider body through untouched.
|
|
158
|
-
events_template: {
|
|
159
|
-
type: 'custom',
|
|
160
|
-
body:
|
|
161
|
-
'[{% for item in response.items %}' +
|
|
162
|
-
'{"external_id": "{{ item.message.headers[\'message-id\'] }}", "event": "{{ item.event }}"}' +
|
|
163
|
-
'{% unless forloop.last %},{% endunless %}{% endfor %}]',
|
|
164
|
-
},
|
|
165
|
-
pagination_context_template: {
|
|
166
|
-
type: 'custom',
|
|
167
|
-
body:
|
|
168
|
-
'{"has_next": {% if response.items.size > 0 %}true{% else %}false{% endif %}, ' +
|
|
169
|
-
'"next": "{{ response.paging.next }}"}',
|
|
170
|
-
},
|
|
171
|
-
},
|
|
172
|
-
message_config: {
|
|
173
|
-
type: 'custom',
|
|
174
|
-
body: '{"external_id": "{{ external_id }}", "status": "delivered"}',
|
|
175
|
-
},
|
|
176
|
-
action_log_config: {
|
|
177
|
-
type: 'custom',
|
|
178
|
-
body: '{"external_id": "{{ external_id }}", "status": "delivered"}',
|
|
179
|
-
},
|
|
180
|
-
})
|
|
181
|
-
} catch (err) {
|
|
182
|
-
const status = (err as { _httpStatus?: number })._httpStatus
|
|
183
|
-
if (status !== 422) throw err
|
|
184
|
-
rejected = true
|
|
185
|
-
}
|
|
186
|
-
if (!rejected) {
|
|
187
|
-
throw new Error('expected a 422 for a poller whose request never consumes the cursor')
|
|
188
|
-
}
|
|
189
|
-
```
|
|
190
|
-
|
|
191
|
-
## 005 — the accepted shape: the path follows the cursor
|
|
192
|
-
|
|
193
|
-
Page 1 (`msg.pagination_context` is falsy) renders the plain collection path
|
|
194
|
-
with bounded query params; every later page rides the cursor link the
|
|
195
|
-
pagination template captured — and drops the params, because Mailgun's
|
|
196
|
-
`paging.next` is a complete URL that already carries them.
|
|
197
|
-
|
|
198
|
-
```typescript
|
|
199
|
-
const { data: asu } = await api.actionStatusUpdaters.create(tenantSlug, datalakeSlug, {
|
|
200
|
-
name: `Mailgun Delivery Poller ${runSuffix} advancing`,
|
|
201
|
-
cron_expression: '*/30 * * * *',
|
|
202
|
-
updater_type: 'restapi',
|
|
203
|
-
updater_tool_id: ctx.restToolId,
|
|
204
|
-
sender_tool_ids: [ctx.senderToolId],
|
|
205
|
-
datalake_id: ctx.datalakeId,
|
|
206
|
-
events_output_schema: {
|
|
207
|
-
type: 'array',
|
|
208
|
-
items: {
|
|
209
|
-
type: 'object',
|
|
210
|
-
required: ['external_id'],
|
|
211
|
-
properties: { external_id: { type: 'string' } },
|
|
212
|
-
},
|
|
213
|
-
},
|
|
214
|
-
pagination_context_output_schema: {
|
|
215
|
-
type: 'object',
|
|
216
|
-
required: ['has_next'],
|
|
217
|
-
properties: { has_next: { type: 'boolean' } },
|
|
218
|
-
},
|
|
219
|
-
updater_body: {
|
|
220
|
-
updater_body_type: 'restapi_request',
|
|
221
|
-
method: 'get',
|
|
222
|
-
path: {
|
|
223
|
-
type: 'custom',
|
|
224
|
-
body:
|
|
225
|
-
'{% if msg.pagination_context %}{{ msg.pagination_context.next }}' +
|
|
226
|
-
'{% else %}/wiremock.domain/events{% endif %}',
|
|
227
|
-
},
|
|
228
|
-
params: {
|
|
229
|
-
type: 'custom',
|
|
230
|
-
body: '{% unless msg.pagination_context %}{"event": "delivered"}{% endunless %}',
|
|
231
|
-
},
|
|
232
|
-
// The provider's own id (`message-id`) becomes `external_id` HERE — the
|
|
233
|
-
// apply path pops that key to find the row it reconciles, so project each
|
|
234
|
-
// event instead of passing the provider body through untouched.
|
|
235
|
-
events_template: {
|
|
236
|
-
type: 'custom',
|
|
237
|
-
body:
|
|
238
|
-
'[{% for item in response.items %}' +
|
|
239
|
-
'{"external_id": "{{ item.message.headers[\'message-id\'] }}", "event": "{{ item.event }}"}' +
|
|
240
|
-
'{% unless forloop.last %},{% endunless %}{% endfor %}]',
|
|
241
|
-
},
|
|
242
|
-
pagination_context_template: {
|
|
243
|
-
type: 'custom',
|
|
244
|
-
body:
|
|
245
|
-
'{"has_next": {% if response.items.size > 0 %}true{% else %}false{% endif %}, ' +
|
|
246
|
-
'"next": "{{ response.paging.next }}"}',
|
|
247
|
-
},
|
|
248
|
-
},
|
|
249
|
-
message_config: {
|
|
250
|
-
type: 'custom',
|
|
251
|
-
body: '{"external_id": "{{ external_id }}", "status": "delivered"}',
|
|
252
|
-
},
|
|
253
|
-
action_log_config: {
|
|
254
|
-
type: 'custom',
|
|
255
|
-
body: '{"external_id": "{{ external_id }}", "status": "delivered"}',
|
|
256
|
-
},
|
|
257
|
-
})
|
|
258
|
-
actionStatusUpdaterId = asu.id!
|
|
259
|
-
if (asu.status !== 'active') {
|
|
260
|
-
throw new Error(`a newly created poller must be free to poll — got status ${asu.status}`)
|
|
261
|
-
}
|
|
262
|
-
```
|
|
263
|
-
|
|
264
|
-
## 006 — discover it (list + metadata)
|
|
265
|
-
|
|
266
|
-
The poller shows up in the paginated ASU list, and `metadataDetails` renders
|
|
267
|
-
the markdown an agent reads to understand the reconciler.
|
|
268
|
-
|
|
269
|
-
```typescript
|
|
270
|
-
const { data: list } = await api.actionStatusUpdaters.list(tenantSlug, datalakeSlug)
|
|
271
|
-
if (!(list.data ?? []).some((u) => u.id === actionStatusUpdaterId)) {
|
|
272
|
-
throw new Error('created restapi ASU not found in the list')
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
const { data: detail } = await api.actionStatusUpdaters.metadataDetails(tenantSlug, datalakeSlug, actionStatusUpdaterId)
|
|
276
|
-
if (typeof detail !== 'string' || detail.length === 0) {
|
|
277
|
-
throw new Error('expected non-empty ASU metadata details')
|
|
278
|
-
}
|
|
279
|
-
```
|
|
280
|
-
|
|
281
|
-
## 007 — write the integration test
|
|
282
|
-
|
|
283
|
-
End the build with a test you keep: re-read the poller and assert the
|
|
284
|
-
facts the scenario depends on — the create was accepted (so the
|
|
285
|
-
pagination guard passed), the row is free to poll, and the run surface is
|
|
286
|
-
on the wire. This block runs live under `make validate-cookbook`.
|
|
287
|
-
|
|
288
|
-
```typescript
|
|
289
|
-
// Re-GET — the stored row echoes the authored cron.
|
|
290
|
-
const { data: poller } = await api.actionStatusUpdaters.get(tenantSlug, datalakeSlug, actionStatusUpdaterId)
|
|
291
|
-
if (poller.cron_expression !== '*/30 * * * *') {
|
|
292
|
-
throw new Error(`cron mismatch on read-back: ${poller.cron_expression}`)
|
|
293
|
-
}
|
|
294
|
-
// cycle_detected is only ever set at runtime by the poll driver —
|
|
295
|
-
// a fresh create MUST read active.
|
|
296
|
-
if (poller.status !== 'active') {
|
|
297
|
-
throw new Error(`a fresh poller must be free to poll — got status ${poller.status}`)
|
|
298
|
-
}
|
|
299
|
-
// last_run_* is the ONLY run surface (no per-run log); fresh create ⇒
|
|
300
|
-
// legitimately null. Assert the field EXISTS, never a value.
|
|
301
|
-
if (!('last_run_status' in poller)) {
|
|
302
|
-
throw new Error('poller response carries no last_run_status field')
|
|
303
|
-
}
|
|
304
|
-
// Behavioural probe — the metadata surface an agent reads must render.
|
|
305
|
-
const { data: pollerDetail } = await api.actionStatusUpdaters.metadataDetails(tenantSlug, datalakeSlug, actionStatusUpdaterId)
|
|
306
|
-
if (typeof pollerDetail !== 'string' || pollerDetail.length === 0) {
|
|
307
|
-
throw new Error('poller metadataDetails came back empty')
|
|
308
|
-
}
|
|
309
|
-
```
|
|
310
|
-
|
|
311
|
-
If the poller stops reconciling in production, refresh once, re-read
|
|
312
|
-
`last_run_error`, and escalate with that evidence — don't rewrite the
|
|
313
|
-
config and wait for another tick.
|
|
314
|
-
|
|
315
|
-
# Gotchas
|
|
316
|
-
|
|
317
|
-
- **The pagination guard is create-time and non-negotiable.** If neither
|
|
318
|
-
`path` nor `params` reads `msg.pagination_context` / `msg.page`, the create
|
|
319
|
-
is a 422 — the request could never advance past page 1. The guard reads
|
|
320
|
-
your template SOURCE, so a dead `{% if false %}{{ msg.page }}{% endif %}`
|
|
321
|
-
won't fool a reviewer even where it fools a regex; write the real cursor
|
|
322
|
-
read.
|
|
323
|
-
- **Page 1 is the falsy-context branch.** `msg.pagination_context` is unset
|
|
324
|
-
on the first page — `{% if msg.pagination_context %}…{% else %}<collection
|
|
325
|
-
path>{% endif %}` is the canonical shape. Bound page 1's `params`
|
|
326
|
-
with `{% unless msg.pagination_context %}` when the cursor link already
|
|
327
|
-
carries the query (Mailgun's `paging.next` does).
|
|
328
|
-
- **`has_next` decides termination — prefer the full-page heuristic in
|
|
329
|
-
production.** The walked shape (`response.items.size > 0`) terminates on
|
|
330
|
-
the first empty page, costing one extra request. Where the provider
|
|
331
|
-
documents a page size, `has_next: {% if response.items.size == 300 %}` (a
|
|
332
|
-
full page implies more) saves that call; a `paging.next` link that is
|
|
333
|
-
absent on the last page is an even stronger signal.
|
|
334
|
-
- **`events_template` must render a JSON ARRAY**, validated against
|
|
335
|
-
`events_output_schema` on every cycle; the pagination render is validated
|
|
336
|
-
against `pagination_context_output_schema` (which must require `has_next`).
|
|
337
|
-
Both schemas are REQUIRED for `restapi` updaters — blank is a 422.
|
|
338
|
-
- **Each schema has a FLOOR the platform enforces, above which the contract is
|
|
339
|
-
yours.** `events_output_schema` must describe an **array whose `items` are
|
|
340
|
-
objects listing `external_id` in `required`**; a bare `{ type: 'array' }` is a
|
|
341
|
-
422 at create. `pagination_context_output_schema` must be an **object listing
|
|
342
|
-
`has_next` in `required`** — that key is what ends the page loop. Demand more
|
|
343
|
-
of your provider on top if you like; only the floor is checked.
|
|
344
|
-
- **The floor exists because `external_id` is how reconciliation finds the row.**
|
|
345
|
-
`apply_status_update` pops that key off every rendered event, so mapping the
|
|
346
|
-
provider's own id (`message-id` / `messageId` / `sid`) into `external_id` is
|
|
347
|
-
the **`events_template`'s job** — project each event, never pass the provider
|
|
348
|
-
body through untouched. A schema that satisfies the floor while the template
|
|
349
|
-
emits raw provider rows creates cleanly and then reconciles nothing on every
|
|
350
|
-
cycle. `message_config` / `action_log_config` then read the **mapped**
|
|
351
|
-
`{{ external_id }}`, not the provider's original key.
|
|
352
|
-
- **`action_log_config` is required alongside `message_config`** for every
|
|
353
|
-
updater type, and both are cast against the *same* pinned reconciliation
|
|
354
|
-
schema — so one template body satisfies both.
|
|
355
|
-
- **A newly created poller is `status: 'active'`.** `cycle_detected` is only
|
|
356
|
-
ever set at runtime by the poll driver — never by a caller; you cannot
|
|
357
|
-
create your way into it.
|
|
358
|
-
- **`action_log_config` is REQUIRED alongside `message_config`** on create
|
|
359
|
-
AND update (PUT) — same per-event assigns, rendered into the action-log
|
|
360
|
-
write shape (`external_id` + at least one of `status` / `sent_at` /
|
|
361
|
-
`metadata`).
|
|
362
|
-
- **One poll cycle can be fired on demand.**
|
|
363
|
-
`api.actionStatusUpdaters.refresh(tenantSlug, datalakeSlug, id)` runs the
|
|
364
|
-
cycle the cron would — `202` with the updater row AS-IS (the poll is
|
|
365
|
-
async; re-read `last_run_status` / `last_run_events_found` /
|
|
366
|
-
`last_run_error` for the outcome) — a day-two operate surface, not a
|
|
367
|
-
build step. That outcome has three values: `'ok'` (the whole window was
|
|
368
|
-
read), `'partial'` (the fetch was truncated — the newest events may be
|
|
369
|
-
missing, with `last_run_error` explaining), and `'error'` (the run
|
|
370
|
-
failed). Treat only `last_run_status === 'ok'` as a complete
|
|
371
|
-
reconciliation; a `!== 'error'` check silently accepts a truncated
|
|
372
|
-
`partial`.
|
|
373
|
-
|
|
374
|
-
# See also
|
|
375
|
-
|
|
376
|
-
- `action_status_updaters.md` §7 — restapi wire shape, the two output
|
|
377
|
-
schemas, `msg.*` assigns
|
|
378
|
-
- `tools.md` — `rest_api` poller body, `status_poller` intent
|
|
379
|
-
- `cookbook/action-status-updaters.md` — the CloudWatch-flavoured sibling
|
|
380
|
-
(log-group polling instead of HTTP pagination)
|
|
381
|
-
- `_setup/foundation.md` — the bootstrap this walk starts from
|
|
382
|
-
- `integration-tests/tests/foundation/action-status-updaters.test.ts` —
|
|
383
|
-
the green test these calls are lifted from (§3d–§3f)
|
|
@@ -1,273 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
title: "Capability: pull records from a third-party REST API on demand"
|
|
3
|
-
summary: A capability walk for pull-based ingestion. Register a REST-API tool with its auth (Bearer here; an OAuth2 refresh-token variant is shown too), bind it to a contract and a Data Activation Client whose `tool_call` names the path to fetch, trigger a fetch (`api.dataActivationClients.runManually`), and verify the rows landed. The platform calls the API for you — no file to upload.
|
|
4
|
-
industry: subscription
|
|
5
|
-
slug: rest-fetch
|
|
6
|
-
vitest_source:
|
|
7
|
-
- integration-tests/tests/subscription/run-dac-fetch.test.ts
|
|
8
|
-
- integration-tests/tests/foundation/oauth2-dac-fetch.test.ts
|
|
9
|
-
- integration-tests/tests/subscription/bootstrap.test.ts
|
|
10
|
-
status: green
|
|
11
|
-
---
|
|
12
|
-
|
|
13
|
-
# Capability
|
|
14
|
-
|
|
15
|
-
**What you get:** records pulled straight from a third-party REST API into your
|
|
16
|
-
dataset on demand, with the platform handling auth and the HTTP call — no file
|
|
17
|
-
to export and upload.
|
|
18
|
-
|
|
19
|
-
A **pull-based** Data Activation Client fetches instead of receiving:
|
|
20
|
-
|
|
21
|
-
- the **tool** holds the API's base URL + auth (`bearer`, or `oauth2` with a
|
|
22
|
-
refresh-token grant the platform exchanges server-side before each call),
|
|
23
|
-
- the **DAC's `tool_call`** names the HTTP `method` + `path` to hit and how to
|
|
24
|
-
paginate; a `response_extractor` unwraps the API's envelope into a row array,
|
|
25
|
-
- `dataActivationClients.runManually(...)` triggers a fetch and returns a
|
|
26
|
-
`batch_id`.
|
|
27
|
-
|
|
28
|
-
This is **global** — only the API and its auth differ. See
|
|
29
|
-
`data_activation_clients.md` §6.3 (`.runManually`) and `tools.md` (`rest_api`
|
|
30
|
-
body) for the reference.
|
|
31
|
-
|
|
32
|
-
# Walkthrough
|
|
33
|
-
|
|
34
|
-
The `_setup/subscription.md` bootstrap left `api`, `tenantSlug`,
|
|
35
|
-
`datalakeSlug`, and `ctx.datalakeId` populated. The fetch targets a mock Stripe
|
|
36
|
-
API; locally that is the integration stack's WireMock on `localhost:8080`.
|
|
37
|
-
|
|
38
|
-
## 001 — create the data source
|
|
39
|
-
|
|
40
|
-
```typescript
|
|
41
|
-
const { data: ds } = await api.dataSources.create(tenantSlug, datalakeSlug, {
|
|
42
|
-
name: `REST Stripe Source ${runSuffix}`,
|
|
43
|
-
uri: 'api.stripe.com',
|
|
44
|
-
description: 'Stripe REST API — origin of the pulled customer rows.',
|
|
45
|
-
status: 'active',
|
|
46
|
-
is_default: false,
|
|
47
|
-
})
|
|
48
|
-
dataSourceId = ds.id!
|
|
49
|
-
```
|
|
50
|
-
|
|
51
|
-
## 002 — create the REST-API tool (Bearer auth)
|
|
52
|
-
|
|
53
|
-
The tool holds the API's `base_url` and credentials. `auth_method: 'bearer'`
|
|
54
|
-
with a `bearer_token` is the simplest case; `status: 'active'` is required (a
|
|
55
|
-
draft tool is skipped at fetch time — see Gotchas).
|
|
56
|
-
|
|
57
|
-
```typescript
|
|
58
|
-
const { data: tool } = await api.tools.create(tenantSlug, datalakeSlug, {
|
|
59
|
-
name: `Stripe REST Tool ${runSuffix}`,
|
|
60
|
-
description: 'WireMock-mocked Stripe REST API (AR customer fetch).',
|
|
61
|
-
intent: 'data_exchange',
|
|
62
|
-
status: 'active',
|
|
63
|
-
datalake_id: ctx.datalakeId,
|
|
64
|
-
data_source_id: dataSourceId,
|
|
65
|
-
body: {
|
|
66
|
-
tool_body_type: 'rest_api',
|
|
67
|
-
auth_method: 'bearer',
|
|
68
|
-
base_url: 'http://localhost:8080/stripe',
|
|
69
|
-
bearer_token: 'sk_test_vitest_stripe_token',
|
|
70
|
-
request_type: 'json',
|
|
71
|
-
response_type: 'json',
|
|
72
|
-
timeout_ms: 30_000,
|
|
73
|
-
},
|
|
74
|
-
})
|
|
75
|
-
ctx.restToolId = tool.id!
|
|
76
|
-
```
|
|
77
|
-
|
|
78
|
-
## 003 — create the customer interoperability contract
|
|
79
|
-
|
|
80
|
-
The contract shapes each fetched customer into a `Customer` upsert. Reuse the
|
|
81
|
-
vendored production Stripe customer + MDM templates.
|
|
82
|
-
|
|
83
|
-
```typescript
|
|
84
|
-
const { readFileSync } = await import('node:fs')
|
|
85
|
-
const { join } = await import('node:path')
|
|
86
|
-
const customerTemplate = readFileSync(
|
|
87
|
-
join(process.env.COOKBOOK_FIXTURES_DIR!, 'subscription/_customers_subscription_customer.liquid'),
|
|
88
|
-
'utf8',
|
|
89
|
-
)
|
|
90
|
-
const mdmTemplate = readFileSync(
|
|
91
|
-
join(process.env.COOKBOOK_FIXTURES_DIR!, 'subscription/_customers_subscription_mdm.liquid'),
|
|
92
|
-
'utf8',
|
|
93
|
-
)
|
|
94
|
-
|
|
95
|
-
const { data: contract } = await api.interoperabilityContracts.create(tenantSlug, datalakeSlug, {
|
|
96
|
-
name: `Stripe REST Customer Contract ${runSuffix}`,
|
|
97
|
-
description: 'Stripe customer fetch → Subscription Customer.',
|
|
98
|
-
resource_type: 'customer',
|
|
99
|
-
template_config: { type: 'custom', body: customerTemplate },
|
|
100
|
-
mdm_input_config: { type: 'custom', body: mdmTemplate },
|
|
101
|
-
generic_table_id: null,
|
|
102
|
-
})
|
|
103
|
-
interopContractId = contract.id!
|
|
104
|
-
```
|
|
105
|
-
|
|
106
|
-
## 004 — create the fetch DAC
|
|
107
|
-
|
|
108
|
-
The Data Activation Client's `tool_call` selects the REST path: `restapi_request`
|
|
109
|
-
with the HTTP `method` and the `path` to hit (`/v1/customers`, appended to the
|
|
110
|
-
tool's `base_url`). `pagination_context_template` declares no further pages here.
|
|
111
|
-
A top-level `response_extractor` unwraps the API's `{ data: [...] }` envelope so
|
|
112
|
-
each element becomes one row.
|
|
113
|
-
|
|
114
|
-
```typescript
|
|
115
|
-
const { data: dac } = await api.dataActivationClients.create(tenantSlug, datalakeSlug, {
|
|
116
|
-
name: `Stripe REST Fetch DAC ${runSuffix}`,
|
|
117
|
-
description: 'Pull-based DAC — fetches Stripe customers over REST.',
|
|
118
|
-
tool_id: ctx.restToolId,
|
|
119
|
-
data_source_id: dataSourceId,
|
|
120
|
-
tool_call: {
|
|
121
|
-
tool_call_type: 'restapi_request',
|
|
122
|
-
method: 'get',
|
|
123
|
-
path: { type: 'custom', body: '/v1/customers' },
|
|
124
|
-
pagination_context_template: { type: 'custom', body: '{"has_next": false}' },
|
|
125
|
-
},
|
|
126
|
-
response_extractor: { type: 'custom', body: '{{ msg.data | to_json }}' },
|
|
127
|
-
interoperability_contract_ids: [interopContractId],
|
|
128
|
-
})
|
|
129
|
-
dacId = dac.id!
|
|
130
|
-
ctx.dacSlug = dac.slug!
|
|
131
|
-
```
|
|
132
|
-
|
|
133
|
-
## 005 — trigger a fetch
|
|
134
|
-
|
|
135
|
-
`runManually` enqueues a fetch and returns the `batch_id` immediately; the actual
|
|
136
|
-
HTTP call + ingestion run in a background worker.
|
|
137
|
-
|
|
138
|
-
```typescript
|
|
139
|
-
const { data: run } = await api.dataActivationClients.runManually(tenantSlug, datalakeSlug, ctx.dacSlug)
|
|
140
|
-
ctx.batchId = run.batch_id!
|
|
141
|
-
if (!ctx.batchId) {
|
|
142
|
-
throw new Error('runManually did not return a batch_id')
|
|
143
|
-
}
|
|
144
|
-
```
|
|
145
|
-
|
|
146
|
-
## 006 — verify the fetched rows landed
|
|
147
|
-
|
|
148
|
-
Poll the DAC logs until the batch shows a fully-merged ingest (the mock returns 3
|
|
149
|
-
customers), then search the `customer` dataset scoped to the batch.
|
|
150
|
-
|
|
151
|
-
```typescript
|
|
152
|
-
const deadline = Date.now() + 90_000
|
|
153
|
-
let merged = false
|
|
154
|
-
while (Date.now() < deadline && !merged) {
|
|
155
|
-
const { data } = await api.dataActivationClients.logs.list(tenantSlug, datalakeSlug, ctx.dacSlug)
|
|
156
|
-
merged = (data.data ?? []).some((r) => {
|
|
157
|
-
const row = r as Record<string, unknown>
|
|
158
|
-
return row.batch_id === ctx.batchId &&
|
|
159
|
-
typeof row.rows_ingested === 'number' && row.rows_ingested >= 3 &&
|
|
160
|
-
Array.isArray(row.output_files) && row.output_files.length > 0
|
|
161
|
-
})
|
|
162
|
-
if (!merged) await new Promise((r) => setTimeout(r, 1_000))
|
|
163
|
-
}
|
|
164
|
-
if (!merged) {
|
|
165
|
-
throw new Error('REST fetch did not produce a merged batch within 90s')
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
const { data: userSearch } = await api.datasets.createUserSearch(tenantSlug, datalakeSlug, 'customer', {
|
|
169
|
-
search_query: `ra.batch_id = '${ctx.batchId}'`,
|
|
170
|
-
})
|
|
171
|
-
const searchDeadline = Date.now() + 60_000
|
|
172
|
-
let rows: Array<Record<string, unknown>> = []
|
|
173
|
-
while (Date.now() < searchDeadline && rows.length < 3) {
|
|
174
|
-
const { data: page } = await api.datasets.search(tenantSlug, datalakeSlug, 'customer', {
|
|
175
|
-
userSearchId: userSearch.id!,
|
|
176
|
-
dataAccessMode: 'unregulated',
|
|
177
|
-
})
|
|
178
|
-
rows = (page.data ?? []) as Array<Record<string, unknown>>
|
|
179
|
-
if (rows.length < 3) await new Promise((r) => setTimeout(r, 1_000))
|
|
180
|
-
}
|
|
181
|
-
if (rows.length < 3) {
|
|
182
|
-
throw new Error(`expected 3 fetched customers, found ${rows.length}`)
|
|
183
|
-
}
|
|
184
|
-
```
|
|
185
|
-
|
|
186
|
-
## 007 — variant: an OAuth2 refresh-token tool
|
|
187
|
-
|
|
188
|
-
When the API uses OAuth2, the tool carries the full `oauth2_*` grant config
|
|
189
|
-
instead of a static token. The platform exchanges the `oauth2_refresh_token` for
|
|
190
|
-
an access token **server-side before each fetch** — your code never handles the
|
|
191
|
-
token. This step creates such a tool to show the body shape (the fetch itself
|
|
192
|
-
runs the same `runManually` flow once bound to a DAC + contract for that API).
|
|
193
|
-
|
|
194
|
-
```typescript
|
|
195
|
-
const { data: oauthTool } = await api.tools.create(tenantSlug, datalakeSlug, {
|
|
196
|
-
name: `OAuth2 REST Tool ${runSuffix}`,
|
|
197
|
-
description: 'REST tool authenticating via an OAuth2 refresh-token grant.',
|
|
198
|
-
intent: 'data_exchange',
|
|
199
|
-
status: 'active',
|
|
200
|
-
datalake_id: ctx.datalakeId,
|
|
201
|
-
data_source_id: dataSourceId,
|
|
202
|
-
body: {
|
|
203
|
-
tool_body_type: 'rest_api',
|
|
204
|
-
auth_method: 'oauth2',
|
|
205
|
-
base_url: 'http://localhost:8080/quickbooks',
|
|
206
|
-
oauth2_grant_type: 'authorization_code',
|
|
207
|
-
oauth2_client_id: 'test_client_id',
|
|
208
|
-
oauth2_client_secret: 'test_client_secret',
|
|
209
|
-
oauth2_token_url: 'http://localhost:8080/quickbooks/oauth2/v1/tokens/bearer',
|
|
210
|
-
oauth2_refresh_token: 'test_initial_refresh_token',
|
|
211
|
-
oauth2_scope: 'com.intuit.quickbooks.accounting',
|
|
212
|
-
oauth2_token_ttl: 3300,
|
|
213
|
-
request_type: 'json',
|
|
214
|
-
response_type: 'json',
|
|
215
|
-
timeout_ms: 30_000,
|
|
216
|
-
},
|
|
217
|
-
})
|
|
218
|
-
if (!oauthTool.id) {
|
|
219
|
-
throw new Error('OAuth2 tool create did not return an id')
|
|
220
|
-
}
|
|
221
|
-
```
|
|
222
|
-
|
|
223
|
-
## 008 — write the integration test
|
|
224
|
-
|
|
225
|
-
End the build with a test you keep: trigger one pull and assert the
|
|
226
|
-
enqueue acknowledgement. `runManually` answers with the allocated
|
|
227
|
-
`batch_id` BEFORE the fetch runs — the HTTP call and the ingestion drain
|
|
228
|
-
on a background worker — so the durable test asserts on the call's own
|
|
229
|
-
response, never on polling the merge (that diagnosis walk lives in §006).
|
|
230
|
-
This block runs live under `make validate-cookbook`.
|
|
231
|
-
|
|
232
|
-
```typescript
|
|
233
|
-
// Re-GET — the client must still be bound to the REST tool.
|
|
234
|
-
const { data: fetchDac } = await api.dataActivationClients.get(tenantSlug, datalakeSlug, dacId)
|
|
235
|
-
if (fetchDac.tool_id !== ctx.restToolId) {
|
|
236
|
-
throw new Error('DAC is no longer bound to the REST fetch tool')
|
|
237
|
-
}
|
|
238
|
-
// Behavioural probe — one manual pull; the ack carries the batch_id
|
|
239
|
-
// the worker will report under.
|
|
240
|
-
const { data: probePull } = await api.dataActivationClients.runManually(tenantSlug, datalakeSlug, ctx.dacSlug)
|
|
241
|
-
if (typeof probePull.batch_id !== 'string' || probePull.batch_id.length === 0) {
|
|
242
|
-
throw new Error('runManually probe returned no batch_id')
|
|
243
|
-
}
|
|
244
|
-
```
|
|
245
|
-
|
|
246
|
-
If the probe fails in production, escalate with the response — and if
|
|
247
|
-
the enqueue succeeds but rows never land, the evidence to escalate with
|
|
248
|
-
is the batch's log row (§006's read), not a re-run storm.
|
|
249
|
-
|
|
250
|
-
# Gotchas
|
|
251
|
-
|
|
252
|
-
- **A draft tool is silently skipped at fetch time.** The worker only fetches
|
|
253
|
-
through `status: 'active'` tools — a `draft` tool produces zero log rows (no
|
|
254
|
-
error, no data). If a fetch ingests nothing, check the tool status first.
|
|
255
|
-
- **The `response_extractor` unwraps the API envelope.** REST APIs wrap rows in
|
|
256
|
-
an envelope (`{ data: [...] }`); the extractor (`{{ msg.data | to_json }}`)
|
|
257
|
-
turns that into the row array the contract maps. Without it, the whole envelope
|
|
258
|
-
is treated as one row.
|
|
259
|
-
- **OAuth2 token exchange is server-side.** With `auth_method: 'oauth2'`, the
|
|
260
|
-
platform swaps the refresh token for an access token before each call — your
|
|
261
|
-
code never sees a token. Bad credentials fail the exchange and produce zero log
|
|
262
|
-
rows (same silent-skip shape as a draft tool).
|
|
263
|
-
- **`runManually` returns immediately.** The `batch_id` comes back before the
|
|
264
|
-
fetch runs; poll the logs to know when rows actually landed.
|
|
265
|
-
|
|
266
|
-
# See also
|
|
267
|
-
|
|
268
|
-
- `data_activation_clients.md` §6.3 — `.runManually` + the pull-based reference
|
|
269
|
-
- `tools.md` — the `rest_api` tool body, `bearer` vs `oauth2` auth
|
|
270
|
-
- `_setup/subscription.md` — the bootstrap this walk starts from
|
|
271
|
-
- `integration-tests/tests/subscription/run-dac-fetch.test.ts`,
|
|
272
|
-
`integration-tests/tests/foundation/oauth2-dac-fetch.test.ts` — the green tests
|
|
273
|
-
these calls are lifted from
|