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:
curo1305
2026-06-10 18:39:41 +02:00
parent 4d7157d7fc
commit 80d6f376b0
+119
View File
@@ -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
}