Files
home-automation/frontend/src/api/client.ts
T

110 lines
3.5 KiB
TypeScript
Raw Normal View History

/**
* Typed API client built on openapi-fetch + generated schema.d.ts.
*
* Middleware contract (orchestrator-decisions.md §11):
* 1. Always send cookies (credentials: "include"; same-origin auto-sends but explicit is clear).
* 2. Non-GET/HEAD requests inject X-CSRF-Token from the csrf holder.
* Exception: POST /api/auth/login skips injection (unauthenticated endpoint).
* 3. 401 responses → clear session state + navigate to /login.
* 4. Other non-2xx responses → throw an ApiError carrying the parsed JSON body,
* so callers (e.g. SMTP test) can inspect body.result.
*/
import createClient, { type Middleware } from 'openapi-fetch'
import type { paths } from './schema.d.ts'
import { getCsrfToken } from './csrf'
// ---------------------------------------------------------------------------
// Error type
// ---------------------------------------------------------------------------
/** Error thrown for non-2xx, non-401 responses. Carries the parsed JSON body. */
export class ApiError extends Error {
constructor(
public readonly status: number,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
public readonly body: any,
) {
super(`API error ${status}`)
this.name = 'ApiError'
}
}
// ---------------------------------------------------------------------------
// Internal navigation helper (avoids React-router import at module level)
// ---------------------------------------------------------------------------
let _navigateToLogin: (() => void) | null = null
/**
* Register a callback that the middleware calls on 401.
* SessionProvider calls this during its setup.
*/
export function registerLoginRedirect(fn: () => void): void {
_navigateToLogin = fn
}
// ---------------------------------------------------------------------------
// CSRF middleware
// ---------------------------------------------------------------------------
const WRITE_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE'])
const LOGIN_PATH = '/api/auth/login'
const csrfMiddleware: Middleware = {
async onRequest({ request }) {
// Always include cookies (same-origin; explicit for clarity)
// Note: credentials is set at client level; this is belt-and-suspenders doc.
const method = request.method.toUpperCase()
const url = new URL(request.url)
if (WRITE_METHODS.has(method) && url.pathname !== LOGIN_PATH) {
const token = getCsrfToken()
if (token) {
request.headers.set('X-CSRF-Token', token)
}
}
return request
},
async onResponse({ response }) {
if (response.status === 401) {
// Clear any cached session state by triggering a page navigation.
// The SessionProvider query will refetch and find no session.
if (_navigateToLogin) {
_navigateToLogin()
}
// Return the original response so callers can handle 401 if needed.
return response
}
if (!response.ok) {
// Parse body and throw; caller can catch ApiError and read .body
let body: unknown
try {
body = await response.clone().json()
} catch {
body = null
}
throw new ApiError(response.status, body)
}
return response
},
}
// ---------------------------------------------------------------------------
// Client instance
// ---------------------------------------------------------------------------
const apiClient = createClient<paths>({
baseUrl: '/',
credentials: 'include',
})
apiClient.use(csrfMiddleware)
export default apiClient