/** * # @devos/api-client * 浏览器端 API 客户端:统一的 fetch 封装,自动附带 JWT 与工作区头, * 归一化错误结构(与后端 error contract 对齐)。 */ import type { ApiErrorBody } from "@devos/types"; /** # 客户端配置 **/ export interface ApiClientOptions { /** # API 基础地址,默认同源 /api/v1 **/ baseUrl?: string; /** # 令牌提供器(返回当前 JWT 或 null) **/ getToken: () => string | null; /** # 工作区 ID 提供器(返回当前工作区或 null) **/ getWorkspaceId?: () => string | null; /** # 401 时的回调(例如跳转登录) **/ onUnauthorized?: () => void; } /** # API 错误(客户端侧归一化) **/ export class ApiError extends Error { /** # 错误码 **/ code: string; /** # HTTP 状态码 **/ status: number; /** # 请求 ID **/ requestId: string; constructor(status: number, code: string, message: string, requestId: string) { super(message); this.status = status; this.code = code; this.requestId = requestId; } } /** # API 客户端 **/ export class ApiClient { /** # 基础地址 **/ private baseUrl: string; private getToken: () => string | null; private getWorkspaceId?: () => string | null; private onUnauthorized?: () => void; constructor(options: ApiClientOptions) { this.baseUrl = (options.baseUrl ?? "/api/v1").replace(/\/$/, ""); this.getToken = options.getToken; this.getWorkspaceId = options.getWorkspaceId; this.onUnauthorized = options.onUnauthorized; } /** # 发起请求 * @param method HTTP 方法 * @param path 相对路径(不含 /api/v1) * @param body 请求体(自动 JSON 序列化) * @param query 查询参数 * @returns 解析后的 JSON 响应 */ async request( method: string, path: string, body?: unknown, query?: Record, ): Promise { const url = new URL(this.baseUrl + path, window.location.origin); if (query) { for (const [k, v] of Object.entries(query)) { if (v !== undefined && v !== null) url.searchParams.set(k, String(v)); } } const headers: Record = {}; const token = this.getToken(); if (token) headers["Authorization"] = `Bearer ${token}`; const wid = this.getWorkspaceId?.(); if (wid) headers["X-Workspace-Id"] = wid; if (body !== undefined) headers["Content-Type"] = "application/json"; const res = await fetch(url.toString(), { method, headers, body: body !== undefined ? JSON.stringify(body) : undefined, }); if (!res.ok) { let code = "INTERNAL_ERROR"; let message = res.statusText; let requestId = ""; let parsed: ApiErrorBody | null = null; try { parsed = (await res.json()) as ApiErrorBody; } catch { /* 非 JSON 错误体 */ } if (parsed?.error) { code = parsed.error.code; message = parsed.error.message; requestId = parsed.error.requestId; } if (res.status === 401) this.onUnauthorized?.(); throw new ApiError(res.status, code, message, requestId); } if (res.status === 204) return undefined as T; return (await res.json()) as T; } get(path: string, query?: Record) { return this.request("GET", path, undefined, query); } post(path: string, body?: unknown) { return this.request("POST", path, body); } patch(path: string, body?: unknown) { return this.request("PATCH", path, body); } delete(path: string) { return this.request("DELETE", path); } }