Files
js-sdk/src/_shared/http.ts

316 lines
9.6 KiB
TypeScript

import { TimeoutError, throwErrorFromResponse } from "../exceptions.js";
/** Configuration for the low-level HTTP client. */
export interface HttpClientConfig {
/** API origin used for all relative request paths. */
baseUrl: string;
/** API key sent as `X-API-Key`. */
apiKey?: string;
/** Bearer JWT sent as `Authorization: Bearer ...`. */
token?: string;
/** Host token sent as `X-Host-Token`. */
hostToken?: string;
}
/** Per-request options accepted by the low-level HTTP client. */
export interface RequestOptions {
/** Query parameters appended to the request URL. */
params?: Record<string, string | number | boolean | undefined>;
/** Additional headers merged after default authentication headers. */
headers?: Record<string, string>;
/** Request-scoped API key override. */
apiKey?: string;
/** Request-scoped bearer JWT override. */
token?: string;
/** Request-scoped host token override. */
hostToken?: string;
/** Timeout in milliseconds for requests that do not provide a signal. */
timeoutMs?: number;
/** Caller-provided cancellation signal. Takes precedence over `timeoutMs`. */
signal?: AbortSignal;
/** Return response text instead of parsing JSON. */
asText?: boolean;
/** Fetch redirect behavior. Defaults to the runtime fetch default. */
redirect?: RequestRedirect;
}
/** Thin `fetch` wrapper with Wrenn authentication and error mapping. */
export class HttpClient {
private readonly baseUrl: string;
private readonly defaultHeaders: Record<string, string>;
/**
* Creates a low-level HTTP client.
*
* @param config - Base URL and optional authentication credentials.
*/
constructor(config: HttpClientConfig) {
this.baseUrl = config.baseUrl.replace(/\/+$/, "");
this.defaultHeaders = {
"Content-Type": "application/json",
Accept: "application/json",
};
if (config.apiKey) {
this.defaultHeaders["X-API-Key"] = config.apiKey;
}
if (config.token) {
this.defaultHeaders.Authorization = `Bearer ${config.token}`;
}
if (config.hostToken) {
this.defaultHeaders["X-Host-Token"] = config.hostToken;
}
}
/**
* Sends a GET request and parses the JSON response.
*
* @param path - API path relative to the configured base URL.
* @param opts - Optional query, header, auth, timeout, and cancellation settings.
* @returns Parsed JSON response.
* @throws TimeoutError when the configured timeout aborts the request.
* @throws WrennError subclasses for unsuccessful responses.
*/
get<T>(path: string, opts?: RequestOptions): Promise<T> {
return this.request<T>("GET", path, undefined, opts);
}
/**
* Sends a POST request with an optional JSON body.
*
* @param path - API path relative to the configured base URL.
* @param body - Optional JSON request body.
* @param opts - Optional query, header, auth, timeout, and cancellation settings.
* @returns Parsed JSON response.
* @throws TimeoutError when the configured timeout aborts the request.
* @throws WrennError subclasses for unsuccessful responses.
*/
post<T>(path: string, body?: unknown, opts?: RequestOptions): Promise<T> {
return this.request<T>("POST", path, body, opts);
}
/**
* Sends a PATCH request with an optional JSON body.
*
* @param path - API path relative to the configured base URL.
* @param body - Optional JSON request body.
* @param opts - Optional query, header, auth, timeout, and cancellation settings.
* @returns Parsed JSON response.
* @throws TimeoutError when the configured timeout aborts the request.
* @throws WrennError subclasses for unsuccessful responses.
*/
patch<T>(path: string, body?: unknown, opts?: RequestOptions): Promise<T> {
return this.request<T>("PATCH", path, body, opts);
}
/**
* Sends a PUT request with an optional JSON body.
*
* @param path - API path relative to the configured base URL.
* @param body - Optional JSON request body.
* @param opts - Optional query, header, auth, timeout, and cancellation settings.
* @returns Parsed JSON response.
* @throws TimeoutError when the configured timeout aborts the request.
* @throws WrennError subclasses for unsuccessful responses.
*/
put<T>(path: string, body?: unknown, opts?: RequestOptions): Promise<T> {
return this.request<T>("PUT", path, body, opts);
}
/**
* Sends a DELETE request and expects no response body.
*
* @param path - API path relative to the configured base URL.
* @param opts - Optional query, header, auth, timeout, and cancellation settings.
* @returns Resolves when the response succeeds.
* @throws TimeoutError when the configured timeout aborts the request.
* @throws WrennError subclasses for unsuccessful responses.
*/
delete(path: string, opts?: RequestOptions): Promise<void> {
return this.request<void>("DELETE", path, undefined, opts);
}
/**
* Uploads multipart form data.
*
* @param path - API path relative to the configured base URL.
* @param formData - Multipart payload to send.
* @param opts - Optional query, header, auth, and cancellation settings.
* @throws WrennError subclasses for unsuccessful responses.
*/
async upload(
path: string,
formData: FormData,
opts?: RequestOptions,
): Promise<void> {
const url = this.buildUrl(path, opts?.params);
const headers: Record<string, string> = { ...this.defaultHeaders };
delete headers["Content-Type"];
if (opts?.apiKey) headers["X-API-Key"] = opts.apiKey;
if (opts?.token) headers.Authorization = `Bearer ${opts.token}`;
if (opts?.hostToken) headers["X-Host-Token"] = opts.hostToken;
const init: RequestInit = {
method: "POST",
headers: { ...headers, ...opts?.headers },
body: formData,
};
const res = await this.fetchWithSignal(url, init, opts);
if (!res.ok) {
await throwErrorFromResponse(res);
}
}
/**
* Downloads a binary response body as a web `ReadableStream`.
*
* @param path - API path relative to the configured base URL.
* @param body - Optional JSON request body.
* @param opts - Optional query, header, auth, timeout, and cancellation settings.
* @returns Response body stream.
* @throws WrennError subclasses for unsuccessful responses.
*/
async download(
path: string,
body?: unknown,
opts?: RequestOptions,
): Promise<ReadableStream<Uint8Array>> {
const res = await this.rawRequest("POST", path, body, opts);
if (!res.body) {
throw new Error("Response body is null");
}
return res.body;
}
/**
* Sends a request and returns the raw `Response` object.
*
* This is intended for endpoints that intentionally return non-JSON responses,
* such as OAuth redirects. Unlike {@link request}, this method does not map
* non-OK statuses to SDK errors.
*
* @param method - HTTP method to use.
* @param path - API path relative to the configured base URL.
* @param body - Optional JSON request body.
* @param opts - Optional query, header, auth, timeout, and redirect settings.
* @returns Raw fetch response.
* @throws TimeoutError When the configured timeout aborts the request.
*/
response(
method: string,
path: string,
body?: unknown,
opts?: RequestOptions,
): Promise<Response> {
return this.rawRequest(method, path, body, opts);
}
/**
* Sends a request and parses the response as JSON unless `asText` is set.
*
* @param method - HTTP method to use.
* @param path - API path relative to the configured base URL.
* @param body - Optional JSON request body.
* @param opts - Optional query, header, auth, timeout, and cancellation settings.
* @returns Parsed response body.
* @throws TimeoutError When the configured timeout aborts the request.
* @throws WrennError subclasses for unsuccessful responses.
*/
async request<T>(
method: string,
path: string,
body?: unknown,
opts?: RequestOptions,
): Promise<T> {
const res = await this.rawRequest(method, path, body, opts);
if (res.status === 204) {
return undefined as T;
}
if (!res.ok) {
await throwErrorFromResponse(res);
}
if (opts?.asText) {
return (await res.text()) as T;
}
return (await res.json()) as T;
}
private async rawRequest(
method: string,
path: string,
body?: unknown,
opts?: RequestOptions,
): Promise<Response> {
const url = this.buildUrl(path, opts?.params);
const headers: Record<string, string> = { ...this.defaultHeaders };
if (body === undefined) {
delete headers["Content-Type"];
}
const init: RequestInit = {
method,
headers: { ...headers, ...opts?.headers },
};
if (opts?.redirect) init.redirect = opts.redirect;
if (body !== undefined) {
init.body = JSON.stringify(body);
}
return this.fetchWithSignal(url, init, opts);
}
private async fetchWithSignal(
url: string,
init: RequestInit,
opts?: RequestOptions,
): Promise<Response> {
const requestInit: RequestInit = { ...init };
if (opts?.signal) {
requestInit.signal = opts.signal;
return fetch(url, requestInit);
}
if (opts?.timeoutMs) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), opts.timeoutMs);
requestInit.signal = controller.signal;
try {
const res = await fetch(url, requestInit);
clearTimeout(timeout);
return res;
} catch (err) {
clearTimeout(timeout);
if (err instanceof DOMException && err.name === "AbortError") {
throw new TimeoutError(`Request timed out after ${opts.timeoutMs}ms`);
}
throw err;
}
}
return fetch(url, requestInit);
}
private buildUrl(
path: string,
params?: Record<string, string | number | boolean | undefined>,
): string {
const url = new URL(`${this.baseUrl}${path}`);
if (params) {
for (const [key, value] of Object.entries(params)) {
if (value !== undefined) {
url.searchParams.set(key, String(value));
}
}
}
return url.toString();
}
}