113 lines
3.4 KiB
TypeScript

/**
* Low-level fetch helpers for the rpki_query_service HTTP API.
*
* All JSON endpoints share the envelope shape:
* { "data": ..., "page": { "nextCursor": string | null, "limit": number } | null,
* "meta": { "runId": string, "schemaVersion": number } }
* Errors are `{ "error": string }` with a non-2xx status.
*/
export class ApiError extends Error {
readonly status: number;
constructor(status: number, message: string) {
super(message);
this.name = "ApiError";
this.status = status;
}
}
export type QueryParams = Record<
string,
string | number | boolean | null | undefined
>;
/** Build a URL with a query string, skipping empty values. */
export function withQuery(path: string, params?: QueryParams): string {
if (!params) return path;
const search = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value === undefined || value === null || value === "") continue;
search.set(key, String(value));
}
const qs = search.toString();
return qs ? `${path}?${qs}` : path;
}
async function parseErrorBody(res: Response): Promise<string> {
try {
const body: unknown = await res.json();
if (
body !== null &&
typeof body === "object" &&
"error" in body &&
typeof (body as { error: unknown }).error === "string"
) {
return (body as { error: string }).error;
}
} catch {
// fall through to status text
}
return `HTTP ${res.status} ${res.statusText}`.trim();
}
/** Default timeout for every API request — hung streams must not spin forever. */
const REQUEST_TIMEOUT_MS = 30_000;
/** Combine the caller's abort signal (if any) with the default timeout. */
function requestSignal(signal?: AbortSignal | null): AbortSignal {
const signals = [signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)].filter(
(s): s is AbortSignal => s != null,
);
return AbortSignal.any(signals);
}
/** Fetch JSON from the API, throwing ApiError on non-2xx. */
export async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
let res: Response;
try {
res = await fetch(path, { ...init, signal: requestSignal(init?.signal) });
} catch (err) {
throw new ApiError(0, err instanceof Error ? err.message : "network error");
}
if (!res.ok) {
throw new ApiError(res.status, await parseErrorBody(res));
}
return (await res.json()) as Promise<T>;
}
/** Fetch binary content (raw object bytes, export tarballs). */
export async function apiFetchBlob(path: string): Promise<Blob> {
let res: Response;
try {
res = await fetch(path, { signal: requestSignal() });
} catch (err) {
throw new ApiError(0, err instanceof Error ? err.message : "network error");
}
if (!res.ok) {
throw new ApiError(res.status, await parseErrorBody(res));
}
return res.blob();
}
/** POST a JSON body, returning the parsed JSON response. */
export async function apiPost<T>(path: string, body: unknown): Promise<T> {
return apiFetch<T>(path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body ?? {}),
});
}
/** Trigger a browser download for a blob. */
export function saveBlob(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(url);
}