- Monorepo: NestJS API (Fastify) + React web (antd/ProComponents) + shared packages - PostgreSQL/Prisma (unified resources, relations graph, audit, events, jobs) - Auth/RBAC/workspace isolation, credential vault (AES-256-GCM) - Servers/SSH, Docker discovery & lifecycle, Job Center - Websites & deployments (git provider credential clone, env, health verification) - Domains/DNS (Cloudflare adapter), certificates (TLS probe), monitoring - Automation (cron/event/webhook HMAC + notifications), AI Gateway, MCP (29 tools) - AI chat window (LLM function-calling + rules fallback, SSE streaming, approvals) - Notes: Vditor WYSIWYG markdown editor, non-blocking autosave - Tests: 35 unit/security tests; UI audit clean (desktop+mobile, deep links)
119 lines
3.6 KiB
TypeScript
119 lines
3.6 KiB
TypeScript
/**
|
||
* # @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<T>(
|
||
method: string,
|
||
path: string,
|
||
body?: unknown,
|
||
query?: Record<string, string | number | boolean | undefined | null>,
|
||
): Promise<T> {
|
||
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<string, string> = {};
|
||
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<T>(path: string, query?: Record<string, string | number | boolean | undefined | null>) {
|
||
return this.request<T>("GET", path, undefined, query);
|
||
}
|
||
post<T>(path: string, body?: unknown) {
|
||
return this.request<T>("POST", path, body);
|
||
}
|
||
patch<T>(path: string, body?: unknown) {
|
||
return this.request<T>("PATCH", path, body);
|
||
}
|
||
delete<T>(path: string) {
|
||
return this.request<T>("DELETE", path);
|
||
}
|
||
}
|