文件
hyb-oa/client/src/api/http.ts
T

233 行
5.7 KiB
TypeScript

import { ApiError, SessionExpiredError, type ApiErrorBody, type ApiSuccess } from "./types";
type AccessTokenGetter = () => string | null;
type RefreshAccessToken = () => Promise<string | null>;
type ClearSession = () => void;
let getAccessToken: AccessTokenGetter = () => null;
let refreshAccessToken: RefreshAccessToken | null = null;
let clearSession: ClearSession | null = null;
let refreshPromise: Promise<string | null> | null = null;
export type ConfigureHttpAuthOptions = {
getAccessToken: AccessTokenGetter;
refreshAccessToken: RefreshAccessToken;
clearSession?: ClearSession;
};
export type ApiRequestOptions = Omit<RequestInit, "headers"> & {
headers?: HeadersInit;
auth?: boolean;
retryOn401?: boolean;
accessTokenOverride?: string | null;
};
export function configureHttpAuth(options: ConfigureHttpAuthOptions) {
getAccessToken = options.getAccessToken;
refreshAccessToken = options.refreshAccessToken;
clearSession = options.clearSession ?? null;
}
async function safeParseJson(response: Response): Promise<unknown> {
const contentType = response.headers.get("content-type") ?? "";
if (!contentType.includes("application/json")) {
return null;
}
try {
return await response.json();
} catch {
return null;
}
}
function buildApiError(response: Response, payload: unknown): ApiError {
const fallbackRequestId = response.headers.get("x-request-id") ?? "";
if (payload && typeof payload === "object" && "error" in payload) {
const body = payload as ApiErrorBody;
return new ApiError({
status: response.status,
code: body.error?.code ?? "SYS_001",
message: body.error?.message ?? "请求失败",
details: body.error?.details,
requestId: body.requestId || fallbackRequestId,
});
}
return new ApiError({
status: response.status,
code: "SYS_001",
message: "请求失败",
requestId: fallbackRequestId,
});
}
async function getRefreshedToken(): Promise<string | null> {
if (!refreshAccessToken) {
return null;
}
if (!refreshPromise) {
refreshPromise = refreshAccessToken()
.catch(() => null)
.finally(() => {
refreshPromise = null;
});
}
return refreshPromise;
}
function throwSessionExpired(): never {
if (clearSession) {
clearSession();
}
throw new SessionExpiredError();
}
async function sendRequest<T>(
path: string,
options: ApiRequestOptions,
): Promise<ApiSuccess<T>> {
const {
auth = true,
retryOn401 = true,
accessTokenOverride,
headers,
...rest
} = options;
const requestHeaders = new Headers(headers ?? {});
const token = accessTokenOverride ?? (auth ? getAccessToken() : null);
if (auth && token) {
requestHeaders.set("authorization", `Bearer ${token}`);
}
if (rest.body && !(rest.body instanceof FormData) && !requestHeaders.has("content-type")) {
requestHeaders.set("content-type", "application/json");
}
const response = await fetch(path, {
...rest,
credentials: "include",
headers: requestHeaders,
});
if (response.status === 401 && auth && retryOn401 && refreshAccessToken) {
const nextToken = await getRefreshedToken();
if (nextToken) {
return sendRequest<T>(path, {
...options,
retryOn401: false,
accessTokenOverride: nextToken,
});
}
throwSessionExpired();
}
const payload = await safeParseJson(response);
if (!response.ok) {
throw buildApiError(response, payload);
}
if (payload && typeof payload === "object" && "data" in payload && "requestId" in payload) {
return payload as ApiSuccess<T>;
}
return {
requestId: response.headers.get("x-request-id") ?? "",
data: undefined as T,
};
}
export async function apiRequest<T>(
path: string,
options: ApiRequestOptions = {},
): Promise<ApiSuccess<T>> {
return sendRequest<T>(path, options);
}
export async function apiRequestData<T>(
path: string,
options: ApiRequestOptions = {},
): Promise<T> {
const response = await apiRequest<T>(path, options);
return response.data;
}
function parseFilenameFromContentDisposition(headerValue: string | null): string | null {
if (!headerValue) {
return null;
}
const parts = headerValue.split(";").map((part) => part.trim());
const filenamePart = parts.find((part) => part.toLowerCase().startsWith("filename="));
if (!filenamePart) {
return null;
}
const raw = filenamePart.slice("filename=".length).trim();
return raw.replace(/^\"|\"$/g, "") || null;
}
export type ApiBlobResponse = {
requestId: string;
blob: Blob;
filename: string | null;
};
export async function apiRequestBlob(
path: string,
options: ApiRequestOptions = {},
): Promise<ApiBlobResponse> {
const {
auth = true,
retryOn401 = true,
accessTokenOverride,
headers,
...rest
} = options;
const requestHeaders = new Headers(headers ?? {});
const token = accessTokenOverride ?? (auth ? getAccessToken() : null);
if (auth && token) {
requestHeaders.set("authorization", `Bearer ${token}`);
}
const response = await fetch(path, {
...rest,
credentials: "include",
headers: requestHeaders,
});
if (response.status === 401 && auth && retryOn401 && refreshAccessToken) {
const nextToken = await getRefreshedToken();
if (nextToken) {
return apiRequestBlob(path, {
...options,
retryOn401: false,
accessTokenOverride: nextToken,
});
}
throwSessionExpired();
}
const payload = await safeParseJson(response);
if (!response.ok) {
throw buildApiError(response, payload);
}
const blob = await response.blob();
return {
requestId: response.headers.get("x-request-id") ?? "",
blob,
filename: parseFilenameFromContentDisposition(response.headers.get("content-disposition")),
};
}