125 lines
3.5 KiB
TypeScript
125 lines
3.5 KiB
TypeScript
import { configuredBackendUrl } from "@/lib/backend-url"
|
|
import {
|
|
getToken,
|
|
isAuthEnabled,
|
|
redirectToPortalLogin,
|
|
redirectToPortalLoginInteractive,
|
|
} from "@/lib/auth"
|
|
|
|
export class ApiClientError extends Error {
|
|
constructor(
|
|
message: string,
|
|
public readonly status: number,
|
|
public readonly payload?: unknown,
|
|
) {
|
|
super(message)
|
|
this.name = "ApiClientError"
|
|
}
|
|
}
|
|
|
|
function trimBaseUrl(baseUrl: string): string {
|
|
return baseUrl.replace(/\/$/, "")
|
|
}
|
|
|
|
/** Absolute or same-origin-relative URL for backend API paths. */
|
|
export function resolveApiUrl(baseUrl: string, path: string): string {
|
|
if (path.startsWith("/") && configuredBackendUrl().kind === "same-origin") {
|
|
return path
|
|
}
|
|
// Safety: never call browser localhost when the UI is served from a remote host
|
|
if (typeof window !== "undefined") {
|
|
const host = window.location.hostname
|
|
const remoteUi = host !== "localhost" && host !== "127.0.0.1"
|
|
const baseIsLocal =
|
|
/^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/i.test(trimBaseUrl(baseUrl))
|
|
if (remoteUi && (baseIsLocal || !baseUrl.trim())) {
|
|
return path.startsWith("/") ? path : `/${path}`
|
|
}
|
|
}
|
|
return trimBaseUrl(baseUrl) + path
|
|
}
|
|
|
|
/** Attach portal JWT when present. */
|
|
export function withAuthHeaders(init?: HeadersInit): Headers {
|
|
const headers = new Headers(init)
|
|
const token = typeof window !== "undefined" ? getToken() : null
|
|
if (token && !headers.has("Authorization")) {
|
|
headers.set("Authorization", `Bearer ${token}`)
|
|
}
|
|
return headers
|
|
}
|
|
|
|
function handleUnauthorized(): never {
|
|
if (typeof window !== "undefined" && isAuthEnabled()) {
|
|
const ok = redirectToPortalLogin()
|
|
if (!ok) redirectToPortalLoginInteractive()
|
|
}
|
|
throw new ApiClientError("Unauthorized", 401)
|
|
}
|
|
|
|
async function parseErrorMessage(res: Response): Promise<string> {
|
|
const payload = await res.json().catch(() => undefined)
|
|
if (
|
|
typeof payload === "object" &&
|
|
payload !== null &&
|
|
"error" in payload &&
|
|
typeof (payload as { error?: unknown }).error === "string"
|
|
) {
|
|
return (payload as { error: string }).error
|
|
}
|
|
return res.statusText || `HTTP ${res.status}`
|
|
}
|
|
|
|
export async function requestJson<T>(
|
|
baseUrl: string,
|
|
path: string,
|
|
init?: RequestInit,
|
|
): Promise<T> {
|
|
const hasBody = init?.body != null
|
|
const headers = withAuthHeaders(init?.headers)
|
|
if (hasBody && !headers.has("Content-Type")) {
|
|
headers.set("Content-Type", "application/json")
|
|
}
|
|
|
|
const res = await fetch(resolveApiUrl(baseUrl, path), {
|
|
...init,
|
|
headers,
|
|
})
|
|
|
|
if (res.status === 401) handleUnauthorized()
|
|
|
|
if (res.status === 204) return undefined as T
|
|
|
|
const payload = await res.json().catch(() => undefined)
|
|
if (!res.ok) {
|
|
const msg =
|
|
typeof payload === "object" &&
|
|
payload !== null &&
|
|
"error" in payload &&
|
|
typeof (payload as { error?: unknown }).error === "string"
|
|
? (payload as { error: string }).error
|
|
: res.statusText
|
|
throw new ApiClientError(msg, res.status, payload)
|
|
}
|
|
|
|
return payload as T
|
|
}
|
|
|
|
/** Binary/download endpoints (backup, backup file) with the same auth + URL rules. */
|
|
export async function requestBlob(
|
|
baseUrl: string,
|
|
path: string,
|
|
init?: RequestInit,
|
|
): Promise<Response> {
|
|
const headers = withAuthHeaders(init?.headers)
|
|
const res = await fetch(resolveApiUrl(baseUrl, path), {
|
|
...init,
|
|
headers,
|
|
})
|
|
if (res.status === 401) handleUnauthorized()
|
|
if (!res.ok) {
|
|
throw new ApiClientError(await parseErrorMessage(res), res.status)
|
|
}
|
|
return res
|
|
}
|