Files
opencloud/src/lib/api.ts
T
2026-07-18 20:49:09 +08:00

135 lines
3.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}