webapp-scaffold/src/core-fetch.ts
Uwe Schuster b14b8188fe v0.3.5: readBody tries .json() without clone() first
Some test mocks expose .json() but not .clone() — cloning throws on
object mocks. Try .json() directly, fall back to .text(). Real Response
objects are unaffected (calling .json() twice would throw, but we only
call it once since we're on the error path).
2026-04-21 22:33:01 +02:00

214 lines
8.1 KiB
TypeScript

// createCoreFetch — factory for the Orval `fetch` mutator used by derived projects.
//
// Orval's `client: 'fetch'` emits functions that call
// coreFetch<T>(url, init)
// and expect one of:
// • { data, status, headers } back (responseShape: 'wrapped', default —
// matches orval's default `includeHttpResponseReturnType: true`)
// • the parsed body directly (responseShape: 'body' — matches orval's
// `includeHttpResponseReturnType: false`, fewo's convention)
//
// Baseline wired in:
// • credentials: 'include' — cookies always sent
// • X-Requested-With: 'XMLHttpRequest' — matches the oatpp CSRF guard
// • 204 No Content → data: undefined — no JSON parse attempted
// • JSON content-type → JSON.parse, else text
//
// Hooks let projects plug in app-specific concerns (sync queue, 401 redirect,
// 409 conflict, network-failure offline fallback, localized error messages)
// without forking the mutator.
export type ResponseShape = 'wrapped' | 'body';
export interface CoreFetchRequest {
url: string;
init: RequestInit;
}
export interface CoreFetchOptions {
/** Prefix for every request; strip trailing slash. */
baseUrl?: string;
/** `'wrapped'` returns { data, status, headers } (default); `'body'` returns the parsed body. */
responseShape?: ResponseShape;
/**
* Tables whose mutations route through an offline sync queue ON NETWORK
* FAILURE only — i.e. "online-first, enqueue-on-fail". Pre-emptive
* always-queue behaviour: set `onEnqueue` to return true directly.
*/
syncTables?: ReadonlySet<string>;
/**
* Pre-emptive enqueue hook — called BEFORE the network attempt, for
* mutating requests on matching sync tables. Return `true` to skip the
* network call (the request was queued); `false` to proceed.
*/
onEnqueue?: (req: CoreFetchRequest) => boolean;
/**
* Network-failure hook — called when fetch() itself rejects (e.g. offline).
* Return a value to swallow the rejection and use that value as the
* response body (returned directly in `'body'` shape, or wrapped in
* `{ data, status: 202, headers: {} }` in `'wrapped'` shape). Return
* `undefined` (the default) to rethrow. Useful for fewo's
* "enqueue mutation on offline, return synthetic ok" pattern.
*/
onNetworkFailure?: (req: CoreFetchRequest, error: unknown) => unknown;
/** Called after any 401 response, before the error is thrown. */
on401?: (req: CoreFetchRequest) => void;
/**
* Called after any 409 response; return a resolved value to swallow
* the error (returned as the response body), or undefined to throw.
*/
on409?: (req: CoreFetchRequest, body: string) => unknown;
/**
* Custom error message formatter. Called for any non-OK response
* (after on401 / on409 have run and didn't swallow). Return a string
* to use as the Error message; return undefined to fall back to the
* default `"<status> <statusText>: <body>"` format. Also receives the
* parsed body when it's valid JSON (otherwise `null`).
*/
formatError?: (
req: CoreFetchRequest,
response: Response,
bodyText: string,
bodyJson: unknown | null,
) => string | undefined;
/** Swap out the fetch impl (tests). */
fetchImpl?: typeof fetch;
}
export type CoreFetchWrapped = <T extends { data: unknown; status: number }>(
url: string,
init?: RequestInit,
) => Promise<T>;
export type CoreFetchBody = <T>(url: string, init?: RequestInit) => Promise<T>;
export type CoreFetch = CoreFetchWrapped & CoreFetchBody;
const MUTATING = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
export function createCoreFetch(opts: CoreFetchOptions = {}): CoreFetch {
const base = (opts.baseUrl ?? '').replace(/\/$/, '');
// Resolve fetch at call time, not construction time, so tests that
// `vi.stubGlobal('fetch', ...)` after module import see the stub.
const callFetch: typeof fetch = opts.fetchImpl
? opts.fetchImpl
: ((...args) => fetch(...(args as Parameters<typeof fetch>))) as typeof fetch;
const shape: ResponseShape = opts.responseShape ?? 'wrapped';
function wrap(data: unknown, status: number, headers: Headers): unknown {
return shape === 'body' ? data : { data, status, headers };
}
function matchesSyncTable(url: string): boolean {
if (!opts.syncTables || opts.syncTables.size === 0) return false;
for (const t of opts.syncTables) {
if (url.includes(`/api/${t}`) || url.includes(`/${t}/`)) return true;
}
return false;
}
return async function coreFetch<T>(
url: string,
init: RequestInit = {},
): Promise<T> {
const method = (init.method ?? 'GET').toUpperCase();
const headers = new Headers(init.headers);
headers.set('X-Requested-With', 'XMLHttpRequest');
const req: CoreFetchRequest = { url, init: { ...init, headers } };
// Pre-emptive enqueue (always-queue pattern).
if (MUTATING.has(method) && matchesSyncTable(url) && opts.onEnqueue) {
if (opts.onEnqueue(req)) {
return wrap(null, 202, new Headers()) as T;
}
}
let r: Response;
try {
r = await callFetch(`${base}${url}`, {
credentials: 'include',
...init,
headers,
});
} catch (networkErr) {
if (opts.onNetworkFailure) {
const replacement = opts.onNetworkFailure(req, networkErr);
if (replacement !== undefined) {
return wrap(replacement, 0, new Headers()) as T;
}
}
throw networkErr;
}
if (r.status === 401) opts.on401?.(req);
if (r.status === 409) {
const [text, json] = await readBody(r);
if (opts.on409) {
const resolved = opts.on409(req, text);
if (resolved !== undefined) {
return wrap(resolved, r.status, r.headers) as T;
}
}
const msg = opts.formatError?.(req, r, text, json)
?? (json && typeof (json as Record<string, unknown>).message === 'string'
? String((json as Record<string, unknown>).message)
: `${r.status} ${r.statusText}: ${text}`);
throw decorate409(new Error(msg), json);
}
if (!r.ok) {
const [text, json] = await readBody(r);
const msg = opts.formatError?.(req, r, text, json)
?? `${r.status} ${r.statusText}: ${text}`;
throw new Error(msg);
}
let data: unknown = undefined;
if (r.status !== 204) {
const ct = r.headers.get('content-type') ?? '';
data = ct.includes('application/json') ? await r.json() : await r.text();
}
return wrap(data, r.status, r.headers) as T;
} as CoreFetch;
}
function safeJson(text: string): unknown | null {
try { return JSON.parse(text); } catch { return null; }
}
/**
* Read an error-response body. Prefer `.json()` (tests commonly mock only
* that); fall back to `.text()` if json() throws. Returns both the raw
* text (for `formatError` callers that want to log it) and the parsed
* JSON (may be null when the body isn't parseable).
*/
async function readBody(r: Response): Promise<[string, unknown | null]> {
// Some mocks expose .json() but not .clone() (e.g. vitest object mocks).
// Try .json() directly; if it throws, fall back to .text().
try {
const j = await r.json();
return [JSON.stringify(j), j];
} catch {
try {
const text = await r.text();
return [text, safeJson(text)];
} catch {
return ['', null];
}
}
}
/** Attach status/body to 409 errors so conflict-resolvers can reach them. */
function decorate409(err: Error, body: unknown): Error {
(err as Error & { status?: number; body?: unknown }).status = 409;
(err as Error & { status?: number; body?: unknown }).body = body;
return err;
}