M2-T06: scaffold React SPA frontend with typed OpenAPI client
- Vite + React 18 + TypeScript + Mantine + TanStack Query + react-router-dom - typed client: openapi-typescript -> src/api/schema.d.ts (committed), openapi-fetch - fetch wrapper middleware: cookies, X-CSRF-Token on writes, 401 -> /login, non-401 errors carry parsed JSON body - SessionProvider/useSession (GET /api/session), ProtectedRoute skeleton - app shell (Mantine + router) with placeholder login/home/config pages + gear nav - dev proxy to FastAPI; vitest smoke test; frontend README - npm scripts: dev/build/preview/lint/typecheck/test/codegen
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* 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
|
||||
Reference in New Issue
Block a user