Skip to content
Open
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
188 changes: 188 additions & 0 deletions packages/ashby/client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
import {
ASHBY_API_BASE,
AshbyAPIError,
buildAshbyBasicAuthHeader,
makeAshbyRequest,
} from './client';

type Captured = {
url: string;
method: string;
headers: Record<string, string>;
body?: string;
};

type MockResponse = {
ok?: boolean;
status?: number;
body?: unknown;
headers?: Record<string, string>;
};

let captured: Captured | undefined;
let attempts = 0;

function mockFetchSequence(responses: MockResponse[]) {
captured = undefined;
attempts = 0;
global.fetch = (async (url: unknown, init?: RequestInit) => {
const headers: Record<string, string> = {};
const raw = init?.headers;
if (raw instanceof Headers) {
raw.forEach((value, key) => {
headers[key.toLowerCase()] = value;
});
} else {
for (const [key, value] of Object.entries(
(raw ?? {}) as Record<string, string>,
)) {
headers[key.toLowerCase()] = value;
}
}
captured = {
url: String(url),
method: init?.method ?? 'GET',
headers,
body: typeof init?.body === 'string' ? init.body : undefined,
};

const response =
responses[Math.min(attempts, responses.length - 1)] ??
({} as MockResponse);
attempts++;

const status = response.status ?? 200;
const payload = response.body ?? {};
return {
ok: response.ok ?? status < 400,
status,
statusText: 'OK',
url: String(url),
headers: new Headers({
'Content-Type': 'application/json',
...response.headers,
}),
json: async () => payload,
text: async () =>
typeof payload === 'string' ? payload : JSON.stringify(payload),
};
}) as unknown as typeof global.fetch;
}

function mockFetch(response: MockResponse) {
mockFetchSequence([response]);
}

describe('Ashby Client', () => {
describe('buildAshbyBasicAuthHeader', () => {
it('formats API key as HTTP Basic Auth with key as username and empty password', () => {
const apiKey = 'test-api-key-12345';
const expectedEncoded = Buffer.from('test-api-key-12345:').toString(
'base64',
);
expect(buildAshbyBasicAuthHeader(apiKey)).toBe(
`Basic ${expectedEncoded}`,
);
});
});

describe('makeAshbyRequest', () => {
it('targets the Ashby API base URL with POST method and Basic auth', async () => {
mockFetch({ body: { success: true, results: { id: 'cand_123' } } });

const apiKey = 'sec_key_abc';
const result = await makeAshbyRequest<{
success: boolean;
results: { id: string };
}>('candidate.info', apiKey, {
body: { candidateId: 'cand_123' },
});

expect(captured?.url).toBe(`${ASHBY_API_BASE}/candidate.info`);
expect(captured?.method).toBe('POST');
expect(captured?.headers.authorization).toBe(
`Basic ${Buffer.from('sec_key_abc:').toString('base64')}`,
);
expect(captured?.headers['content-type']).toContain('application/json');
expect(JSON.parse(captured?.body ?? '{}')).toEqual({
candidateId: 'cand_123',
});
expect(result.results.id).toBe('cand_123');
});

it('handles endpoints with leading slash gracefully', async () => {
mockFetch({ body: { success: true, results: [] } });

await makeAshbyRequest('/candidate.list', 'test-key', {
body: { limit: 10 },
});

expect(captured?.url).toBe(`${ASHBY_API_BASE}/candidate.list`);
expect(captured?.method).toBe('POST');
});

it('throws AshbyAPIError when response has success: false envelope', async () => {
mockFetch({
body: {
success: false,
errors: [
{
code: 'missing_endpoint_permission',
message: 'Missing candidate write permission',
},
],
},
});

await expect(
makeAshbyRequest('candidate.create', 'test-key', {
body: { name: 'Test' },
}),
).rejects.toThrow(AshbyAPIError);
});

it('retries upon 429 Too Many Requests and respects Retry-After', async () => {
mockFetchSequence([
{ status: 429, body: {}, headers: { 'Retry-After': '1' } },
{ status: 200, body: { success: true, results: { id: '1' } } },
]);

const result = await makeAshbyRequest<{
success: boolean;
results: { id: string };
}>('candidate.info', 'test-key', {
body: { candidateId: '1' },
});

expect(attempts).toBe(2);
expect(result.results.id).toBe('1');
});

it('parses HTTP 403 ApiError into AshbyAPIError with status and code', async () => {
mockFetch({
status: 403,
body: {
success: false,
errors: [
{
code: 'missing_endpoint_permission',
message: 'Access forbidden',
},
],
},
});

try {
await makeAshbyRequest('candidate.anonymize', 'test-key', {
body: { candidateId: '123' },
});
fail('Expected makeAshbyRequest to throw');
} catch (error) {
expect(error).toBeInstanceOf(AshbyAPIError);
const ashbyErr = error as AshbyAPIError;
expect(ashbyErr.status).toBe(403);
expect(ashbyErr.code).toBe('missing_endpoint_permission');
}
});
});
});
154 changes: 154 additions & 0 deletions packages/ashby/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import type {
ApiRequestOptions,
OpenAPIConfig,
RateLimitConfig,
} from 'corsair/http';
import { ApiError, request } from 'corsair/http';

export const ASHBY_API_BASE = 'https://api.ashbyhq.com';

/**
* Ashby API rate limiting configuration.
* When encountering 429 Too Many Requests, Corsair will retry with exponential backoff,
* respecting the Retry-After header if present.
*/
export const ASHBY_RATE_LIMIT_CONFIG: RateLimitConfig = {
enabled: true,
maxRetries: 3,
initialRetryDelay: 1000,
backoffMultiplier: 2,
headerNames: {
retryAfter: 'Retry-After',
},
};

export type AshbyErrorItem = {
code?: string;
message?: string;
};

/**
* Custom error class representing an error returned by the Ashby API or transport layer.
*/
export class AshbyAPIError extends Error {
constructor(
message: string,
public readonly status?: number,
public readonly code?: string,
public readonly errors?: AshbyErrorItem[],
) {
super(message);
this.name = 'AshbyAPIError';
}
}

export type AshbyRequestOptions = {
body?: Record<string, unknown>;
headers?: Record<string, string>;
};

/**
* Encodes the Ashby API key into an HTTP Basic Authorization header.
* Ashby expects the API key as the username with an empty password.
*/
export function buildAshbyBasicAuthHeader(apiKey: string): string {
const encoded = Buffer.from(`${apiKey}:`).toString('base64');
return `Basic ${encoded}`;
}

/**
* Makes an RPC-style HTTP POST request to the Ashby API.
* All Ashby API endpoints use the POST method with JSON bodies.
*/
export async function makeAshbyRequest<T>(
endpoint: string,
apiKey: string,
options: AshbyRequestOptions = {},
): Promise<T> {
const normalizedEndpoint = endpoint.startsWith('/')
? endpoint
: `/${endpoint}`;

const config: OpenAPIConfig = {
BASE: ASHBY_API_BASE,
VERSION: '1.0.0',
WITH_CREDENTIALS: false,
CREDENTIALS: 'omit',
TOKEN: undefined,
HEADERS: {
'Content-Type': 'application/json',
Accept: 'application/json',
Authorization: buildAshbyBasicAuthHeader(apiKey),
...options.headers,
},
};

const requestOptions: ApiRequestOptions = {
method: 'POST',
url: normalizedEndpoint,
body: options.body ?? {},
mediaType: 'application/json; charset=utf-8',
};

try {
const response = await request<T>(config, requestOptions, {
rateLimitConfig: ASHBY_RATE_LIMIT_CONFIG,
});

// Check if response contains Ashby failure envelope { success: false, errors: [...], error: "..." }
if (
response &&
typeof response === 'object' &&
'success' in response &&
(response as { success: boolean }).success === false
) {
const failed = response as {
success: false;
errors?: AshbyErrorItem[];
error?: string;
};
const firstError = failed.errors?.[0];
const message =
firstError?.message || failed.error || 'Ashby API request failed';
const code = firstError?.code;
throw new AshbyAPIError(message, 400, code, failed.errors);
}

return response;
} catch (error) {
if (error instanceof AshbyAPIError) {
throw error;
}

if (error instanceof ApiError) {
const status = error.status;
let parsedErrors: AshbyErrorItem[] | undefined;
let parsedCode: string | undefined;
let message = error.message;

if (error.body && typeof error.body === 'object') {
const bodyObj = error.body as {
errors?: AshbyErrorItem[];
error?: string;
message?: string;
};
if (Array.isArray(bodyObj.errors) && bodyObj.errors.length > 0) {
parsedErrors = bodyObj.errors;
parsedCode = bodyObj.errors[0]?.code;
message = bodyObj.errors[0]?.message || message;
} else if (typeof bodyObj.error === 'string') {
message = bodyObj.error;
} else if (typeof bodyObj.message === 'string') {
message = bodyObj.message;
}
}

throw new AshbyAPIError(message, status, parsedCode, parsedErrors);
}

if (error instanceof Error) {
throw new AshbyAPIError(error.message);
}
throw new AshbyAPIError('Unknown Ashby error');
}
}
Loading
Loading