blob: 96287cb62c31c441cf3f67e05a2189db1202581f [file]
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
import { recordTraceId } from "./last-trace-id";
export class ApiError extends Error {
constructor(
public readonly status: number,
public readonly path: string,
public readonly body: unknown,
message?: string,
) {
super(message ?? `API ${status} on ${path}`);
this.name = "ApiError";
}
}
export type ApiFetchInit = Omit<RequestInit, "body"> & {
body?: unknown;
};
const PORTAL_API_PREFIX = "/api/v1";
function buildUrl(path: string): string {
if (path.startsWith("http://") || path.startsWith("https://")) return path;
const normalized = path.startsWith("/") ? path : `/${path}`;
if (normalized.startsWith(PORTAL_API_PREFIX)) return normalized;
return `${PORTAL_API_PREFIX}${normalized}`;
}
async function parseBody(response: Response): Promise<unknown> {
const contentType = response.headers.get("content-type") ?? "";
if (response.status === 204) return null;
if (contentType.includes("application/json")) {
try {
return await response.json();
} catch {
return null;
}
}
try {
return await response.text();
} catch {
return null;
}
}
// The backend reports errors as {"error": "..."}; surface that text so dialogs
// show the guard message instead of a generic "API 409 on ...".
function errorMessageFromBody(body: unknown): string | undefined {
if (body && typeof body === "object" && "error" in body) {
const { error } = body as { error: unknown };
if (typeof error === "string" && error.length > 0) return error;
}
return undefined;
}
export async function apiFetch<T = unknown>(path: string, init: ApiFetchInit = {}): Promise<T> {
const url = buildUrl(path);
const headers = new Headers(init.headers);
let body: BodyInit | undefined;
if (init.body !== undefined && init.body !== null) {
if (
typeof init.body === "string" ||
init.body instanceof FormData ||
init.body instanceof Blob
) {
body = init.body as BodyInit;
} else {
if (!headers.has("content-type")) headers.set("content-type", "application/json");
body = JSON.stringify(init.body);
}
}
if (!headers.has("accept")) headers.set("accept", "application/json");
const response = await fetch(url, { ...init, body, headers });
// Backend HTTP middleware sets X-Trace-Id; capture so callers can deep-link.
const traceId = response.headers.get("x-trace-id");
if (traceId) recordTraceId(traceId);
const parsed = await parseBody(response);
if (!response.ok) {
throw new ApiError(response.status, url, parsed, errorMessageFromBody(parsed));
}
return parsed as T;
}