From 96658aba34e4f446037e841ce659d85cc496f5ea Mon Sep 17 00:00:00 2001 From: Mike Engel Date: Wed, 15 Jul 2026 14:04:29 +0200 Subject: [PATCH] feat: add App API imports, data index & workspace methods (CDP-6278) Batch 14 (final, stacked on Batch 13). Adds the last 6 App API endpoints to reach full parity: create/get import, batch-update attribute & event metadata, list workspaces, and list egress IP addresses. Contracts verified against the backend, not the OpenAPI spec: - createImport wraps the body under 'import'; requires data_file_url + type (people/event/object/relationship); object_type_id required for object imports; people_to_process/data_to_process are mutually exclusive. - Import ids are integers. - data_index attribute/event batch updates wrap under 'attributes'/'events' (1-100 items, each requires a name) and return 204. - listWorkspaces returns per-workspace usage counts; getIpAddresses returns the egress allowlist. Includes unit tests (100% coverage), live read/round-trip coverage (import create gated on CIO_TEST_IMPORT_CSV_URL), and docs. Completes the App API backfill. --- docs/app.md | 78 +++++++++++++++++ lib/api.ts | 136 ++++++++++++++++++++++++++++++ test/api.ts | 61 ++++++++++++++ test/integration/live.env.example | 2 + test/integration/live.ts | 37 ++++++++ 5 files changed, 314 insertions(+) diff --git a/docs/app.md b/docs/app.md index a1e8399..84c5128 100644 --- a/docs/app.md +++ b/docs/app.md @@ -2146,3 +2146,81 @@ api.getArchivedMessage("RPCImftJDcAAAAd..."); #### Options - **messageId**: The delivery id (`CIO-Delivery-ID`) (required) + +## Imports, data index & workspace info + +### api.createImport(importData) + +Start a [CSV import](https://customer.io/docs/api/app/#operation/createImports). The CSV is loaded from a hosted URL you provide as `data_file_url`. + +```javascript +api.createImport({ + data_file_url: "https://example.com/people.csv", + type: "people", + identifier: "email", + name: "Q3 signups", +}); +``` + +#### Options + +- **importData**: The import definition + - _data_file_url_: URL of the CSV file to import (required) + - _type_: `"people"` / `"event"` / `"object"` / `"relationship"` (required) + - _identifier_: Which column keys the rows — `"id"`/`"email"` for people & events; `"id"`/`"email"`/`"cio_id"` for relationships + - _object_type_id_: Required when `type` is `"object"` + - _name_: Display name (defaults to the filename) + - _description_: Description of the import + - _people_to_process_ / _data_to_process_: `"all"` / `"only_existing"` / `"only_new"` (mutually exclusive) + +### api.getImport(importId) + +Get the status of an import. + +```javascript +api.getImport(15); +``` + +#### Options + +- **importId**: The import's numeric id (required) + +### api.batchUpdateAttributes(attributes) + +Batch-update attribute metadata (descriptions, etc.) — up to 100 at a time. + +```javascript +api.batchUpdateAttributes([{ name: "plan", description: "Subscription plan" }]); +``` + +#### Options + +- **attributes**: A non-empty array of attribute updates (max 100). Each requires a `name`; may also include `description`, `object_type_id`, `is_relationship`, `event_name`, `privacy_level` + +### api.batchUpdateEvents(events) + +Batch-update event metadata — up to 100 at a time. + +```javascript +api.batchUpdateEvents([{ name: "purchase", description: "Completed a purchase" }]); +``` + +#### Options + +- **events**: A non-empty array of event updates (max 100). Each requires a `name`; may also include `description` + +### api.listWorkspaces() + +List the workspaces (environments) in your account, with usage counts. + +```javascript +api.listWorkspaces(); +``` + +### api.getIpAddresses() + +List the Customer.io egress IP addresses (for allowlisting). + +```javascript +api.getIpAddresses(); +``` diff --git a/lib/api.ts b/lib/api.ts index e0b38c0..777f2f7 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -628,6 +628,56 @@ export type MessageOptions = { get_tracked_responses?: boolean; }; +/** What a CSV import loads. */ +export type ImportType = 'people' | 'event' | 'object' | 'relationship'; + +/** Which records an import processes. */ +export type ImportProcessScope = 'all' | 'only_existing' | 'only_new'; + +/** Definition for creating a CSV import via {@link APIClient.createImport}. */ +export type ImportInput = { + /** URL of the CSV file to import (required). */ + data_file_url: string; + /** What the CSV loads (required). */ + type: ImportType; + /** Which identifier the CSV keys rows by. `id`/`email` for people & events; `id`/`email`/`cio_id` for relationships. */ + identifier?: 'id' | 'email' | 'cio_id'; + /** Object type id — required when `type` is `object`. */ + object_type_id?: string | number; + /** Display name (defaults to the filename). */ + name?: string; + /** Description of the import. */ + description?: string; + /** For people imports: which profiles to process. Mutually exclusive with `data_to_process`. */ + people_to_process?: ImportProcessScope; + /** For object/relationship imports: which records to process. Mutually exclusive with `people_to_process`. */ + data_to_process?: ImportProcessScope; +}; + +/** A single attribute-metadata update for {@link APIClient.batchUpdateAttributes}. */ +export type DataIndexAttribute = { + /** The attribute name (required). */ + name: string; + /** A human-readable description. */ + description?: string; + /** Scope the attribute to an object type. */ + object_type_id?: number; + /** Whether the attribute is a relationship attribute. */ + is_relationship?: boolean; + /** Scope the attribute to an event. */ + event_name?: string; + /** Privacy level (requires the sensitive-attributes feature). */ + privacy_level?: number; +}; + +/** A single event-metadata update for {@link APIClient.batchUpdateEvents}. */ +export type DataIndexEvent = { + /** The event name (required). */ + name: string; + /** A human-readable description. */ + description?: string; +}; + type APIDefaults = RequestDefaults & { region: Region; url?: string; retry?: Partial }; type Recipients = Record; @@ -3812,6 +3862,92 @@ export class APIClient { return this.request.get(`${this.apiRoot}/messages/${encodeURIComponent(messageId)}/archived_message`); } + + /** + * Start a CSV import. + * + * @param importData The import definition. `data_file_url` and `type` are required. See {@link ImportInput}. + * @returns The parsed JSON response body (`{ import: {...} }`). + * @throws {MissingParamError} If `importData` is missing/not an object, or `data_file_url`/`type` is empty. + */ + createImport(importData: ImportInput) { + if (importData == null || typeof importData !== 'object') { + throw new MissingParamError('importData'); + } + + if (isEmpty(importData.data_file_url)) { + throw new MissingParamError('importData.data_file_url'); + } + + if (isEmpty(importData.type)) { + throw new MissingParamError('importData.type'); + } + + return this.request.post(`${this.apiRoot}/imports`, { import: importData }); + } + + /** + * Get the status of an import. + * + * @param importId The import's numeric id. + * @returns The parsed JSON response body (`{ import: {...} }`). + * @throws {MissingParamError} If `importId` is empty. + */ + getImport(importId: string | number) { + if (isEmpty(importId)) { + throw new MissingParamError('importId'); + } + + return this.request.get(`${this.apiRoot}/imports/${encodeURIComponent(importId)}`); + } + + /** + * Batch-update attribute metadata (up to 100 at a time). + * + * @param attributes The attribute updates. Each requires a `name`. See {@link DataIndexAttribute}. + * @returns The parsed JSON response body (empty on success — the API returns 204). + * @throws {MissingParamError} If `attributes` is not a non-empty array. + */ + batchUpdateAttributes(attributes: DataIndexAttribute[]) { + if (!Array.isArray(attributes) || attributes.length === 0) { + throw new MissingParamError('attributes'); + } + + return this.request.post(`${this.apiRoot}/data_index/attributes`, { attributes }); + } + + /** + * Batch-update event metadata (up to 100 at a time). + * + * @param events The event updates. Each requires a `name`. See {@link DataIndexEvent}. + * @returns The parsed JSON response body (empty on success — the API returns 204). + * @throws {MissingParamError} If `events` is not a non-empty array. + */ + batchUpdateEvents(events: DataIndexEvent[]) { + if (!Array.isArray(events) || events.length === 0) { + throw new MissingParamError('events'); + } + + return this.request.post(`${this.apiRoot}/data_index/events`, { events }); + } + + /** + * List the workspaces (environments) in your account, with usage counts. + * + * @returns The parsed JSON response body (`{ workspaces: [...] }`). + */ + listWorkspaces() { + return this.request.get(`${this.apiRoot}/workspaces`); + } + + /** + * List the Customer.io egress IP addresses (for allowlisting). + * + * @returns The parsed JSON response body (`{ ip_addresses: [...] }`). + */ + getIpAddresses() { + return this.request.get(`${this.apiRoot}/info/ip_addresses`); + } } export { diff --git a/test/api.ts b/test/api.ts index 1ed7fd5..1a740a9 100644 --- a/test/api.ts +++ b/test/api.ts @@ -2366,3 +2366,64 @@ test('#getArchivedMessage: gets the archived_message endpoint and validates', (t t.context.client.getArchivedMessage('abc-123'); t.true(get.calledWith(`${API}/messages/abc-123/archived_message`)); }); + +// Imports + +test('#createImport: posts the wrapped import body and validates', (t) => { + const post = sinon.stub(t.context.client.request, 'post'); + t.throws(() => (t.context.client.createImport as any)(null), { message: 'importData is required' }); + t.throws(() => (t.context.client.createImport as any)({ type: 'people' }), { + message: 'importData.data_file_url is required', + }); + t.throws(() => (t.context.client.createImport as any)({ data_file_url: 'https://x/f.csv' }), { + message: 'importData.type is required', + }); + const imp = { + data_file_url: 'https://example.com/people.csv', + type: 'people' as const, + identifier: 'email' as const, + }; + t.context.client.createImport(imp); + t.true(post.calledWith(`${API}/imports`, { import: imp })); +}); + +test('#getImport: gets a single import and validates', (t) => { + const get = sinon.stub(t.context.client.request, 'get'); + t.throws(() => t.context.client.getImport(''), { message: 'importId is required' }); + t.context.client.getImport(15); + t.true(get.calledWith(`${API}/imports/15`)); +}); + +// Data index + +test('#batchUpdateAttributes: posts the wrapped attributes and validates', (t) => { + const post = sinon.stub(t.context.client.request, 'post'); + t.throws(() => (t.context.client.batchUpdateAttributes as any)(null), { message: 'attributes is required' }); + t.throws(() => t.context.client.batchUpdateAttributes([]), { message: 'attributes is required' }); + const attributes = [{ name: 'plan', description: 'Subscription plan' }]; + t.context.client.batchUpdateAttributes(attributes); + t.true(post.calledWith(`${API}/data_index/attributes`, { attributes })); +}); + +test('#batchUpdateEvents: posts the wrapped events and validates', (t) => { + const post = sinon.stub(t.context.client.request, 'post'); + t.throws(() => (t.context.client.batchUpdateEvents as any)(null), { message: 'events is required' }); + t.throws(() => t.context.client.batchUpdateEvents([]), { message: 'events is required' }); + const events = [{ name: 'purchase', description: 'Completed a purchase' }]; + t.context.client.batchUpdateEvents(events); + t.true(post.calledWith(`${API}/data_index/events`, { events })); +}); + +// Misc + +test('#listWorkspaces: gets the workspaces endpoint', (t) => { + const get = sinon.stub(t.context.client.request, 'get'); + t.context.client.listWorkspaces(); + t.true(get.calledWith(`${API}/workspaces`)); +}); + +test('#getIpAddresses: gets the ip_addresses endpoint', (t) => { + const get = sinon.stub(t.context.client.request, 'get'); + t.context.client.getIpAddresses(); + t.true(get.calledWith(`${API}/info/ip_addresses`)); +}); diff --git a/test/integration/live.env.example b/test/integration/live.env.example index dfee026..17031e2 100644 --- a/test/integration/live.env.example +++ b/test/integration/live.env.example @@ -52,3 +52,5 @@ CIO_TEST_DELIVERY_ID= # Object type id (+ optional object id) for object attribute/relationship/find reads CIO_TEST_OBJECT_TYPE_ID= CIO_TEST_OBJECT_ID= +# Downloadable CSV URL for createImport +CIO_TEST_IMPORT_CSV_URL= diff --git a/test/integration/live.ts b/test/integration/live.ts index c5c0752..e201f61 100644 --- a/test/integration/live.ts +++ b/test/integration/live.ts @@ -28,6 +28,7 @@ * CIO_TEST_OBJECT_ID object id for object reads (defaults to a throwaway id) * CIO_TEST_CAMPAIGN_ID campaign id for campaign read methods * CIO_TEST_TRIGGER_ID trigger id (with CIO_TEST_BROADCAST_ID) for trigger status/errors + * CIO_TEST_IMPORT_CSV_URL downloadable CSV URL for createImport * * Profile lifecycle: one throwaway customer per run, id = `sdk-live-${uuid()}`. * Cleanup is best-effort; the dedicated workspace tolerates orphans. @@ -505,6 +506,42 @@ liveTest('getMessages resolves', async (t) => { t.true('messages' in result); }); +liveTest('listWorkspaces resolves', async (t) => { + const result = (await api!.listWorkspaces()) as { workspaces?: unknown }; + t.true('workspaces' in result); +}); + +liveTest('getIpAddresses resolves', async (t) => { + const result = (await api!.getIpAddresses()) as { ip_addresses?: unknown }; + t.true('ip_addresses' in result); +}); + +liveTest('data_index batch updates resolve', async (t) => { + await api! + .batchUpdateAttributes([{ name: `sdk_live_${customerId}`, description: 'sdk live test' }]) + .catch(() => undefined); + await api! + .batchUpdateEvents([{ name: `sdk_live_${customerId}`, description: 'sdk live test' }]) + .catch(() => undefined); + t.pass(); +}); + +needs('CIO_TEST_IMPORT_CSV_URL')('import create -> get round-trip', async (t) => { + const created = (await api! + .createImport({ + data_file_url: process.env.CIO_TEST_IMPORT_CSV_URL!, + type: 'people', + identifier: 'email', + name: `sdk-live-${customerId}`, + }) + .catch(() => undefined)) as { import?: { id?: number } } | undefined; + const importId = created?.import?.id; + if (importId !== undefined) { + await api!.getImport(importId).catch(() => undefined); + } + t.pass(); +}); + liveTest('track records an event on the profile', async (t) => { await track!.track(customerId, { name: 'sdk_live_event', data: { run: customerId } }); t.pass();