Files
pansy/web/src/lib/api.ts
T
steveandClaude Opus 4.8 ae1906e169
Build image / build-and-push (push) Successful in 7s
Address Gadfly review on #6: logout errors, register gating, guard errors
Fixes from the PR #25 adversarial review (graded 23 real / 5 false positive):

Error handling
- onLogout wraps mutateAsync in try/catch: a failed logout no longer
  becomes an unhandled rejection; the user stays put (session still valid)
  and the button offers "Retry sign out" (5 models flagged this).
- Root errorComponent (RouteError): a non-401 /auth/me failure in a
  beforeLoad guard now shows a recoverable "Try again" screen instead of
  blanking.
- Forms drop noValidate, restoring native required/type=email/minLength
  checks before hitting the server.

Correctness
- RegisterPage gates on providers.isPending/isError before rendering, so
  the form no longer briefly appears (submittable) on an SSO-only server.
- TextField id falls back to name then a useId() value, so the label/hint
  associations hold even if a caller omits both.

Maintainability
- Single safeRedirectPath (lib/redirect.ts) replaces the duplicated
  safeRedirect/safeInternalPath (4 models).
- Generic ApiError helpers (apiErrorCode, errorMessage) moved to lib/api.ts;
  LoginPage builds the OIDC URL from the exported API_BASE.
- Divider moved to components/ui/. errorMessage doc clarified.

Verified again in a real browser: register -> /gardens; Sign out -> /login.
tsc --noEmit and vite build clean.

Not changed (graded, with rationale): OIDC deep-link redirect isn't
preserved (needs backend state threading — follow-up); 60s me staleTime in
guards (server is authoritative); the login/register onSubmit catches are
the intended react-query pattern (errors render via mutation state).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
2026-07-18 18:10:00 -04:00

146 lines
5.0 KiB
TypeScript

// Typed fetch wrapper for the pansy JSON API under /api/v1.
//
// Every non-2xx response throws an ApiError carrying the HTTP status and the
// parsed response body. That contract matters beyond this issue: the optimistic
// editor (per DESIGN.md § Sync) sends each PATCH/DELETE with a row `version` and,
// on a 409, reads the *current* row back out of `ApiError.body` to reconcile.
/** Base path for the versioned JSON API; also used to build server-nav links. */
export const API_BASE = '/api/v1'
/** Error thrown for any non-2xx response (or a network failure, status 0). */
export class ApiError extends Error {
readonly status: number
/** Parsed response body (JSON object, string, or null). On a 409 this is the current server row. */
readonly body: unknown
constructor(message: string, status: number, body: unknown) {
super(message)
this.name = 'ApiError'
this.status = status
this.body = body
}
/** A version conflict (DESIGN.md § Sync); `body` holds the current server row. */
get isConflict(): boolean {
return this.status === 409
}
/** No authenticated session; callers typically redirect to /login. */
get isUnauthorized(): boolean {
return this.status === 401
}
}
type ParamValue = string | number | boolean | undefined | null
export type Params = Record<string, ParamValue>
export interface RequestOptions {
method?: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'
/** JSON request body; serialized and sent with a JSON content-type. */
body?: unknown
/** Query-string parameters; undefined/null/'' entries are omitted. */
params?: Params
signal?: AbortSignal
}
function buildUrl(path: string, params?: Params): string {
const url = API_BASE + path
if (!params) return url
const qs = new URLSearchParams()
for (const [k, v] of Object.entries(params)) {
if (v === undefined || v === null || v === '') continue
qs.set(k, String(v))
}
const s = qs.toString()
return s ? `${url}?${s}` : url
}
async function parseBody(res: Response): Promise<unknown> {
if (res.status === 204) return null
const text = await res.text()
if (!text) return null
const contentType = res.headers.get('content-type') ?? ''
if (contentType.includes('application/json')) {
try {
return JSON.parse(text)
} catch {
return text
}
}
return text
}
function messageFrom(body: unknown, status: number): string {
if (body && typeof body === 'object') {
const err = (body as { error?: { message?: string } }).error
if (err?.message) return err.message
const msg = (body as { message?: string }).message
if (msg) return msg
}
return `Request failed (${status})`
}
/** Perform a request against /api/v1 and return the parsed JSON body as T. */
export async function apiFetch<T>(path: string, opts: RequestOptions = {}): Promise<T> {
const { method = 'GET', body, params, signal } = opts
// Serialize before the try so a JSON.stringify failure (e.g. a circular value)
// surfaces as itself, not as a misleading "cannot reach the server" error.
const requestBody = body !== undefined ? JSON.stringify(body) : undefined
let res: Response
try {
res = await fetch(buildUrl(path, params), {
method,
signal,
credentials: 'same-origin', // send the HttpOnly session cookie
headers: {
accept: 'application/json',
...(body !== undefined ? { 'content-type': 'application/json' } : {}),
},
body: requestBody,
})
} catch (err) {
if ((err as Error)?.name === 'AbortError') throw err
throw new ApiError('Cannot reach the pansy server.', 0, null)
}
const payload = await parseBody(res)
if (!res.ok) {
throw new ApiError(messageFrom(payload, res.status), res.status, payload)
}
return payload as T
}
/** The server error code from an ApiError body ({error:{code,message}}), if any. */
export function apiErrorCode(err: unknown): string | null {
if (err instanceof ApiError && err.body && typeof err.body === 'object') {
const e = (err.body as { error?: { code?: unknown } }).error
if (e && typeof e.code === 'string') return e.code
}
return null
}
/**
* A user-facing message for a caught request error. An ApiError carries the
* server's own message (already user-friendly), so that wins; `fallback` covers
* everything else (unexpected throws).
*/
export function errorMessage(err: unknown, fallback = 'Something went wrong.'): string {
if (err instanceof ApiError) return err.message
return fallback
}
/** Convenience verbs over apiFetch. */
export const api = {
get: <T>(path: string, opts?: Omit<RequestOptions, 'method' | 'body'>) =>
apiFetch<T>(path, { ...opts, method: 'GET' }),
post: <T>(path: string, body?: unknown, opts?: Omit<RequestOptions, 'method' | 'body'>) =>
apiFetch<T>(path, { ...opts, method: 'POST', body }),
patch: <T>(path: string, body?: unknown, opts?: Omit<RequestOptions, 'method' | 'body'>) =>
apiFetch<T>(path, { ...opts, method: 'PATCH', body }),
delete: <T>(path: string, opts?: Omit<RequestOptions, 'method' | 'body'>) =>
apiFetch<T>(path, { ...opts, method: 'DELETE' }),
}