Docker images / prepare-release (push) Successful in 6s
Docker images / backend-image (push) Successful in 1m24s
Docker images / frontend-image (push) Successful in 2m6s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 43s
Docker images / publish-release (push) Successful in 7s
Replaced direct fetch calls with requestJson and requestBlob utility functions across multiple components for improved consistency and error handling. This change enhances the maintainability of the codebase by centralizing API request logic and ensuring uniform handling of authentication and response parsing.
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
|
|
}
|