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>
216 lines
8.2 KiB
TypeScript
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;
|
|
}
|