113 lines
3.4 KiB
TypeScript
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);
|
|
}
|