Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions docs/app.md
Original file line number Diff line number Diff line change
Expand Up @@ -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();
```
136 changes: 136 additions & 0 deletions lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<RetryOptions> };

type Recipients = Record<string, unknown>;
Expand Down Expand Up @@ -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 {
Expand Down
61 changes: 61 additions & 0 deletions test/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`));
});
2 changes: 2 additions & 0 deletions test/integration/live.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
37 changes: 37 additions & 0 deletions test/integration/live.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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();
Expand Down