webapp-scaffold/src/core-fetch.ts
Uwe Schuster fd451fd452 #5: createCoreFetch default responseShape flipped to 'body'
Orval's `client: 'fetch'` (with the no-`includeHttpResponseReturnType`
default) emits mutator signatures expecting the parsed body — so making
`'body'` the default means the no-arg `createCoreFetch()` form is the
right answer for the typical Orval-driven derived project. Callers that
need Response metadata opt in via `responseShape: 'wrapped'`.

Aligns README + docstring + the inline default. Tests updated to assert
default-`'body'` and explicit-`'wrapped'`.

Bump to 0.4.0 (BREAKING — default flipped). Downstream consumers on the
default need to either (a) add explicit `responseShape: 'wrapped'` to
preserve old behaviour, or (b) migrate callers to the body shape.
fewo-webapp's existing `responseShape: 'body'` override is now redundant
and will be dropped in a follow-up.

Closes #5

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 21:47:40 +02:00

216 lines
8.2 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:
// • the parsed body directly (responseShape: 'body', DEFAULT — matches
// orval's `includeHttpResponseReturnType: false`, the Orval-driven
// "no extra ceremony" path)
// • { data, status, headers } back (responseShape: 'wrapped' — matches
// orval's `includeHttpResponseReturnType: true`; opt in when callers
// need the Response metadata)
//
// 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;
/** `'body'` returns the parsed body (default); `'wrapped'` returns { data, status, headers }. */
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 ?? 'body';
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;
}