apiClient.ts 4.95 KB
export type ApiClientOptions = {
  baseUrl?: string;
  /**
   * Optional auth token provider.
   * If present, request will add `Authorization: Bearer <token>`.
   */
  getToken?: () => string | null | undefined;
};

export type AbpErrorPayload = {
  error?: {
    code?: string;
    message?: string;
    details?: string;
    validationErrors?: { message?: string; members?: string[] }[];
  };
};

type WrappedResponse<T = unknown> = {
  data?: T;
  succeeded?: boolean;
  statusCode?: number;
  errors?: unknown;
  extras?: unknown;
  timestamp?: unknown;
};

export class ApiError extends Error {
  status: number;
  payload?: unknown;

  constructor(message: string, status: number, payload?: unknown) {
    super(message);
    this.name = "ApiError";
    this.status = status;
    this.payload = payload;
  }
}

function joinUrl(baseUrl: string, path: string): string {
  if (!baseUrl) return path;
  const b = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
  const p = path.startsWith("/") ? path : `/${path}`;
  return `${b}${p}`;
}

function toQueryString(params: Record<string, unknown>): string {
  const qs = new URLSearchParams();
  for (const [k, v] of Object.entries(params)) {
    if (v === undefined || v === null || v === "") continue;
    if (typeof v === "boolean") {
      qs.set(k, v ? "true" : "false");
      continue;
    }
    qs.set(k, String(v));
  }
  const s = qs.toString();
  return s ? `?${s}` : "";
}

function getAbpErrorMessage(payload: unknown): string | null {
  const p = payload as AbpErrorPayload | null | undefined;
  const msg = p?.error?.message?.trim();
  if (msg) return msg;
  return null;
}

function normalizePagedResultShape(x: unknown): unknown {
  // Some APIs return the list directly in `data` (no items/totalCount).
  if (Array.isArray(x)) {
    return { items: x, totalCount: x.length };
  }
  if (!x || typeof x !== "object") return x;
  const o = x as Record<string, unknown>;

  // 兼容 ABP/PagedResultDto 的两种命名:TotalCount/Items vs totalCount/items
  const hasUpper = "TotalCount" in o || "Items" in o;
  const hasLower = "totalCount" in o || "items" in o;
  if (hasUpper && !hasLower) {
    return {
      ...o,
      totalCount: o.TotalCount,
      items: o.Items,
    };
  }

  // 兼容部分接口返回:{ data: [...], totalCount: number }
  if (!("items" in o) && Array.isArray(o.data) && typeof o.totalCount === "number") {
    return {
      ...o,
      items: o.data,
    };
  }
  // 兼容部分接口返回:{ Data: [...], TotalCount: number }
  if (!("items" in o) && Array.isArray((o as any).Data) && typeof (o as any).TotalCount === "number") {
    return {
      ...o,
      totalCount: (o as any).TotalCount,
      items: (o as any).Data,
    };
  }
  // 有 items 但 totalCount 缺失时,避免分页总数恒为 0
  if (Array.isArray(o.items) && typeof o.totalCount !== "number") {
    const tc = o.TotalCount;
    return {
      ...o,
      totalCount: typeof tc === "number" ? tc : o.items.length,
    };
  }
  return x;
}

export function createApiClient(opts: ApiClientOptions = {}) {
  const baseUrl = opts.baseUrl ?? import.meta.env.VITE_API_BASE_URL ?? "http://localhost:19001";
  const getToken = opts.getToken;

  async function requestJson<T>(input: {
    path: string;
    method: "GET" | "POST" | "PUT" | "DELETE";
    query?: Record<string, unknown>;
    body?: unknown;
    signal?: AbortSignal;
    /**
     * ABP conventional controller usually uses `api/app`.
     * Keep it configurable per call.
     */
    prefix?: string;
  }): Promise<T> {
    const prefix = input.prefix ?? "/api/app";
    const url = joinUrl(baseUrl, `${prefix}${input.path}${toQueryString(input.query ?? {})}`);

    const headers: Record<string, string> = {
      "Content-Type": "application/json",
    };
    const token = getToken?.();
    if (token) headers.Authorization = `Bearer ${token}`;

    const res = await fetch(url, {
      method: input.method,
      headers,
      body: input.body === undefined ? undefined : JSON.stringify(input.body),
      signal: input.signal,
    });

    const contentType = res.headers.get("content-type") ?? "";
    const isJson = contentType.includes("application/json");
    const payload = isJson ? await res.json().catch(() => null) : await res.text().catch(() => "");

    if (!res.ok) {
      const abpMsg = getAbpErrorMessage(payload);
      const msg = abpMsg ?? (typeof payload === "string" && payload.trim() ? payload : "Request failed.");
      throw new ApiError(msg, res.status, payload);
    }

    // 部分宿主会把真实返回包在 { data, succeeded, statusCode, ... } 中(如抓包所示)。
    // 为了不污染各业务 service,这里统一做一次解包:优先返回 data。
    if (payload && typeof payload === "object" && "data" in (payload as Record<string, unknown>)) {
      const wrapped = payload as WrappedResponse<T>;
      return normalizePagedResultShape(wrapped.data ?? null) as T;
    }

    return normalizePagedResultShape(payload) as T;
  }

  return { requestJson };
}