diff --git a/.changeset/chatty-bears-clap.md b/.changeset/chatty-bears-clap.md new file mode 100644 index 0000000..d93df30 --- /dev/null +++ b/.changeset/chatty-bears-clap.md @@ -0,0 +1,5 @@ +--- +"fetchify": major +--- + +**BREAKING**: `createFetchify` client methods (`get`/`post`/`put`/`patch`/`delete`/`head`/`options`) now resolve to a `FetchifyResponse` object (`{ data: T | null; response: Response }`) instead of the raw `fetch` `Response`. `data` holds the parsed response body — JSON-parsed when possible, falling back to raw text, and `null` when the response wasn't `ok` — and each method accepts an optional generic (e.g. `client.get("/user")`) to type `data`. `response` is a clone of the original `fetch` `Response`, left unread for callers who need `.json()`/`.blob()`/`status`/`headers` directly. Call sites using the old `const response = await client.get(...)` pattern need to switch to `const { data, response } = await client.get(...)`. diff --git a/DOCS.md b/DOCS.md index 86fc966..9e84582 100644 --- a/DOCS.md +++ b/DOCS.md @@ -39,14 +39,17 @@ interface Fetchify { } ``` -Each method has the [`FetchifyMethod`](#fetchifymethod) signature: it takes a path and an optional `RequestInit`, and returns a `Promise`. +Each method has the [`FetchifyMethod`](#fetchifymethod) signature: it takes a path and an optional `RequestInit`, plus an optional type parameter `T`, and returns a `Promise<`[`FetchifyResponse`](#fetchifyresponset)`>`. If `options.baseUrl` is set, request paths are resolved against it; otherwise the path is passed straight through to `fetch`. ```ts const client = createFetchify({ baseUrl: "https://api.example.com" }); -const res = await client.get("/users/1"); +const { data, response } = await client.get("/users/1"); +// data: User | null — JSON-parsed body, or null if the response wasn't ok +// response: Response — unread clone of the raw fetch Response + const created = await client.post("/users", { body: JSON.stringify({ name: "Ada" }), }); @@ -82,10 +85,37 @@ Configuration accepted by `createFetchify` and `defineOptions`. ### `FetchifyMethod` ```ts -type FetchifyMethod = (path: string, init?: RequestInit) => Promise; +type FetchifyMethod = ( + path: string, + init?: RequestInit, +) => Promise>; +``` + +Signature of each HTTP verb method on a `Fetchify` client. The type parameter `T` types the resolved [`FetchifyResponse`](#fetchifyresponset)'s `data` field and defaults to `string`. + +### `FetchifyResponse` + +```ts +interface FetchifyResponse { + data: T | null; + response: Response; +} ``` -Signature of each HTTP verb method on a `Fetchify` client. +Resolved value of every `Fetchify` client method. + +- `data` — the response body, only populated when the underlying response was `ok`. The body is read as text and `JSON.parse`d: the parsed value is used if that succeeds, otherwise the raw text is used. `null` when the response wasn't `ok`, or the body couldn't be read. +- `response` — a clone of the original `fetch` `Response`, taken before `data` is read, so its body is still unread. Use it for `.json()`, `.blob()`, `status`, `headers`, `ok`, etc. + +```ts +const { data, response } = await client.get("/users/1"); + +if (!response.ok) { + console.error(response.status); +} else { + console.log(data); // User | null +} +``` ## `VERSION` diff --git a/openspec/changes/custom-response-object/.openspec.yaml b/openspec/changes/custom-response-object/.openspec.yaml new file mode 100644 index 0000000..d658936 --- /dev/null +++ b/openspec/changes/custom-response-object/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-02 diff --git a/openspec/changes/custom-response-object/design.md b/openspec/changes/custom-response-object/design.md new file mode 100644 index 0000000..4f1d8bf --- /dev/null +++ b/openspec/changes/custom-response-object/design.md @@ -0,0 +1,41 @@ +## Context + +`createFetchify`'s client methods currently resolve straight to the `Response` produced by the underlying `fetch` call (`src/lib/create-fetchify.ts`, `src/types/fetchify-method.ts`). Callers must check `response.ok`, read the body themselves, and cast the parsed result to whatever shape they expect — and since a `Response` body stream can only be consumed once, anyone who wants both a parsed body and access to the raw response has to remember to `.clone()` it before reading. This change moves that bookkeeping into `createFetchify` itself by wrapping every method's result in a small, generic `FetchifyResponse` object. + +## Goals / Non-Goals + +**Goals:** +- Give callers the response body already parsed, for the common case of a successful (`ok`) response, without a separate `.text()`/`.json()` call. +- Let callers declare the expected body shape per call (`client.get("/user")`) and get that type back on `data`, instead of hand-writing a cast at every call site. +- Preserve full access to the original `Response` (status, headers, `ok`, and its own body-reading methods) via an unread clone, so nothing the caller could do with a raw `fetch` result is lost. +- Apply uniformly to every HTTP method on the client (`get`/`post`/`put`/`patch`/`delete`/`head`/`options`). + +**Non-Goals:** +- No runtime validation/parsing of `data` against `T` — the generic is a compile-time assertion the caller opts into (same trust model as most typed HTTP clients); `createFetchify` does not check that the JSON it parsed actually matches the shape `T` describes. +- No content-type sniffing — the parse strategy (try JSON, fall back to text) is fixed and does not branch on response headers. +- No throwing on non-`ok` responses — a non-`ok` response still resolves normally, just with `data: null`; callers inspect `response.ok`/`response.status` themselves. +- No retries, timeouts, or interceptors — out of scope, unchanged from the existing `http-client` capability's non-goals. + +## Decisions + +- **`FetchifyResponse` shape: `{ data: T | null; response: Response }`** — two fields cover both "give me the body" and "let me use the real Response" without collapsing them into one lossy value. Defaulting `T` to `string` keeps today's no-generic call sites (`client.get("/user")`) behaving the same as before this change for plain-text endpoints, and type-checking without any changes. + - Alternative considered: default `T` to `unknown`, forcing every caller to either specify a generic or narrow `data` before use — rejected as a bigger ergonomics regression for existing/simple callers than the value it adds; `string` matches the original (pre-generic) behavior. +- **Parse strategy: read the body as text, then attempt `JSON.parse`; use the parsed value if it succeeds, otherwise fall back to the raw text** — this is what makes `client.get("/user")` actually yield an object on `data` at runtime (not just a type-level cast of a string), while still supporting plain-text endpoints (`data` stays a string when the body isn't JSON) without any opt-in flag. + - Alternative considered: always call `response.json()` directly (throw/produce `null` on non-JSON bodies) — rejected because it would break the plain-text case described in the original ask (`data` as parsed text) for any endpoint that isn't JSON. + - Alternative considered: branch on the `Content-Type` header to decide JSON vs. text — rejected as an extra layer of "cleverness"/surface area (mismatched or missing content-types, charsets) for a "simple wrapper" package; attempt-JSON-then-fall-back-to-text is simpler and doesn't depend on servers setting correct headers. + - Note: because TypeScript generics are erased at runtime, `T` cannot itself decide whether to parse JSON or return text — the runtime behavior is the same regardless of what `T` a caller passes; `T` only changes the static type of the result. This is called out explicitly as a Non-Goal above (no runtime validation against `T`) so it isn't mistaken for a stronger guarantee. +- **`data` is `T | null`, populated only when `response.ok`** — a non-`ok` response (4xx/5xx) leaves `data: null` rather than attempting to parse an error body, so a failed request never crashes the caller's `await`; they inspect `response` to see what happened. A body-read/parse failure (rare — e.g. an aborted stream) also yields `data: null` rather than throwing. + - Alternative considered: populate `data` regardless of `ok`, or throw on non-`ok` — rejected: "when response is ok" was an explicit condition on `data`, and swallowing read/parse errors into `null` (vs. throwing) keeps `createFetchify` from introducing a new failure mode a caller must catch. +- **`response` is `raw.clone()`, taken before `data` is read** — cloning first guarantees the clone's body stream is completely untouched, regardless of whether the text/JSON read on the original succeeds, fails, or is skipped (non-`ok` case). The caller's `response` is always safe to read from scratch. + - Alternative considered: clone only when `ok` is true (skip cloning, and skip the body read, for non-`ok` responses) — rejected because `response` must behave the same way (a fresh, unread `Response`) regardless of `data`; making `response`'s "freshness" conditional on `ok` would be a surprising, hard-to-discover special case. +- **`FetchifyMethod` becomes a generic function type, `(path, init?) => Promise>`, and each client method (`get`/`post`/etc.) forwards its own call-site type parameter** — matches the requested call pattern `client.get("/user")` directly; the generic lives on the method call, not on `createFetchify` itself, since the expected shape varies per endpoint/call, not per client instance. + - Alternative considered: put the generic on `createFetchify(...)` so the whole client is typed for one shape — rejected, a single client is used for many endpoints with different body shapes; per-call generics are the only sensible granularity. +- **Types live in `src/types/fetchify-response.ts` and `src/types/fetchify-method.ts`** — keeps `src/types/` as the home for shared structural types, mirroring the existing split between the function-shape type (`FetchifyMethod`) and the data-shape type (`FetchifyResponse`). +- **Breaking change, not additive** — `FetchifyMethod` changes in place (`Promise` → `Promise>`) rather than adding a second parallel method or an options flag to opt in. `createFetchify` is a "simple wrapper" per the original `http-client` design; keeping two return shapes around long-term would double the surface area for no lasting benefit. Ships as a `major` changeset per the project's semver conventions. + +## Risks / Trade-offs + +- [Every call site that destructures/uses the old `Response` return value now breaks at compile time] → Intentional and documented as a breaking change; TypeScript will surface every call site that needs updating (e.g. `const res = await client.get(...)` now needs `const { data, response } = await client.get(...)`), and the `major` changeset communicates it to consumers. +- [`client.get(...)` doesn't actually verify the response matches `User`] → Accepted, documented as a Non-Goal; this matches the trust model of comparable typed HTTP clients (the generic is caller-asserted, not runtime-checked). Callers who need real validation layer their own schema check on top of `data`. +- [Attempt-JSON-then-fall-back-to-text means a response that happens to be valid JSON but semantically plain text (e.g. a body that's literally `"123"` or `"true"`) parses to a number/boolean instead of staying a string] → Accepted as inherent to any content-sniffing parse strategy without a content-type check; documented so it isn't a surprise, and callers with such endpoints can read `response.text()` themselves instead of relying on `data`. +- [Cloning on every request has a small overhead even when the caller never touches `response`] → Acceptable; `Response.clone()` is cheap (no body copy occurs until the clone's body is actually read) and this is the only way to guarantee `response` is always safe to consume. diff --git a/openspec/changes/custom-response-object/proposal.md b/openspec/changes/custom-response-object/proposal.md new file mode 100644 index 0000000..2328379 --- /dev/null +++ b/openspec/changes/custom-response-object/proposal.md @@ -0,0 +1,24 @@ +## Why + +`createFetchify`'s HTTP methods currently resolve to the raw `fetch` `Response`. Every caller has to repeat the same boilerplate: check `response.ok`, then call `.text()`/`.json()` to get at the body — and if they also want to inspect the raw response (status, headers, or to parse the body a second way), they have to remember to `.clone()` it first, since a `Response` body can only be read once. There's also no way to tell TypeScript what shape a given endpoint's body has; every caller ends up writing their own `as User` cast. A small wrapper object that hands back the parsed body alongside an unread clone of the original response, with an optional generic for the body's shape, removes that repetitive, easy-to-get-wrong setup from every call site. + +## What Changes + +- Add a `FetchifyResponse` type: `{ data: T | null; response: Response }`. +- `data` holds the response body, but only when the underlying response was `ok`; otherwise `data` is `null`. The body is read as text and then `JSON.parse`d: if parsing succeeds, `data` is the parsed value; if the body isn't valid JSON, `data` falls back to the raw text. Either way `data`'s static type is `T`, so a caller who knows an endpoint returns JSON can pass a generic (e.g. `client.get("/user")`) and get a typed `data` back instead of casting it themselves. +- `response` holds a clone of the original `fetch` `Response`, taken before the body is consumed for `data`, so the caller still has an unread `Response` to call `.json()`/`.blob()`/`.text()` on, or to inspect `status`/`headers`/`ok`. +- Every `Fetchify` client method (`get`/`post`/`put`/`patch`/`delete`/`head`/`options`) becomes generic — `get(path, init?): Promise>` — and resolves to `FetchifyResponse` instead of the raw `Response`. This is a breaking change to `FetchifyMethod`'s signature and return type. + +## Capabilities + +### Modified Capabilities +- `http-client`: `FetchifyMethod` becomes generic and its return type changes from `Promise` to `Promise>`; the HTTP method wrappers now parse and return the response body (JSON, falling back to text) instead of returning the raw `Response` unmodified. + +## Impact + +- `src/types/fetchify-response.ts`: new module defining `FetchifyResponse`. +- `src/types/fetchify-method.ts`: `FetchifyMethod` becomes generic (``), returning `Promise>`. +- `src/lib/create-fetchify.ts`: the internal `request` helper clones the raw response, conditionally reads `data` (JSON with a text fallback), and returns `{ data, response }`; each client method (`get`/`post`/etc.) forwards its own type parameter through to `request`. +- `src/index.ts` / `src/native.ts`: re-export `FetchifyResponse` alongside the existing exports. +- Existing tests that assert on a raw `Response` return value are updated to assert on `{ data, response }`, including a case exercising the generic (e.g. `client.get(...)`). +- Breaking change — requires a `major` changeset. diff --git a/openspec/changes/custom-response-object/specs/http-client/spec.md b/openspec/changes/custom-response-object/specs/http-client/spec.md new file mode 100644 index 0000000..844b4cd --- /dev/null +++ b/openspec/changes/custom-response-object/specs/http-client/spec.md @@ -0,0 +1,42 @@ +## MODIFIED Requirements + +### Requirement: HTTP method wrappers +Each of `get`, `post`, `put`, `patch`, `delete`, `head`, and `options` SHALL issue a `fetch` request using its corresponding uppercase HTTP method (e.g. `get` issues a request with method `GET`), forwarding any caller-supplied request-init options (headers, body, signal, etc.) other than `method`, and SHALL be generic over an optional type parameter `T` (defaulting to `string`), resolving to a `FetchifyResponse` object: `{ data: T | null; response: Response }`. + +`data` SHALL be populated only when the underlying `Response`'s `ok` is `true`: the body SHALL be read as text and `JSON.parse`d; if parsing succeeds, `data` SHALL be the parsed value, and if the body is not valid JSON, `data` SHALL be the raw text. When the underlying response is not `ok`, or the body cannot be read, `data` SHALL be `null`. `response` SHALL be a clone of the original `fetch` `Response`, taken before any body is read for `data`, so it remains fully unread and usable by the caller (`.json()`, `.blob()`, `.text()`, `status`, `headers`, `ok`, etc.). + +#### Scenario: Method sets the correct HTTP verb +- **WHEN** a client calls `client.post("/users", { body: JSON.stringify({ name: "a" }) })` +- **THEN** the underlying request is sent with HTTP method `POST` and the given body + +#### Scenario: Caller-supplied init.method is overridden +- **WHEN** a client calls `client.get("/users", { method: "POST" })` +- **THEN** the underlying request is still sent with HTTP method `GET` + +#### Scenario: ok JSON response yields parsed data +- **WHEN** any HTTP method wrapper resolves, the underlying response's `ok` is `true`, and the body is valid JSON +- **THEN** the resolved value's `data` is the parsed JSON value + +#### Scenario: ok non-JSON response yields text data +- **WHEN** any HTTP method wrapper resolves, the underlying response's `ok` is `true`, and the body is not valid JSON +- **THEN** the resolved value's `data` is the response body read as text + +#### Scenario: Non-ok response yields null data +- **WHEN** any HTTP method wrapper resolves and the underlying response's `ok` is `false` +- **THEN** the resolved value's `data` is `null` + +#### Scenario: Body read failure yields null data +- **WHEN** the underlying response's `ok` is `true` but reading its body fails +- **THEN** the resolved value's `data` is `null` + +#### Scenario: response is an unread clone of the original Response +- **WHEN** any HTTP method wrapper resolves +- **THEN** the resolved value's `response` is a clone of the `Response` produced by the underlying `fetch` call, with its body not yet consumed, so the caller can independently call `.text()`, `.json()`, or read `status`/`headers`/`ok` on it + +#### Scenario: Caller specifies a type parameter +- **WHEN** a consumer calls `client.get("/user")` +- **THEN** the resolved value's `data` has static type `User | null`, and at runtime holds the JSON-parsed response body + +#### Scenario: Caller omits the type parameter +- **WHEN** a consumer calls `client.get("/user")` without a type parameter +- **THEN** the resolved value's `data` has static type `string | null`, matching the default parse-then-fallback behavior diff --git a/openspec/changes/custom-response-object/tasks.md b/openspec/changes/custom-response-object/tasks.md new file mode 100644 index 0000000..e93f587 --- /dev/null +++ b/openspec/changes/custom-response-object/tasks.md @@ -0,0 +1,17 @@ +## 1. Implement FetchifyResponse + +- [x] 1.1 Define `FetchifyResponse` (`{ data: T | null; response: Response }`) in `src/types/fetchify-response.ts`. +- [x] 1.2 Update `FetchifyMethod` in `src/types/fetchify-method.ts` to a generic function type `(path: string, init?: RequestInit) => Promise>`. +- [x] 1.3 Update the internal `request` helper in `src/lib/create-fetchify.ts` to: call `fetch` as today, clone the raw response into `response` before reading any body, and — only when the raw response's `ok` is `true` — read the body as text, attempt `JSON.parse` on it, and use the parsed value as `data` if it succeeds or the raw text if it doesn't; fall back to `data: null` on a non-`ok` response or a read failure. Make `request` (and the returned `data`) generic over `T`. +- [x] 1.4 Update the `HTTP_METHODS`-driven client build in `create-fetchify.ts` so each of `get`/`post`/`put`/`patch`/`delete`/`head`/`options` forwards its own call-site type parameter (``) through to `request`, matching the `FetchifyMethod` signature. +- [x] 1.5 Export `FetchifyResponse` from `src/index.ts` and `src/native.ts` alongside the existing exports. + +## 2. Tests + +- [x] 2.1 Update `src/lib/create-fetchify.test.ts` to assert the new `{ data, response }` shape: an `ok` JSON response yields the parsed object in `data`; an `ok` non-JSON response yields the raw text in `data`; a non-`ok` response yields `data: null`; `response` remains an independent, unread `Response` in every case (still readable via `.text()`/`.json()`/`status`/`headers`); each HTTP method still sends the correct verb. +- [x] 2.2 Add a test exercising the generic call pattern (e.g. `client.get("/user")`) confirming `data` is typed and populated as the parsed `User` object. +- [x] 2.3 Run `bun test` and confirm all tests pass. + +## 3. Release + +- [x] 3.1 Run `bun run changeset` to record a `major` changeset describing the breaking change to `FetchifyMethod`'s signature and return type. diff --git a/src/index.ts b/src/index.ts index a39e043..623db3a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,3 +5,4 @@ export type { Fetchify } from "./lib/create-fetchify"; export { defineOptions } from "./lib/define-options"; export type { FetchifyOptions } from "./lib/define-options"; export type { FetchifyMethod } from "./types/fetchify-method"; +export type { FetchifyResponse } from "./types/fetchify-response"; diff --git a/src/lib/create-fetchify.test.ts b/src/lib/create-fetchify.test.ts index 42260d8..ad8ae01 100644 --- a/src/lib/create-fetchify.test.ts +++ b/src/lib/create-fetchify.test.ts @@ -88,12 +88,53 @@ test("caller-supplied init.method is overridden by the client method", async () expect(init.method).toBe("GET"); }); -test("returns the fetch Response unmodified", async () => { +test("response is an unread clone of the fetch Response", async () => { const client = createFetchify({ baseUrl: "https://api.example.com" }); - const response = await client.get("/users"); + const { response } = await client.get("/users"); expect(response.status).toBe(204); + expect(response.bodyUsed).toBe(false); +}); + +test("ok JSON response yields parsed data", async () => { + fetchMock = mock(() => + Promise.resolve( + new Response(JSON.stringify({ id: 1, name: "Ada" }), { status: 200 }), + ), + ); + globalThis.fetch = fetchMock as unknown as typeof fetch; + const client = createFetchify({ baseUrl: "https://api.example.com" }); + + const { data } = await client.get<{ id: number; name: string }>("/user"); + + expect(data).toEqual({ id: 1, name: "Ada" }); +}); + +test("ok non-JSON response yields text data", async () => { + fetchMock = mock(() => + Promise.resolve(new Response("hello world", { status: 200 })), + ); + globalThis.fetch = fetchMock as unknown as typeof fetch; + const client = createFetchify({ baseUrl: "https://api.example.com" }); + + const { data } = await client.get("/message"); + + expect(data).toBe("hello world"); +}); + +test("non-ok response yields null data", async () => { + fetchMock = mock(() => + Promise.resolve(new Response("not found", { status: 404 })), + ); + globalThis.fetch = fetchMock as unknown as typeof fetch; + const client = createFetchify({ baseUrl: "https://api.example.com" }); + + const { data, response } = await client.get("/missing"); + + expect(data).toBeNull(); + expect(response.status).toBe(404); + expect(response.bodyUsed).toBe(false); }); test("accepts options built with defineOptions", async () => { diff --git a/src/lib/create-fetchify.ts b/src/lib/create-fetchify.ts index 2e12eab..357830b 100644 --- a/src/lib/create-fetchify.ts +++ b/src/lib/create-fetchify.ts @@ -1,5 +1,6 @@ import type { FetchifyOptions } from "./define-options"; import type { FetchifyMethod } from "../types/fetchify-method"; +import type { FetchifyResponse } from "../types/fetchify-response"; export interface Fetchify { get: FetchifyMethod; @@ -25,18 +26,38 @@ export function createFetchify(options: FetchifyOptions = {}): Fetchify { const baseUrl = options.baseUrl === undefined ? undefined : new URL(options.baseUrl); - const request = ( + const request = async ( verb: (typeof HTTP_METHODS)[number]["verb"], path: string, init?: RequestInit, - ): Promise => { + ): Promise> => { const url = baseUrl ? new URL(path, baseUrl) : path; - return fetch(url, { ...init, method: verb }); + const raw = await fetch(url, { ...init, method: verb }); + // bun-types' ambient `Response.clone()` resolves to undici's Response + // type (missing Bun's Headers extras) when the "dom" lib isn't loaded, + // even though the value at runtime is a normal Bun Response. + const response = raw.clone() as Response; + + let data: T | null = null; + if (raw.ok) { + try { + const text = await raw.text(); + try { + data = JSON.parse(text) as T; + } catch { + data = text as unknown as T; + } + } catch { + data = null; + } + } + + return { data, response }; }; return HTTP_METHODS.reduce((client, { key, verb }) => { - client[key] = (path: string, init?: RequestInit) => - request(verb, path, init); + client[key] = ((path: string, init?: RequestInit) => + request(verb, path, init)) as FetchifyMethod; return client; }, {} as Fetchify); } diff --git a/src/native.ts b/src/native.ts index a39e043..623db3a 100644 --- a/src/native.ts +++ b/src/native.ts @@ -5,3 +5,4 @@ export type { Fetchify } from "./lib/create-fetchify"; export { defineOptions } from "./lib/define-options"; export type { FetchifyOptions } from "./lib/define-options"; export type { FetchifyMethod } from "./types/fetchify-method"; +export type { FetchifyResponse } from "./types/fetchify-response"; diff --git a/src/types/fetchify-method.ts b/src/types/fetchify-method.ts index 4896a4b..eb97eaf 100644 --- a/src/types/fetchify-method.ts +++ b/src/types/fetchify-method.ts @@ -1,4 +1,6 @@ -export type FetchifyMethod = ( +import type { FetchifyResponse } from "./fetchify-response"; + +export type FetchifyMethod = ( path: string, init?: RequestInit, -) => Promise; +) => Promise>; diff --git a/src/types/fetchify-response.ts b/src/types/fetchify-response.ts new file mode 100644 index 0000000..6baf74e --- /dev/null +++ b/src/types/fetchify-response.ts @@ -0,0 +1,4 @@ +export interface FetchifyResponse { + data: T | null; + response: Response; +}