233 行
5.7 KiB
TypeScript
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")),
|
|
};
|
|
}
|