HR-ATS-Portal/frontend/src/lib/apiClient.js

210 lines
6.3 KiB
JavaScript

/* ============================================================
apiClient.js — the one HTTP entry point.
Attaches the bearer token, renews proactively inside the skew window, and
retries exactly once on a 401. Never loops: if the retry also 401s, the
session is over and the expired handler fires.
============================================================ */
import { ApiError, parseDetail, SessionExpiredError } from './errors'
import { getAccessToken, isExpiring } from './tokenStore'
import { refreshSession } from './refresh'
const API_BASE = import.meta.env.VITE_API_BASE ?? ''
// AuthProvider registers this so the module never has to import the router.
let onSessionExpired = () => {}
export function setSessionExpiredHandler(fn) {
onSessionExpired = fn
}
function buildUrl(path, params) {
const url = new URL(`${API_BASE}${path}`, window.location.origin)
if (params) {
for (const [k, v] of Object.entries(params)) {
if (v !== undefined && v !== null && v !== '') url.searchParams.set(k, v)
}
}
return url.toString()
}
/**
* @param {object} opts
* @param {boolean} [opts.auth=true] attach the session bearer token
* @param {string} [opts.token] an explicit token (the password-reset flow);
* suppresses all refresh behaviour
*/
export async function request(
path,
{ method = 'GET', body, params, auth = true, token, signal } = {},
) {
// PROACTIVE renewal. Coalesced by single-flight, so a screen firing six
// queries at once on an expiring token still triggers exactly one refresh.
if (auth && !token && isExpiring()) {
try {
await refreshSession()
} catch {
// Fall through — the 401 path below makes the final call. This keeps a
// transient network blip from logging the user out.
}
}
// Multipart uploads pass a FormData body. Content-Type is deliberately NOT
// set for those: the browser has to write it itself so the generated boundary
// token ends up in the header, and a hand-set value strips it and the server
// reads an unparseable body. FormData is replayable, so the 401 retry below
// can re-send the same object.
const multipart = typeof FormData !== 'undefined' && body instanceof FormData
const send = async () => {
const headers = { Accept: 'application/json' }
if (body != null && !multipart) headers['Content-Type'] = 'application/json'
const bearer = token ?? (auth ? getAccessToken() : null)
if (bearer) headers.Authorization = `Bearer ${bearer}`
return fetch(buildUrl(path, params), {
method,
headers,
signal,
body: body == null ? undefined : multipart ? body : JSON.stringify(body),
})
}
let res
try {
res = await send()
} catch (err) {
if (err?.name === 'AbortError') throw err
throw new ApiError('Unable to reach the server. Check your connection.', 0, null)
}
// REACTIVE renewal — exactly one retry.
if (res.status === 401 && auth && !token) {
try {
await refreshSession()
} catch (err) {
if (err instanceof SessionExpiredError) onSessionExpired()
throw err
}
res = await send()
if (res.status === 401) {
// A fresh access token still 401s: the user was deactivated or soft-deleted
// server-side (get_current_user rejects both). Signing them out is correct.
onSessionExpired()
throw new ApiError('Session expired', 401, null)
}
}
let data = null
const text = await res.text()
if (text) {
try {
data = JSON.parse(text)
} catch {
data = null
}
}
if (!res.ok) {
throw new ApiError(
parseDetail(data?.detail) || res.statusText || 'Request failed',
res.status,
data,
)
}
return data
}
/**
* Authenticated file download. Document routes return a binary body, so they
* cannot go through `request()` (that always JSON-parses). Same bearer /
* refresh behaviour as `request`; 404s stay 404 (the download route never 403s).
*/
export async function downloadFile(path, { params, auth = true, filename } = {}) {
if (auth && isExpiring()) {
try {
await refreshSession()
} catch {
/* fall through — the 401 path below makes the final call */
}
}
const send = async () => {
const headers = { Accept: '*/*' }
const bearer = auth ? getAccessToken() : null
if (bearer) headers.Authorization = `Bearer ${bearer}`
return fetch(buildUrl(path, params), { method: 'GET', headers })
}
let res
try {
res = await send()
} catch (err) {
if (err?.name === 'AbortError') throw err
throw new ApiError('Unable to reach the server. Check your connection.', 0, null)
}
if (res.status === 401 && auth) {
try {
await refreshSession()
} catch (err) {
if (err instanceof SessionExpiredError) onSessionExpired()
throw err
}
res = await send()
if (res.status === 401) {
onSessionExpired()
throw new ApiError('Session expired', 401, null)
}
}
if (!res.ok) {
let data = null
const text = await res.text()
if (text) {
try {
data = JSON.parse(text)
} catch {
data = null
}
}
throw new ApiError(
parseDetail(data?.detail) || res.statusText || 'Download failed',
res.status,
data,
)
}
const blob = await res.blob()
const fromHeader = filenameFromDisposition(res.headers.get('Content-Disposition'))
const name = filename || fromHeader || 'download'
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = name
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
}
function filenameFromDisposition(header) {
if (!header) return null
const star = /filename\*=UTF-8''([^;]+)/i.exec(header)
if (star) {
try {
return decodeURIComponent(star[1])
} catch {
return star[1]
}
}
const quoted = /filename="([^"]+)"/i.exec(header)
if (quoted) return quoted[1]
const plain = /filename=([^;]+)/i.exec(header)
return plain ? plain[1].trim() : null
}
export const get = (path, params, opts) => request(path, { ...opts, params })
export const post = (path, body, opts) => request(path, { ...opts, method: 'POST', body })
export const put = (path, body, opts) => request(path, { ...opts, method: 'PUT', body })
export const del = (path, opts) => request(path, { ...opts, method: 'DELETE' })