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)
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* # @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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user