Files
mygo/web/src/api/client.ts
T
ld 04eb8727eb feat(web): add authenticated file workflow
- feat: connect login and logout to the REST API with session-scoped tokens, protected routing, single-flight refresh, and authenticated query cache cleanup.
- feat: add the Ant Design application shell with root file listing, pagination, small-file upload, and authenticated download.
- test: cover session storage and API retry and error behavior with Vitest.
- docs: document the browser milestone, same-origin API topology, and deferred transfer and account features.
2026-07-15 20:16:11 +08:00

160 lines
3.8 KiB
TypeScript

import { sessionStore, type SessionStore } from './session.ts'
import type { ErrorResponse, TokenPair } from './types.ts'
const defaultApiBase = '/api/v1'
type ResponseType = 'blob' | 'json' | 'void'
export interface ApiRequestOptions {
auth?: boolean
body?: BodyInit | null
headers?: HeadersInit
json?: unknown
method?: string
responseType?: ResponseType
signal?: AbortSignal
}
interface ApiClientOptions {
apiBase?: string
fetchImplementation?: typeof fetch
store?: SessionStore
}
export class ApiError extends Error {
readonly status: number
readonly logId?: string
constructor(status: number, message: string, logId?: string) {
super(message)
this.name = 'ApiError'
this.status = status
this.logId = logId
}
}
async function responseError(response: Response): Promise<ApiError> {
let body: ErrorResponse | undefined
try {
body = (await response.json()) as ErrorResponse
} catch {
// Non-JSON failures use a stable fallback message.
}
const message =
body?.error?.message ?? `Request failed with status ${response.status}`
return new ApiError(response.status, message, body?.error?.log_id)
}
async function readResponse<T>(
response: Response,
responseType: ResponseType,
): Promise<T> {
if (!response.ok) {
throw await responseError(response)
}
if (responseType === 'blob') {
return (await response.blob()) as T
}
if (responseType === 'void' || response.status === 204) {
return undefined as T
}
return (await response.json()) as T
}
export function createApiClient({
apiBase = defaultApiBase,
fetchImplementation = fetch,
store = sessionStore,
}: ApiClientOptions = {}) {
let refreshPromise: Promise<TokenPair> | null = null
const refreshSession = (): Promise<TokenPair> => {
if (refreshPromise) {
return refreshPromise
}
const refreshToken = store.get()?.refresh_token
if (!refreshToken) {
store.clear()
return Promise.reject(new ApiError(401, 'Your session has expired'))
}
refreshPromise = fetchImplementation(`${apiBase}/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refresh_token: refreshToken }),
})
.then((response) => readResponse<TokenPair>(response, 'json'))
.then((session) => {
store.set(session)
return session
})
.catch((error: unknown) => {
store.clear()
throw error
})
.finally(() => {
refreshPromise = null
})
return refreshPromise
}
const request = async <T>(
path: string,
options: ApiRequestOptions = {},
): Promise<T> => {
const {
auth = true,
body,
headers: providedHeaders,
json,
method = 'GET',
responseType = 'json',
signal,
} = options
const run = (accessToken?: string) => {
const headers = new Headers(providedHeaders)
let requestBody = body
if (json !== undefined) {
headers.set('Content-Type', 'application/json')
requestBody = JSON.stringify(json)
}
if (accessToken) {
headers.set('Authorization', `Bearer ${accessToken}`)
}
return fetchImplementation(`${apiBase}${path}`, {
method,
headers,
body: requestBody,
signal,
})
}
const accessToken = auth ? store.get()?.access_token : undefined
if (auth && !accessToken) {
throw new ApiError(401, 'Authentication required')
}
let response = await run(accessToken)
if (auth && response.status === 401) {
const refreshedSession = await refreshSession()
response = await run(refreshedSession.access_token)
if (response.status === 401) {
store.clear()
}
}
return readResponse<T>(response, responseType)
}
return { request }
}
export const apiClient = createApiClient()