132 lines
4.1 KiB
TypeScript
132 lines
4.1 KiB
TypeScript
import { useAuthStore } from '@/auth/store'
|
|
import type { ApiErrorPayload, ApiResponse, ListParams, TokenGrant } from './types'
|
|
|
|
const BASE_URL = (import.meta.env.VITE_API_BASE_URL as string | undefined) ?? ''
|
|
|
|
export const ADMIN_CLIENT_MACHINE_NAME = 'admin-api-client'
|
|
|
|
export class ApiError extends Error {
|
|
constructor(
|
|
public status: number,
|
|
public code: string,
|
|
message: string,
|
|
public errors?: Record<string, string[]>,
|
|
) {
|
|
super(message)
|
|
this.name = 'ApiError'
|
|
}
|
|
}
|
|
|
|
interface RequestOptions {
|
|
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
|
|
body?: unknown
|
|
params?: ListParams
|
|
/** skip Bearer + refresh handling (login/refresh calls) */
|
|
anonymous?: boolean
|
|
signal?: AbortSignal
|
|
}
|
|
|
|
function buildUrl(path: string, params?: ListParams): string {
|
|
const url = new URL(path, BASE_URL)
|
|
if (params) {
|
|
for (const [key, value] of Object.entries(params)) {
|
|
if (value !== undefined && value !== null && value !== '') {
|
|
url.searchParams.set(key, String(value))
|
|
}
|
|
}
|
|
}
|
|
return url.toString()
|
|
}
|
|
|
|
// Single-flight refresh: concurrent 401s share one refresh attempt.
|
|
let refreshInFlight: Promise<boolean> | null = null
|
|
|
|
async function tryRefresh(): Promise<boolean> {
|
|
refreshInFlight ??= (async () => {
|
|
const { refreshToken } = useAuthStore.getState()
|
|
if (!refreshToken) return false
|
|
try {
|
|
const res = await fetch(buildUrl('/api/v1/auth/refresh-token'), {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
body: JSON.stringify({
|
|
refresh_token: refreshToken,
|
|
client_machine_name: ADMIN_CLIENT_MACHINE_NAME,
|
|
}),
|
|
})
|
|
if (!res.ok) return false
|
|
const json = (await res.json()) as ApiResponse<TokenGrant>
|
|
if (!json.success || !json.data.access_token) return false
|
|
useAuthStore.getState().setTokens(json.data)
|
|
return true
|
|
} catch {
|
|
return false
|
|
} finally {
|
|
// allow the next expiry to trigger a fresh attempt
|
|
setTimeout(() => {
|
|
refreshInFlight = null
|
|
}, 0)
|
|
}
|
|
})()
|
|
|
|
return refreshInFlight
|
|
}
|
|
|
|
async function parseError(res: Response): Promise<ApiError> {
|
|
let payload: ApiErrorPayload | null = null
|
|
try {
|
|
payload = (await res.json()) as ApiErrorPayload
|
|
} catch {
|
|
// non-JSON error body
|
|
}
|
|
return new ApiError(
|
|
res.status,
|
|
payload?.code ?? `HTTP_${res.status}`,
|
|
payload?.message ?? res.statusText,
|
|
payload?.data?.errors,
|
|
)
|
|
}
|
|
|
|
async function rawRequest(path: string, options: RequestOptions, retried = false): Promise<Response> {
|
|
const { accessToken } = useAuthStore.getState()
|
|
const headers: Record<string, string> = {
|
|
Accept: 'application/json',
|
|
'X-Language-Locale': 'zh-CN',
|
|
}
|
|
if (options.body !== undefined) headers['Content-Type'] = 'application/json'
|
|
if (!options.anonymous && accessToken) headers.Authorization = `Bearer ${accessToken}`
|
|
|
|
const res = await fetch(buildUrl(path, options.params), {
|
|
method: options.method ?? 'GET',
|
|
headers,
|
|
body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
|
|
signal: options.signal,
|
|
})
|
|
|
|
if (res.status === 401 && !options.anonymous && !retried) {
|
|
if (await tryRefresh()) {
|
|
return rawRequest(path, options, true)
|
|
}
|
|
useAuthStore.getState().clear()
|
|
}
|
|
|
|
return res
|
|
}
|
|
|
|
export async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
|
const res = await rawRequest(path, options)
|
|
if (!res.ok) throw await parseError(res)
|
|
return (await res.json()) as T
|
|
}
|
|
|
|
export const api = {
|
|
get: <T>(path: string, params?: ListParams, signal?: AbortSignal) =>
|
|
request<T>(path, { params, signal }),
|
|
post: <T>(path: string, body?: unknown) => request<T>(path, { method: 'POST', body }),
|
|
put: <T>(path: string, body?: unknown) => request<T>(path, { method: 'PUT', body }),
|
|
patch: <T>(path: string, body?: unknown) => request<T>(path, { method: 'PATCH', body }),
|
|
delete: <T>(path: string, body?: unknown) => request<T>(path, { method: 'DELETE', body }),
|
|
anonymousPost: <T>(path: string, body?: unknown) =>
|
|
request<T>(path, { method: 'POST', body, anonymous: true }),
|
|
}
|