#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>
This commit is contained in:
Uwe Schuster 2026-04-25 21:47:40 +02:00
parent 90c5ca2248
commit fd451fd452
4 changed files with 28 additions and 22 deletions

View file

@ -43,12 +43,16 @@ webapp-scaffold-postprocess-openapi && \
orval orval
``` ```
## `createCoreFetch` (v0.2) ## `createCoreFetch` (v0.4 — default flipped to `'body'`)
Orval's `client: 'fetch'` calls a mutator `coreFetch<T>(url, init)` and Orval's `client: 'fetch'` calls a mutator `coreFetch<T>(url, init)`. The
expects `{data, status, headers}` back. `createCoreFetch(opts)` returns return shape is configurable; **the default is `'body'`** (returns the
such a function, with credentials/CSRF headers / 204 / content-type parsed JSON body directly, matching Orval's
handling already wired, and hooks for project-specific concerns: `includeHttpResponseReturnType: false`). Pass `responseShape: 'wrapped'`
to opt back into `{data, status, headers}` when callers need the
Response metadata. `createCoreFetch(opts)` returns such a function, with
credentials/CSRF headers / 204 / content-type handling already wired,
and hooks for project-specific concerns:
```ts ```ts
import { createCoreFetch } from '@uschuster/webapp-scaffold/core-fetch'; import { createCoreFetch } from '@uschuster/webapp-scaffold/core-fetch';

View file

@ -1,6 +1,6 @@
{ {
"name": "@uschuster/webapp-scaffold", "name": "@uschuster/webapp-scaffold",
"version": "0.3.7", "version": "0.4.0",
"description": "Shared build scripts + Vite config factories for webapp-template-derived projects.", "description": "Shared build scripts + Vite config factories for webapp-template-derived projects.",
"type": "module", "type": "module",
"bin": { "bin": {

View file

@ -17,22 +17,22 @@ function jsonResponse(body: unknown, init: ResponseInit = {}): Response {
} }
describe('createCoreFetch — response shapes', () => { describe('createCoreFetch — response shapes', () => {
it("'wrapped' default returns { data, status, headers }", async () => { it("'body' default returns the parsed body directly", async () => {
const cf = createCoreFetch({ fetchImpl: () => Promise.resolve(jsonResponse({ ok: 1 })) }); const cf = createCoreFetch({ fetchImpl: () => Promise.resolve(jsonResponse({ ok: 1 })) });
const r = await cf<{ ok: number }>('/x');
expect(r).toEqual({ ok: 1 });
});
it("'wrapped' opt-in returns { data, status, headers }", async () => {
const cf = createCoreFetch({
responseShape: 'wrapped',
fetchImpl: () => Promise.resolve(jsonResponse({ ok: 1 })),
});
const r = await cf<{ data: { ok: number }; status: number }>('/x'); const r = await cf<{ data: { ok: number }; status: number }>('/x');
expect(r.data).toEqual({ ok: 1 }); expect(r.data).toEqual({ ok: 1 });
expect(r.status).toBe(200); expect(r.status).toBe(200);
}); });
it("'body' returns the parsed body directly", async () => {
const cf = createCoreFetch({
responseShape: 'body',
fetchImpl: () => Promise.resolve(jsonResponse({ ok: 1 })),
});
const r = await cf<{ ok: number }>('/x');
expect(r).toEqual({ ok: 1 });
});
it('non-JSON 2xx returns the body as plain text', async () => { it('non-JSON 2xx returns the body as plain text', async () => {
const cf = createCoreFetch({ const cf = createCoreFetch({
responseShape: 'body', responseShape: 'body',

View file

@ -3,10 +3,12 @@
// Orval's `client: 'fetch'` emits functions that call // Orval's `client: 'fetch'` emits functions that call
// coreFetch<T>(url, init) // coreFetch<T>(url, init)
// and expect one of: // and expect one of:
// • { data, status, headers } back (responseShape: 'wrapped', default — // • the parsed body directly (responseShape: 'body', DEFAULT — matches
// matches orval's default `includeHttpResponseReturnType: true`) // orval's `includeHttpResponseReturnType: false`, the Orval-driven
// • the parsed body directly (responseShape: 'body' — matches orval's // "no extra ceremony" path)
// `includeHttpResponseReturnType: false`, fewo's convention) // • { data, status, headers } back (responseShape: 'wrapped' — matches
// orval's `includeHttpResponseReturnType: true`; opt in when callers
// need the Response metadata)
// //
// Baseline wired in: // Baseline wired in:
// • credentials: 'include' — cookies always sent // • credentials: 'include' — cookies always sent
@ -29,7 +31,7 @@ export interface CoreFetchOptions {
/** Prefix for every request; strip trailing slash. */ /** Prefix for every request; strip trailing slash. */
baseUrl?: string; baseUrl?: string;
/** `'wrapped'` returns { data, status, headers } (default); `'body'` returns the parsed body. */ /** `'body'` returns the parsed body (default); `'wrapped'` returns { data, status, headers }. */
responseShape?: ResponseShape; responseShape?: ResponseShape;
/** /**
@ -101,7 +103,7 @@ export function createCoreFetch(opts: CoreFetchOptions = {}): CoreFetch {
const callFetch: typeof fetch = opts.fetchImpl const callFetch: typeof fetch = opts.fetchImpl
? opts.fetchImpl ? opts.fetchImpl
: ((...args) => fetch(...(args as Parameters<typeof fetch>))) as typeof fetch; : ((...args) => fetch(...(args as Parameters<typeof fetch>))) as typeof fetch;
const shape: ResponseShape = opts.responseShape ?? 'wrapped'; const shape: ResponseShape = opts.responseShape ?? 'body';
function wrap(data: unknown, status: number, headers: Headers): unknown { function wrap(data: unknown, status: number, headers: Headers): unknown {
return shape === 'body' ? data : { data, status, headers }; return shape === 'body' ? data : { data, status, headers };