135 lines
3.7 KiB
TypeScript
135 lines
3.7 KiB
TypeScript
import type { AuthResponse } from '@/types/api'
|
||
|
||
const DEFAULT_API_URL = 'http://localhost:8000/api/v1'
|
||
|
||
export const API_URL = (import.meta.env.VITE_API_URL || DEFAULT_API_URL).replace(/\/$/, '')
|
||
export const AUTH_UPDATED_EVENT = 'opencloud:auth-updated'
|
||
export const AUTH_EXPIRED_EVENT = 'opencloud:auth-expired'
|
||
|
||
let accessToken: string | null = null
|
||
let refreshPromise: Promise<AuthResponse> | null = null
|
||
|
||
export class ApiError extends Error {
|
||
status: number
|
||
detail: unknown
|
||
|
||
constructor(message: string, status: number, detail?: unknown) {
|
||
super(message)
|
||
this.name = 'ApiError'
|
||
this.status = status
|
||
this.detail = detail
|
||
}
|
||
}
|
||
|
||
interface ApiRequestOptions extends Omit<RequestInit, 'body'> {
|
||
body?: BodyInit | Record<string, unknown> | null
|
||
auth?: boolean
|
||
retry?: boolean
|
||
}
|
||
|
||
function isJsonBody(body: ApiRequestOptions['body']): body is Record<string, unknown> {
|
||
return !!body
|
||
&& typeof body === 'object'
|
||
&& !(body instanceof FormData)
|
||
&& !(body instanceof Blob)
|
||
&& !(body instanceof URLSearchParams)
|
||
&& !(body instanceof ArrayBuffer)
|
||
}
|
||
|
||
function detailToMessage(detail: unknown, fallback: string) {
|
||
if (typeof detail === 'string') return detail
|
||
if (Array.isArray(detail)) {
|
||
const messages = detail
|
||
.map(item => item && typeof item === 'object' && 'msg' in item ? String(item.msg) : '')
|
||
.filter(Boolean)
|
||
if (messages.length) return messages.join(';')
|
||
}
|
||
return fallback
|
||
}
|
||
|
||
async function toApiError(response: Response) {
|
||
let detail: unknown
|
||
try {
|
||
const payload = await response.json() as { detail?: unknown }
|
||
detail = payload.detail
|
||
} catch {
|
||
detail = undefined
|
||
}
|
||
|
||
return new ApiError(
|
||
detailToMessage(detail, `请求失败(${response.status})`),
|
||
response.status,
|
||
detail,
|
||
)
|
||
}
|
||
|
||
export function setAccessToken(token: string | null) {
|
||
accessToken = token
|
||
}
|
||
|
||
export function clearAccessToken() {
|
||
accessToken = null
|
||
}
|
||
|
||
async function request<T>(path: string, options: ApiRequestOptions = {}) {
|
||
const { body, auth = true, retry = true, ...requestOptions } = options
|
||
const headers = new Headers(requestOptions.headers)
|
||
let requestBody: BodyInit | null | undefined = body as BodyInit | null | undefined
|
||
|
||
if (isJsonBody(body)) {
|
||
headers.set('Content-Type', 'application/json')
|
||
requestBody = JSON.stringify(body)
|
||
}
|
||
|
||
if (auth && accessToken) {
|
||
headers.set('Authorization', `Bearer ${accessToken}`)
|
||
}
|
||
|
||
const response = await fetch(`${API_URL}${path}`, {
|
||
...requestOptions,
|
||
body: requestBody,
|
||
headers,
|
||
credentials: 'include',
|
||
})
|
||
|
||
if (response.status === 401 && auth && accessToken && retry) {
|
||
try {
|
||
const authResponse = await refreshSession()
|
||
window.dispatchEvent(new CustomEvent<AuthResponse>(AUTH_UPDATED_EVENT, { detail: authResponse }))
|
||
return request<T>(path, { ...options, retry: false })
|
||
} catch {
|
||
clearAccessToken()
|
||
window.dispatchEvent(new Event(AUTH_EXPIRED_EVENT))
|
||
}
|
||
}
|
||
|
||
if (!response.ok) throw await toApiError(response)
|
||
if (response.status === 204) return undefined as T
|
||
|
||
return response.json() as Promise<T>
|
||
}
|
||
|
||
export function apiRequest<T>(path: string, options: ApiRequestOptions = {}) {
|
||
return request<T>(path, options)
|
||
}
|
||
|
||
export function refreshSession() {
|
||
if (!refreshPromise) {
|
||
refreshPromise = (async () => {
|
||
const response = await fetch(`${API_URL}/auth/refresh`, {
|
||
method: 'POST',
|
||
credentials: 'include',
|
||
})
|
||
if (!response.ok) throw await toApiError(response)
|
||
|
||
const authResponse = await response.json() as AuthResponse
|
||
setAccessToken(authResponse.access_token)
|
||
return authResponse
|
||
})().finally(() => {
|
||
refreshPromise = null
|
||
})
|
||
}
|
||
|
||
return refreshPromise
|
||
}
|