Files
gig-admin/src/api/client.ts

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 }),
}