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
400 changes: 400 additions & 0 deletions packages/activecampaign/behaviour.test.ts

Large diffs are not rendered by default.

124 changes: 124 additions & 0 deletions packages/activecampaign/client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { ActiveCampaignAPIError } from './client';

/**
* `request` may hand fetch either a plain object or a `Headers` instance, so
* header assertions normalise both. Reading only one shape would let an
* assertion pass against an empty object.
*/
function readHeaders(init: RequestInit | undefined): Record<string, string> {
const raw = init?.headers;
if (!raw) return {};
if (raw instanceof Headers) return Object.fromEntries(raw.entries());
if (Array.isArray(raw)) return Object.fromEntries(raw);
return Object.fromEntries(
Object.entries(raw as Record<string, string>).map(([k, v]) => [k, v]),
);
}

describe('ActiveCampaign client', () => {
const originalFetch = globalThis.fetch;
let calls: Array<{ url: string; init?: RequestInit }>;

beforeEach(() => {
calls = [];
globalThis.fetch = (async (url: string, init?: RequestInit) => {
calls.push({ url: String(url), init });
return new Response(JSON.stringify({ tags: [], meta: { total: '0' } }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}) as typeof fetch;
});

afterEach(() => {
globalThis.fetch = originalFetch;
});

describe('credential validation', () => {
it('rejects a missing API token before issuing a request', async () => {
const { makeActiveCampaignRequest } = await import('./client');
await expect(
makeActiveCampaignRequest('tags', '', 'example'),
).rejects.toBeInstanceOf(ActiveCampaignAPIError);
expect(calls).toHaveLength(0);
});

it('rejects a missing account before issuing a request', async () => {
const { makeActiveCampaignRequest } = await import('./client');
await expect(
makeActiveCampaignRequest('tags', 'token-123', ''),
).rejects.toBeInstanceOf(ActiveCampaignAPIError);
expect(calls).toHaveLength(0);
});

/**
* The account slug is interpolated into the hostname, so a value
* carrying a slash or a dot could redirect the request to another host.
*/
it.each([
['evil.com/', 'a slash'],
['host.other.com', 'a dot'],
['a b', 'a space'],
['acct@x', 'an at sign'],
])('rejects an account containing %s (%s)', async (account) => {
const { makeActiveCampaignRequest } = await import('./client');
await expect(
makeActiveCampaignRequest('tags', 'token-123', account),
).rejects.toBeInstanceOf(ActiveCampaignAPIError);
expect(calls).toHaveLength(0);
});

it('accepts an account of letters, digits and hyphens', async () => {
const { makeActiveCampaignRequest } = await import('./client');
await makeActiveCampaignRequest('tags', 'token-123', 'my-account-1');
expect(calls).toHaveLength(1);
});
});

describe('request shape', () => {
it('sends the token in an Api-Token header, not a query string', async () => {
const { makeActiveCampaignRequest } = await import('./client');
await makeActiveCampaignRequest('tags', 'token-123', 'example');

expect(calls).toHaveLength(1);
const headers = readHeaders(calls[0]?.init);
const headerNames = Object.keys(headers).map((h) => h.toLowerCase());
expect(headerNames).toContain('api-token');
expect(headers['Api-Token'] ?? headers['api-token']).toBe('token-123');
// A key in the query string would leak into logs and referrers.
expect(calls[0]?.url).not.toContain('token-123');
});

it('builds the account-specific base URL', async () => {
const { makeActiveCampaignRequest } = await import('./client');
await makeActiveCampaignRequest('tags', 'token-123', 'example');
expect(calls[0]?.url).toContain('https://example.api-us1.com/api/3');
expect(calls[0]?.url).toContain('/tags');
});

it('routes GraphQL to /ecom/graphql on the same host', async () => {
const { makeActiveCampaignGraphQLRequest } = await import('./client');
await makeActiveCampaignGraphQLRequest(
'{ products { id } }',
'token-123',
'example',
);
expect(calls[0]?.url).toContain('https://example.api-us1.com/api/3');
expect(calls[0]?.url).toContain('ecom/graphql');
const headers = readHeaders(calls[0]?.init);
expect(headers['Api-Token'] ?? headers['api-token']).toBe('token-123');
});

it('uses the same auth header for REST and GraphQL', async () => {
const { makeActiveCampaignRequest, makeActiveCampaignGraphQLRequest } =
await import('./client');
await makeActiveCampaignRequest('tags', 'token-123', 'example');
await makeActiveCampaignGraphQLRequest('{ x }', 'token-123', 'example');
const first = readHeaders(calls[0]?.init);
const second = readHeaders(calls[1]?.init);
expect(first['Api-Token'] ?? first['api-token']).toBe(
second['Api-Token'] ?? second['api-token'],
);
});
});
});
155 changes: 155 additions & 0 deletions packages/activecampaign/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import type {
ApiRequestOptions,
OpenAPIConfig,
RateLimitConfig,
} from 'corsair/http';
import { request } from 'corsair/http';

export class ActiveCampaignAPIError extends Error {
constructor(
message: string,
public readonly code?: string,
) {
super(message);
this.name = 'ActiveCampaignAPIError';
}
}

/**
* ActiveCampaign hosts every account on its own subdomain, so the base URL
* cannot be a constant the way it can for a single-tenant API. The account
* slug is the second half of the credential and is supplied alongside the key.
*
* @see https://developers.activecampaign.com/reference/url
*/
function buildBaseUrl(account: string): string {
return `https://${account}.api-us1.com/api/3`;
}

/**
* ActiveCampaign allows 5 requests per second per account, shared across the
* REST and GraphQL surfaces, and answers 429 once that is exceeded. Unlike
* many APIs it returns rate-limit headers on successful responses as well as
* on rejections (`RateLimit-Limit`, `RateLimit-Remaining`), and a
* `Retry-After` on the 429 itself, which is the header the retry honours.
*
* @see https://developers.activecampaign.com/reference/rate-limits
*/
const ACTIVECAMPAIGN_RATE_LIMIT_CONFIG: RateLimitConfig = {
enabled: true,
maxRetries: 5,
initialRetryDelay: 1000,
backoffMultiplier: 2,
headerNames: {
retryAfter: 'Retry-After',
},
};

function buildConfig(apiToken: string, account: string): OpenAPIConfig {
return {
BASE: buildBaseUrl(account),
VERSION: '3',
WITH_CREDENTIALS: false,
CREDENTIALS: 'omit',
TOKEN: undefined,
HEADERS: {
'Content-Type': 'application/json',
Accept: 'application/json',
'Api-Token': apiToken,
},
};
}

/**
* Rejects a credential that is missing or that carries characters which cannot
* appear in a hostname. The account slug is interpolated into the base URL, so
* validating it here keeps a malformed value from redirecting a request to
* another host.
*/
function assertCredentials(apiToken: string, account: string): void {
if (!apiToken) {
throw new ActiveCampaignAPIError(
'An API token is required for the ActiveCampaign integration',
'MISSING_API_TOKEN',
);
}
if (!account) {
throw new ActiveCampaignAPIError(
'An account name is required for the ActiveCampaign integration - it is the subdomain of your API URL, https://<account>.api-us1.com',
'MISSING_ACCOUNT',
);
}
if (!/^[a-zA-Z0-9-]+$/.test(account)) {
throw new ActiveCampaignAPIError(
'The ActiveCampaign account name must contain only letters, numbers and hyphens',
'INVALID_ACCOUNT',
);
}
}

/**
* Issues a v3 REST request with the account's `Api-Token` header, rate-limit
* retries and this plugin's error handlers.
*/
export async function makeActiveCampaignRequest<T>(
endpoint: string,
apiToken: string,
account: string,
options: {
method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
/** Most endpoints take an object envelope; the bulk ones take a raw array. */
body?: Record<string, unknown> | unknown[];
query?: Record<string, string | number | boolean | undefined>;
} = {},
): Promise<T> {
assertCredentials(apiToken, account);
const { method = 'GET', body, query } = options;

const requestOptions: ApiRequestOptions = {
method,
url: endpoint,
body:
method === 'POST' || method === 'PUT' || method === 'PATCH'
? body
: undefined,
mediaType: 'application/json; charset=utf-8',
query,
};

return await request<T>(buildConfig(apiToken, account), requestOptions, {
rateLimitConfig: ACTIVECAMPAIGN_RATE_LIMIT_CONFIG,
});
}

/**
* Issues an eComm GraphQL request.
*
* ActiveCampaign puts its e-commerce catalog behind GraphQL at
* `/ecom/graphql` rather than extending the REST surface, but on the same host
* and behind the same `Api-Token` header and the same 5 req/sec budget. Both
* transports therefore share one config builder and one rate-limit config, so
* the two surfaces cannot drift apart in auth or throttling behaviour.
*
* Used by the e-commerce GraphQL operations in `endpoints/platform.ts`.
*
* @see https://developers.activecampaign.com/reference/about-the-graphql-api
*/
export async function makeActiveCampaignGraphQLRequest<T>(
query: string,
apiToken: string,
account: string,
variables?: Record<string, unknown>,
): Promise<T> {
assertCredentials(apiToken, account);

const requestOptions: ApiRequestOptions = {
method: 'POST',
url: 'ecom/graphql',
body: variables ? { query, variables } : { query },
mediaType: 'application/json; charset=utf-8',
};

return await request<T>(buildConfig(apiToken, account), requestOptions, {
rateLimitConfig: ACTIVECAMPAIGN_RATE_LIMIT_CONFIG,
});
}
Loading
Loading