Files
devos/packages/api-client/src/index.ts
T
phptits 0d6cd37579 DevOS v1 + Phase 2: unified resource model, infra ops, website deployment, AI chat ops
- 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)
2026-09-10 06:35:55 +00:00

119 lines
3.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* # @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);
}
}