Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
135 changes: 135 additions & 0 deletions packages/bouncer/client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import {
BOUNCER_API_BASE,
BouncerAPIError,
makeBouncerRequest,
} from './client';

const realFetch = global.fetch;

describe('Bouncer client', () => {
let calls: { url: string; init?: RequestInit }[] = [];

beforeEach(() => {
calls = [];
});

afterEach(() => {
global.fetch = realFetch;
});

function mockFetch(
status = 200,
body: unknown = {},
headers: Record<string, string> = {},
) {
global.fetch = (async (url: string, init?: RequestInit) => {
calls.push({ url, init });
return {
ok: status >= 200 && status < 300,
status,
statusText: status === 200 ? 'OK' : 'Error',
url,
headers: new Headers({
'Content-Type': 'application/json',
...headers,
}),
json: async () => body,
text: async () => JSON.stringify(body),
};
}) as unknown as typeof global.fetch;
}

it('builds a versionless base so callers choose v1 or v1.1', () => {
expect(BOUNCER_API_BASE).toBe('https://api.usebouncer.com');
});

it('authenticates with x-api-key only', async () => {
mockFetch(200, { credits: 100 });

const result = await makeBouncerRequest<{ credits: number }>(
'v1.1/credits',
'test-api-key',
);

expect(calls).toHaveLength(1);
expect(calls[0]?.url).toBe(`${BOUNCER_API_BASE}/v1.1/credits`);
const headers = new Headers(calls[0]?.init?.headers);
expect(headers.get('x-api-key')).toBe('test-api-key');
// A bearer header would leak the same secret a second time.
expect(headers.get('authorization')).toBeNull();
expect(result).toEqual({ credits: 100 });
});

it('tolerates a leading slash on the endpoint', async () => {
mockFetch(200, {});

await makeBouncerRequest('/v1.1/credits', 'k');

expect(calls[0]?.url).toBe(`${BOUNCER_API_BASE}/v1.1/credits`);
});

it('sends a JSON body on POST', async () => {
mockFetch(200, { batchId: 'batch-123' });

const body = [{ email: 'test@example.com' }];
await makeBouncerRequest('v1.1/email/verify/batch', 'k', {
method: 'POST',
body,
});

expect(calls[0]?.init?.method).toBe('POST');
expect(JSON.parse(calls[0]?.init?.body as string)).toEqual(body);
});

it('sends query parameters alongside a POST body', async () => {
mockFetch(200, {});

await makeBouncerRequest('v1.1/email/verify/batch', 'k', {
method: 'POST',
body: [{ email: 'a@b.com' }],
query: { callback: 'https://example.com/hook' },
});

expect(new URL(calls[0]!.url).searchParams.get('callback')).toBe(
'https://example.com/hook',
);
});

it('drops undefined query parameters', async () => {
mockFetch(200, {});

await makeBouncerRequest('v1.1/email/verify', 'k', {
query: { email: 'a@b.com', timeout: undefined },
});

expect(calls[0]?.url).toBe(
`${BOUNCER_API_BASE}/v1.1/email/verify?email=a%40b.com`,
);
});

it('wraps an HTTP error in BouncerAPIError preserving status', async () => {
mockFetch(402, { status: '402', error: 'Payment Required' });

await expect(makeBouncerRequest('v1.1/credits', 'k')).rejects.toThrow(
BouncerAPIError,
);

try {
await makeBouncerRequest('v1.1/credits', 'k');
throw new Error('expected a rejection');
} catch (err) {
expect(err).toBeInstanceOf(BouncerAPIError);
expect((err as BouncerAPIError).status).toBe(402);
}
});

it('wraps a network failure in BouncerAPIError', async () => {
global.fetch = (async () => {
throw new Error('Network timeout');
}) as unknown as typeof global.fetch;

await expect(makeBouncerRequest('v1.1/credits', 'k')).rejects.toThrow(
BouncerAPIError,
);
});
});
91 changes: 91 additions & 0 deletions packages/bouncer/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import type { ApiRequestOptions, OpenAPIConfig } from 'corsair/http';
import { ApiError, request } from 'corsair/http';

export class BouncerAPIError extends Error {
public readonly status?: number;
public readonly statusText?: string;
public readonly body?: unknown;
public readonly retryAfter?: number;
public readonly rateLimitReset?: number;
public readonly rateLimitRemaining?: number;
public readonly rateLimitLimit?: number;

constructor(
message: string,
public readonly code?: number | string,
options?: { cause?: Error },
) {
super(message, options);
this.name = 'BouncerAPIError';

if (options?.cause instanceof ApiError) {
this.status = options.cause.status;
this.statusText = options.cause.statusText;
this.body = options.cause.body;
this.retryAfter = options.cause.retryAfter;
this.rateLimitReset = options.cause.rateLimitReset;
this.rateLimitRemaining = options.cause.rateLimitRemaining;
this.rateLimitLimit = options.cause.rateLimitLimit;
}
}
}

/**
* Bouncer serves its surface from a single host but two API versions:
* email/domain/credits live under `v1.1`, the toxicity list jobs under `v1`.
* The version therefore belongs to the endpoint path, not the base URL.
*
* https://docs.usebouncer.com/llms.txt
*/
export const BOUNCER_API_BASE = 'https://api.usebouncer.com';

export async function makeBouncerRequest<T>(
endpoint: string,
apiKey: string,
options: {
method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
body?: unknown;
query?: Record<string, string | number | boolean | undefined>;
} = {},
): Promise<T> {
const { method = 'GET', body, query } = options;

// Bouncer authenticates with the `x-api-key` header only, so `TOKEN` is
// deliberately unset: it would add a redundant `Authorization: Bearer`
// carrying the same secret.
const config: OpenAPIConfig = {
BASE: BOUNCER_API_BASE,
VERSION: '1.1.0',
WITH_CREDENTIALS: false,
CREDENTIALS: 'omit',
HEADERS: {
'Content-Type': 'application/json',
'x-api-key': apiKey,
},
};

const cleanUrl = endpoint.startsWith('/') ? endpoint.slice(1) : endpoint;

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

try {
return await request<T>(config, requestOptions);
} catch (error) {
if (error instanceof ApiError) {
throw new BouncerAPIError(error.message, error.status, { cause: error });
}
if (error instanceof Error) {
throw new BouncerAPIError(error.message, undefined, { cause: error });
}
throw new BouncerAPIError('Unknown error');
}
}
Loading
Loading