import pathLib from 'node:path'; import { NetworkError, NetworkPlatformError, PlatformError } from '../errors/platform-error'; export enum ResponseContentType { Json = 'json', Text = 'text', } export enum HttpMethod { Post = 'POST', Get = 'GET', Put = 'PUT', Patch = 'PATCH', } interface ApiHelperConstructorParams { host: string; apiKey?: string; throwResponseErrors?: boolean; } export const compileHostUrl = (params: { host: string; apiKey?: string }) => { const { host, apiKey } = params; let hostUrl = host; // If API key is a sandbox key, then convert the host to a sandbox host, // but only if it's of the `api.` convention. Ignore if it's localhost. if (apiKey && apiKey.split('_')[0] === 'sandbox') { hostUrl = hostUrl.replace('api.', 'sandbox.'); } // If no 'http' or 'https' is present, then prefix 'https'. // This makes it easy to support http://localhost if (!/^https?:\/\//i.test(hostUrl)) { hostUrl = `https://${hostUrl}`; } // const url = new URL(pathLib.join('v1', path), hostUrl).href; return hostUrl; }; /** * This is a simple API helper to interact with the Root API. */ export class RootAPIHelper { headers: any; host: string; throwResponseErrors: boolean; constructor(params: ApiHelperConstructorParams) { const { host, apiKey, throwResponseErrors } = params; this.headers = { 'Content-Type': 'application/json', ...(apiKey && { Authorization: `Bearer ${apiKey}` }), }; this.host = compileHostUrl({ host, apiKey }); this.throwResponseErrors = !!throwResponseErrors; } /** * Defaults to a 'GET' request with response content type of JSON. * Path will be appended to `{host}/v1`. * Query params in 'path' will be ignored. */ send = async (params: { path: string; method?: HttpMethod; body?: Record | Record[]; responseContentType?: ResponseContentType; searchParams?: Record; /** * Optional AbortSignal for cancelling in-flight requests. Used by callers that run * requests in parallel (e.g. validate + fetch in push.ts) and want to tear the second * one down as soon as the first rejects — otherwise the loser keeps consuming * bandwidth and server resources after the user has already seen a failure. */ signal?: AbortSignal; }): Promise => { const { path, responseContentType, searchParams, method, body, signal } = params; const url = new URL(pathLib.join('v1', path), this.host); url.search = searchParams ? new URLSearchParams(searchParams).toString() : ''; let response: Response; try { response = await fetch(url.href, { method: method || HttpMethod.Get, headers: this.headers, ...(body && { body: JSON.stringify(body) }), ...(signal && { signal }), }); } catch (error) { throw new NetworkError(error as Error, url.href); } // Treat the whole 2xx range as success — some endpoints legitimately return 201 Created // or 204 No Content. Guarding only on `!== 200` would incorrectly raise PlatformError // for those responses. if (this.throwResponseErrors && (response.status < 200 || response.status >= 300)) { let errorBody: NetworkPlatformError; try { errorBody = (await response.json()) as NetworkPlatformError; } catch { throw new PlatformError( { error: { type: 'unknown_error', message: `Non-JSON response (status ${response.status})` }, } as NetworkPlatformError, response.status, ); } throw new PlatformError(errorBody, response.status); } // 204 No Content has no body — attempting `.json()` throws. Match the declared content // type so Text-mode callers get a string (`''`) and JSON-mode callers get `undefined`; // returning `undefined` from a `Text` call would break `.split(...)` etc. downstream. if (response.status === 204) { return responseContentType === ResponseContentType.Text ? '' : undefined; } return responseContentType === ResponseContentType.Text ? response.text() : response.json(); }; } export const getNewApiHelper = (params: ApiHelperConstructorParams) => new RootAPIHelper(params);