04eb8727eb
- 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.
160 lines
3.8 KiB
TypeScript
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()
|