264 lines
8.0 KiB
JavaScript
264 lines
8.0 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)
|
|
}
|
|
|
|
/**
|
|
* Authenticated binary GET returning an object URL for <img>/<video>/<iframe>
|
|
* use — a plain src attribute cannot carry the bearer token. Returns null on
|
|
* 404 (the resource legitimately not existing, e.g. a job with no cover
|
|
* image). `type` re-wraps the blob with that MIME type: routes that send
|
|
* application/octet-stream would otherwise trigger a download instead of the
|
|
* browser's inline viewer. Callers own the URL: revoke it when done.
|
|
*/
|
|
export async function fetchBlobUrl(path, { params, auth = true, type } = {}) {
|
|
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.status === 404) return null
|
|
if (!res.ok) {
|
|
throw new ApiError(res.statusText || 'Request failed', res.status, null)
|
|
}
|
|
const blob = await res.blob()
|
|
return URL.createObjectURL(type ? new Blob([blob], { type }) : blob)
|
|
}
|
|
|
|
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' })
|