316 lines
9.6 KiB
TypeScript
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();
|
|
}
|
|
}
|