Files
MikrotikManager/shared/api/http-client.ts
T

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
}