ultisuite-client/lib/api/client.ts
R3D347HR4Y d6d18f911b
Some checks failed
E2E / Playwright e2e (push) Has been cancelled
Lots of stuff and mobile app
2026-06-17 00:13:28 +02:00

242 lines
6.8 KiB
TypeScript

import type { ApiError } from "./types"
import { ensureAccessToken } from "@/lib/auth/ensure-access-token"
import { handleUnauthorized } from "@/lib/auth/handle-unauthorized"
import { isSessionExpired } from "@/lib/auth/session-guard-store"
import { getApiBaseUrl } from "@/lib/runtime-config"
export class OfflineError extends Error {
constructor() {
super("Device is offline")
this.name = "OfflineError"
}
}
export class ApiRequestError extends Error {
code: string
details?: unknown
status: number
constructor(status: number, code: string, message: string, details?: unknown) {
super(message)
this.name = "ApiRequestError"
this.status = status
this.code = code
this.details = details
}
}
const DEFAULT_TIMEOUT = 10_000
const DEFAULT_RETRIES = 3
const BASE_DELAY = 1000
class ApiClient {
/**
* Resolver so the base URL is read at call time. On native the backend is
* only known after the server picker runs (runtime config); on web it stays
* the proxied `/api/v1`.
*/
constructor(private resolveBaseUrl: () => string = getApiBaseUrl) {}
private resolveUrl(path: string): URL {
const baseUrl = this.resolveBaseUrl()
const base = baseUrl.startsWith("http")
? baseUrl
: `${typeof window !== "undefined" ? window.location.origin : "http://localhost"}${baseUrl}`
// Absolute path (leading /) would replace /api/v1 — keep base path segment.
const normalizedBase = base.endsWith("/") ? base : `${base}/`
const normalizedPath = path.startsWith("/") ? path.slice(1) : path
return new URL(normalizedPath, normalizedBase)
}
private async getHeaders(): Promise<HeadersInit> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
}
const token = await ensureAccessToken()
if (token) {
headers["Authorization"] = `Bearer ${token}`
}
return headers
}
private async request<T>(
method: string,
path: string,
opts?: {
body?: unknown
params?: Record<string, string | undefined>
headers?: Record<string, string>
timeout?: number
retries?: number
}
): Promise<T> {
if (typeof navigator !== "undefined" && !navigator.onLine) {
throw new OfflineError()
}
const url = this.resolveUrl(path)
if (opts?.params) {
for (const [key, value] of Object.entries(opts.params)) {
if (value !== undefined) {
url.searchParams.set(key, value)
}
}
}
const timeout = opts?.timeout ?? DEFAULT_TIMEOUT
const maxRetries = opts?.retries ?? DEFAULT_RETRIES
let lastError: Error | null = null
let authRetried = false
for (let attempt = 0; attempt <= maxRetries; attempt++) {
if (attempt > 0) {
const delay = BASE_DELAY * Math.pow(2, attempt - 1)
await new Promise((resolve) => setTimeout(resolve, delay))
}
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), timeout)
try {
const response = await fetch(url.toString(), {
method,
headers: { ...(await this.getHeaders()), ...opts?.headers },
body: opts?.body ? JSON.stringify(opts.body) : undefined,
signal: controller.signal,
})
clearTimeout(timer)
if (!response.ok) {
let errorBody: ApiError | undefined
try {
errorBody = await response.json()
} catch {}
const err = new ApiRequestError(
response.status,
errorBody?.code ?? "UNKNOWN",
errorBody?.message ?? response.statusText,
errorBody?.details
)
if (response.status === 401) {
if (isSessionExpired()) {
throw err
}
if (!authRetried) {
authRetried = true
const resolution = await handleUnauthorized()
if (resolution === "refreshed") {
continue
}
if (resolution === "offline") {
throw new OfflineError()
}
} else {
await handleUnauthorized({ forceExpired: true })
}
}
if (response.status >= 400 && response.status < 500) {
throw err
}
lastError = err
continue
}
if (response.status === 204) {
return undefined as T
}
const text = await response.text()
if (!text.trim()) {
return undefined as T
}
return JSON.parse(text) as T
} catch (err) {
clearTimeout(timer)
if (err instanceof ApiRequestError && err.status >= 400 && err.status < 500) {
throw err
}
lastError = err instanceof Error ? err : new Error(String(err))
if (err instanceof DOMException && err.name === "AbortError") {
lastError = new Error("Request timed out")
}
}
}
throw lastError ?? new Error("Request failed")
}
async get<T>(path: string, params?: Record<string, string | undefined>): Promise<T> {
return this.request<T>("GET", path, { params })
}
/** GET binary body (inline attachments, exports). */
async getBlob(path: string, authRetried = false): Promise<Blob> {
if (typeof navigator !== "undefined" && !navigator.onLine) {
throw new OfflineError()
}
const url = this.resolveUrl(path)
const headers: Record<string, string> = {}
const token = await ensureAccessToken()
if (token) {
headers["Authorization"] = `Bearer ${token}`
}
const response = await fetch(url.toString(), { method: "GET", headers })
if (!response.ok) {
if (response.status === 401) {
if (isSessionExpired()) {
throw new ApiRequestError(response.status, "UNKNOWN", response.statusText)
}
if (!authRetried) {
const resolution = await handleUnauthorized()
if (resolution === "refreshed") {
return this.getBlob(path, true)
}
if (resolution === "offline") {
throw new OfflineError()
}
} else {
await handleUnauthorized({ forceExpired: true })
}
}
throw new ApiRequestError(
response.status,
"UNKNOWN",
response.statusText
)
}
return response.blob()
}
async post<T>(path: string, body?: unknown): Promise<T> {
return this.request<T>("POST", path, { body })
}
async put<T>(
path: string,
body?: unknown,
headers?: Record<string, string>,
): Promise<T> {
return this.request<T>("PUT", path, { body, headers })
}
async patch<T>(path: string, body?: unknown): Promise<T> {
return this.request<T>("PATCH", path, { body })
}
async delete(path: string): Promise<void> {
await this.request<void>("DELETE", path)
}
}
export const apiClient = new ApiClient()