feat(08-07): create api/utils.js with request() and fetchWithRetry() — CODE-08
- Move request() verbatim from client.js to utils.js (breaks circular dep) - Add fetchWithRetry() consolidating 3 blob-download 401-retry patterns - Lazy import of authStore preserved for Pinia bootstrap cycle safety - Bearer token from authStore memory only per CLAUDE.md security rule
This commit is contained in:
@@ -0,0 +1,119 @@
|
|||||||
|
/**
|
||||||
|
* HTTP transport + 401-retry consolidator.
|
||||||
|
*
|
||||||
|
* `request()` moved from client.js to break the circular dependency:
|
||||||
|
* domain modules import request from here; client.js re-exports from those
|
||||||
|
* same domain modules, so request cannot also live in client.js.
|
||||||
|
*
|
||||||
|
* `fetchWithRetry()` consolidates 3 blob-download patterns that previously
|
||||||
|
* duplicated identical auth-injection + 401-retry boilerplate:
|
||||||
|
* - adminExportAuditLogCsv (admin.js)
|
||||||
|
* - adminDownloadDailyExport (admin.js)
|
||||||
|
* - fetchDocumentContent (documents.js)
|
||||||
|
*
|
||||||
|
* Security: Bearer token injected from authStore (Pinia memory only — CLAUDE.md).
|
||||||
|
* Token is NEVER read from localStorage or sessionStorage.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Core HTTP transport. All JSON-returning endpoints go through this function.
|
||||||
|
*
|
||||||
|
* On 401: attempts one token refresh via authStore.refresh() then retries.
|
||||||
|
* Skip-refresh guard: login/register return 401 for bad credentials (not expired
|
||||||
|
* tokens), and refresh itself must not retry (would cause infinite loop).
|
||||||
|
*
|
||||||
|
* @param {string} path — API path (e.g. '/api/documents')
|
||||||
|
* @param {RequestInit & {_retry?: boolean}} [options] — fetch options
|
||||||
|
* @returns {Promise<any>} — parsed JSON response, or null for 204/empty body
|
||||||
|
*/
|
||||||
|
export async function request(path, options = {}) {
|
||||||
|
// Lazy import to avoid circular dependency (stores/auth.js → api/client.js → stores/auth.js)
|
||||||
|
const { useAuthStore } = await import('../stores/auth.js')
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
const headers = { ...(options.headers || {}) }
|
||||||
|
if (authStore.accessToken) {
|
||||||
|
headers['Authorization'] = `Bearer ${authStore.accessToken}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(path, { ...options, headers, credentials: 'include' })
|
||||||
|
|
||||||
|
// 401 → attempt refresh → retry once
|
||||||
|
// Skip refresh for auth endpoints: login/register return 401 for bad credentials (not expired tokens),
|
||||||
|
// and refresh itself must not retry to avoid an infinite loop.
|
||||||
|
const noRefreshPaths = ['/api/auth/login', '/api/auth/register', '/api/auth/refresh']
|
||||||
|
if (res.status === 401 && !options._retry && !noRefreshPaths.includes(path)) {
|
||||||
|
try {
|
||||||
|
await authStore.refresh()
|
||||||
|
return request(path, { ...options, _retry: true })
|
||||||
|
} catch {
|
||||||
|
authStore.accessToken = null
|
||||||
|
authStore.user = null
|
||||||
|
throw new Error('Session expired')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
let msg = `HTTP ${res.status}`
|
||||||
|
let payload = null
|
||||||
|
try {
|
||||||
|
const body = await res.json()
|
||||||
|
if (typeof body.detail === 'object' && body.detail !== null) {
|
||||||
|
payload = body.detail
|
||||||
|
msg = body.detail.message || `HTTP ${res.status}`
|
||||||
|
} else {
|
||||||
|
msg = body.detail || msg
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
const err = new Error(msg)
|
||||||
|
err.status = res.status
|
||||||
|
if (payload) err.payload = payload
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
if (res.status === 204 || res.headers.get('content-length') === '0') return null
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authenticated fetch with 401-retry for non-JSON responses (blobs, raw Response).
|
||||||
|
*
|
||||||
|
* Consolidates adminExportAuditLogCsv, adminDownloadDailyExport, fetchDocumentContent
|
||||||
|
* which share identical auth-injection + 401-retry boilerplate (CODE-08).
|
||||||
|
*
|
||||||
|
* Unlike request(), this does NOT parse the response — the raw Response is returned
|
||||||
|
* so callers can call .text(), .blob(), etc. as appropriate.
|
||||||
|
*
|
||||||
|
* Security: Bearer token from authStore.accessToken (memory only — CLAUDE.md).
|
||||||
|
* On 401-and-not-already-retried: calls authStore.refresh() and recurses with
|
||||||
|
* _retry=true. On refresh failure: clears in-memory token state and throws
|
||||||
|
* 'Session expired'.
|
||||||
|
*
|
||||||
|
* @param {string} url — full URL to fetch
|
||||||
|
* @param {RequestInit} [options] — fetch options (method, headers, etc.)
|
||||||
|
* @param {boolean} [_retry] — internal retry guard; callers must NOT pass this
|
||||||
|
* @returns {Promise<Response>} — raw Response; caller decides how to consume it
|
||||||
|
*/
|
||||||
|
export async function fetchWithRetry(url, options = {}, _retry = false) {
|
||||||
|
const { useAuthStore } = await import('../stores/auth.js')
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
const headers = { ...(options.headers || {}) }
|
||||||
|
if (authStore.accessToken) {
|
||||||
|
headers['Authorization'] = `Bearer ${authStore.accessToken}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(url, { ...options, headers, credentials: 'include' })
|
||||||
|
|
||||||
|
if (res.status === 401 && !_retry) {
|
||||||
|
try {
|
||||||
|
await authStore.refresh()
|
||||||
|
return fetchWithRetry(url, options, true)
|
||||||
|
} catch {
|
||||||
|
authStore.accessToken = null
|
||||||
|
authStore.user = null
|
||||||
|
throw new Error('Session expired')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return res
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user