@r0hitsharma/http-client-react 0.12.0-rohit-fork-ci.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/DESIGN.md ADDED
@@ -0,0 +1,378 @@
1
+ # http-client-react — design contract
2
+
3
+ `@r0hitsharma/http-client-react` binds TanStack Query to an `openapi-fetch`
4
+ client. This document is the authoritative statement of what the layer
5
+ guarantees, what it deliberately refuses to do, and why.
6
+
7
+ ## Premise: the generated `paths` type is the endpoint definition
8
+
9
+ There is exactly one description of the API in a consuming app: the
10
+ `openapi-typescript` output. `createQueryApi` reads everything off it — which
11
+ methods exist on which paths, what params and body each takes, what its 2xx
12
+ response is, what its error responses are. Nothing is restated in a hand-written
13
+ endpoint registry, a key factory, or a hooks file, because every restatement is
14
+ a thing that can drift from the spec while still compiling.
15
+
16
+ The consequence worth naming: **a mock layer belongs on the same premise.** The
17
+ sibling `http-client-msw` package derives msw request handlers from the same
18
+ `paths` type, so a fixture that no longer matches the API fails to typecheck
19
+ rather than silently passing a test.
20
+
21
+ ## Verdict on `openapi-react-query`
22
+
23
+ We considered depending on `openapi-react-query` (the openapi-ts ecosystem's own
24
+ react-query binding) and **hand-rolled instead**, reusing its type *shape* as
25
+ prior art.
26
+
27
+ `openapi-react-query` implements exactly two things: a query key of
28
+ `[method, path, init]`, and a `queryFn` that calls the client and re-throws
29
+ `error`. Every v1 requirement here replaces one of them:
30
+
31
+ 1. **The key must be canonical.** Its key holds the caller's `init` verbatim, so
32
+ `{ limit, search }` and `{ search, limit }` are different keys for partial
33
+ matching, and a `signal` or a `headers` object in the init lands in the key.
34
+ We cannot fix that by overriding `queryKey`, because its `queryFn` reads the
35
+ init back *out of the key* — a sanitized key would change the request.
36
+ 2. **Failures must carry status and the parsed body.** It rethrows the bare error
37
+ body, which loses the status code. Its `queryFn` is internal, so this is not
38
+ overridable.
39
+ 3. **Middleware over the parsed result.** It has none, and `openapi-fetch`'s own
40
+ `use()` middleware works on `Request`/`Response`, which is the wrong altitude
41
+ for response validation.
42
+ 4. **Tags and invalidation.** It has none.
43
+
44
+ So the dependency would have bought only the generic signature types — and those
45
+ we can get from `openapi-fetch`'s own exported helpers (`MaybeOptionalInit`,
46
+ `FetchResponse`) plus `openapi-typescript-helpers`, both already in the tree.
47
+ Against that, it adds a package whose react-query and `openapi-fetch` peer
48
+ ranges have to stay compatible with our exact pins, for code we override
49
+ entirely. The hand-rolled version is ~250 lines of types and ~120 of runtime.
50
+
51
+ What we *did* borrow, deliberately, is the generic signature shape:
52
+ `<TMethod, TPath, TInit, TResponse, TOptions>` with the conditional
53
+ `...[init, options]` tuple, the self-referential `TOptions` constraint that makes
54
+ `select` infer, and `NoInfer` on the return type. That shape is well-tested
55
+ upstream and reinventing it would have been strictly worse.
56
+
57
+ One dependency was added: `openapi-typescript-helpers@0.1.0`, a types-only
58
+ package that `openapi-fetch` already depends on, re-exported from
59
+ http-client-core so both packages type against one pinned copy of the OpenAPI
60
+ helper types.
61
+
62
+ ## Query key contract
63
+
64
+ A key is always exactly three elements:
65
+
66
+ ```
67
+ [method, path, sanitizeQueryInit(init)]
68
+ ```
69
+
70
+ - `method` is the lowercase OpenAPI method; `path` is the path *template*, braces
71
+ intact. Together they are the operation, so `['get', '/users']` prefix-matches
72
+ every cached variant of that endpoint.
73
+ - The third element is the **identity-bearing** part of the init only: path
74
+ params, query params, and the body, with object keys sorted at every depth and
75
+ `undefined`-valued properties removed. Arrays keep their order.
76
+ - `undefined`, `{}`, `{ params: {} }`, and
77
+ `{ params: { query: { after: undefined } } }` all sanitize to `{}` and share a
78
+ cache entry.
79
+ - Per-call transport concerns — `signal`, `fetch`, `headers`, `baseUrl`,
80
+ `parseAs`, the serializers — are **excluded**. They are either
81
+ non-serializable or, in the case of headers, credentials that have no business
82
+ being visible in a cache key or in devtools.
83
+
84
+ react-query's own `hashKey` already sorts plain-object keys, so canonicalizing is
85
+ not what makes two orderings hit the same entry. What it buys is everything that
86
+ compares keys *structurally*: partial `invalidateQueries` matching
87
+ (`partialDeepEqual` is order- and `undefined`-sensitive), `exact` filters, and a
88
+ key you can read in devtools.
89
+
90
+ **Consequence for header-varying responses.** Because headers are not part of
91
+ identity, two requests that differ only by header share a cache entry. If a
92
+ response genuinely varies by header (a tenant selector, say), spread the options
93
+ and override `queryKey` yourself — the `queryFn` closes over the init rather
94
+ than reading it out of the key, so a custom key still issues the right request.
95
+
96
+ ## Error contract
97
+
98
+ `openapi-fetch` resolves both outcomes into `{ data, error }`; react-query only
99
+ treats a rejection as failure. The `queryFn` converts:
100
+
101
+ - Non-2xx (or any `error`) rejects with `HttpRequestError`, carrying `status`,
102
+ `statusText`, the parsed `body` typed from the operation's error responses, the
103
+ `method`/`path`, and the raw `Response`. `body` is `TBody | undefined`, not
104
+ `TBody`: a 5xx from something in front of the API, or any non-2xx with
105
+ `Content-Length: 0`, leaves `openapi-fetch` nothing to parse. Narrowing with
106
+ `isHttpRequestError` gets you `status` for free but still not a body.
107
+ - A 204, or a response with `Content-Length: 0`, resolves to `null` — react-query
108
+ rejects `undefined` as query data.
109
+ - A `HEAD` resolves to `null` unconditionally. A HEAD response has no body by
110
+ definition, so `openapi-fetch` parses none; its `Content-Length` echoes the
111
+ size the matching `GET` would have returned, so neither check above catches it.
112
+
113
+ The declared error type is `HttpRequestError<TErrorBody> | Error`, not
114
+ `HttpRequestError` alone. That is honest rather than convenient: a transport
115
+ failure or a middleware (response validation, for instance) rejects with
116
+ something that is not an HTTP error, so `status` is reachable only after
117
+ `isHttpRequestError(error)`. The guard matches on `name` rather than `instanceof`
118
+ so it survives a consumer ending up with two copies of the package.
119
+
120
+ `createZodResponseMiddleware`'s rejection is the one of those with a guard of its
121
+ own, `isZodResponseValidationError`, because the retry policy has to tell it from
122
+ a transport failure — it is the only statusless rejection this package raises
123
+ after the request completed.
124
+
125
+ The guard takes **no body type parameter**. A check on `name` cannot say
126
+ anything about the body's shape, so a parameter would only have let a call site
127
+ name a type and get it back unchecked. It derives the body type from what the
128
+ argument already declares instead: narrowing a `QueryApiError<TErrorBody>` keeps
129
+ that operation's error body, and narrowing a `catch` binding keeps `unknown`.
130
+
131
+ ## Middleware contract
132
+
133
+ `(ctx, next) => Promise<unknown>`, onion order: the instance chain outermost in
134
+ declared order, a per-call chain appended innermost. `ctx` carries `method`,
135
+ `path`, `operationType` (`'query' | 'mutation'`), and `init` — the init as it
136
+ will be handed to `openapi-fetch`, including react-query's abort signal.
137
+
138
+ Middleware sees the **parsed result**, not `Request`/`Response`. That is the
139
+ altitude response validation, timing, and result shimming want. Anything that
140
+ needs the raw HTTP objects belongs in `openapi-fetch`'s own `client.use()`.
141
+
142
+ `next()` with no argument passes the context through; `next(ctx)` rewrites it for
143
+ everything downstream. Not calling `next` at all short-circuits the chain with a
144
+ substitute result. Calling it twice rejects rather than issuing the request
145
+ twice — which also means **a middleware cannot retry**: it has no way to re-issue
146
+ the request it is wrapping. Whole-operation retry is react-query's `retry`/
147
+ `retryDelay`, which re-enter the chain from the top; transport-level retry
148
+ belongs in the `fetch` handed to `createApiClient`, which owns the HTTP call.
149
+
150
+ The one shipped middleware, `createZodResponseMiddleware`, validates bodies
151
+ against the OpenAPI document through http-client-core's
152
+ `getComponentSchemaFromOpenApi`, so the runtime schema and the static types come
153
+ from the same spec. It **passes the body through unmodified** — it never
154
+ substitutes zod's parse output, so the runtime value always matches the
155
+ statically inferred one and no coercion happens behind the caller's back.
156
+ Compiled schemas are memoized per middleware instance, because
157
+ `z.fromJSONSchema` is far too expensive per request.
158
+
159
+ ## Tag contract
160
+
161
+ Tags are declared in two places and nowhere else:
162
+
163
+ - the vocabulary, on `createQueryApi(client, { tags })` — which both infers
164
+ `TTag` (so `tags` and `invalidates` are checked against a closed set) and makes
165
+ an unknown tag throw at runtime, catching typos from untyped call sites;
166
+ - membership, on the `queryOptions` call that belongs to the tag.
167
+
168
+ Membership is recorded where the query is described, so the query stays the
169
+ single place its cache behaviour lives. On mutation success, tags resolved from
170
+ `invalidates` are invalidated through the `QueryClient` react-query passes to the
171
+ mutation callback — no `useQueryClient` at the call site, no `QueryClient`
172
+ threaded into the api instance.
173
+
174
+ **An unknown tag fails where it is written, not where it is used.** A literal in
175
+ `tags` or `invalidates` is checked when the options are built — before anything
176
+ has run — so it throws, which is what catches a typo from an untyped call site.
177
+ A tag an `invalidates` callback derives from the mutation's result can only be
178
+ checked after the mutation has already succeeded, and throwing there would report
179
+ the succeeded mutation as failed and skip the caller's own `onSuccess`. So an
180
+ unknown derived tag is dropped, with one `console.warn` per distinct tag per api
181
+ instance in a non-production build. The mutation's outcome never depends on the
182
+ tag vocabulary.
183
+
184
+ **Granularity is per `${method} ${path}`, not per key.** Invalidating a tag
185
+ invalidates every cached variant of the endpoints under it, whatever their
186
+ params. That is what "refetch the user list" almost always means, and it keeps
187
+ the registry bounded by endpoint count rather than by cache size. The corollary:
188
+ two queries on one endpoint with different tags cannot be invalidated
189
+ independently — use the key directly for that.
190
+
191
+ ### The registry's boundary
192
+
193
+ A tag matches only the endpoints some `queryOptions` call has registered on this
194
+ api instance, in this session. Registration happens as a side effect of building
195
+ the options, so the registry is empty until the first call that declares a tag —
196
+ and it is genuinely possible to have a cache entry that no `queryOptions` call
197
+ ever described:
198
+
199
+ - `queryClient.setQueryData(api.queryKey('get', '/positions'), …)` — a key
200
+ without options, written directly;
201
+ - `prefetchQuery`/`fetchQuery` against `api.queryKey(…)` rather than against
202
+ `api.queryOptions(…)` — which drops more than the tag, and is written up in
203
+ full in [the README](./README.md#prefetching-pass-queryoptions-never-querykey);
204
+ - SSR or persisted-cache **hydration**, which restores entries before any render
205
+ has run.
206
+
207
+ Those entries are cached under a `[method, path, …]` key like any other, but the
208
+ tag does not know the endpoint, so `invalidateTags` and `tagFilter` skip them
209
+ silently. This is a real hole, not a theoretical one: the invalidation looks
210
+ correct and simply does nothing.
211
+
212
+ Two ways out, both explicit:
213
+
214
+ 1. **Register the endpoint once, at module scope.** Building the options is what
215
+ registers, so the result can be discarded — the call is the registration.
216
+
217
+ ```ts
218
+ // Registers `get /positions` under `positions` at import time, so a later
219
+ // hydrated or hand-written entry for it is in scope for the tag.
220
+ void api.queryOptions('get', '/positions', undefined, { tags: ['positions'] });
221
+ ```
222
+
223
+ 2. **Invalidate by key instead of by tag.** The first two elements of a key are
224
+ the operation and react-query prefix-matches, so
225
+ `invalidateQueries({ queryKey: ['get', '/positions'] })` reaches every cached
226
+ variant whether or not the registry has heard of it.
227
+
228
+ The registry is deliberately not fixed by scanning the cache for `[method, path]`
229
+ pairs: that would make a tag's membership depend on what happens to be cached at
230
+ the moment of invalidation, which is a worse contract than one that is empty
231
+ until declared.
232
+
233
+ ## QueryClient defaults
234
+
235
+ `createQueryClient` exists because react-query's defaults are tuned for a
236
+ document-shaped app and this package's consumers are not building one. Two are
237
+ changed; the deliberate part is how few.
238
+
239
+ **`refetchOnWindowFocus: false`.** The default is `true`, which means returning
240
+ to a tab reissues every active query. On a dashboard that is a visible reload of
241
+ panels the user was reading, triggered by an action that expressed no intent to
242
+ reload. Freshness belongs to `staleTime` and to explicit invalidation, both of
243
+ which the app already controls.
244
+
245
+ The consequence is that **the app owns error recovery**: a mounted query that has
246
+ exhausted its retries makes no further attempt on its own while the tab stays
247
+ open and the connection stays up, and react-query keeps serving the last
248
+ successful `data` beside `status: 'error'` — so a session that expired under an
249
+ open dashboard reads as current numbers rather than as an empty panel.
250
+ `refetchOnReconnect` and `refetchOnMount` are left at react-query's defaults, so
251
+ a dropped connection returning or a remount still refetches; in between, showing
252
+ the error and offering a refetch is the app's job, not this package's.
253
+
254
+ **A status-aware `retry`.** The default retries every rejection three times. The
255
+ policy here splits on what the status *says*: a 5xx is a well-formed request
256
+ meeting an unwell server, so ask again; 408, 425, and 429 name transient
257
+ conditions — a server-side request timeout, a TLS early-data refusal, rate
258
+ limiting — so ask again; every other 4xx says the request itself is wrong, and
259
+ repeating it unchanged buys nothing but latency. Below 400 is not retried
260
+ either: a 304 on the error path is a caching bug.
261
+
262
+ A rejection carrying no status is retried. That covers a dropped connection, a
263
+ CORS refusal, an abort, and a middleware that threw, and it is the deliberately
264
+ optimistic branch — the alternative, treating an unrecognised rejection as fatal,
265
+ turns one dropped socket into an error state. The cost of being wrong is two
266
+ extra requests; the cost of the other default is a screen that fails on a blip.
267
+
268
+ One statusless rejection is exempt, and it is this package's own.
269
+ `ZodResponseValidationError` has no status because `createZodResponseMiddleware`
270
+ rejects *after* a 2xx arrived and parsed: the request completed, and the body the
271
+ server sent will not have changed by the next attempt. Retrying it costs two more
272
+ round trips to reach the same mismatch three seconds later — precisely the cost
273
+ the status policy above exists to avoid on a 422. `isZodResponseValidationError`
274
+ is what the predicate narrows with, on `name` rather than `instanceof`, for the
275
+ same duplicate-copy reason as `isHttpRequestError`.
276
+
277
+ Two retries rather than react-query's three, so that with the default
278
+ exponential `retryDelay` an error surfaces in about three seconds instead of
279
+ seven. A panel sitting pending over a fault that will not clear is worse than an
280
+ error state that arrives promptly.
281
+
282
+ **Mutations get nothing.** react-query already does not retry them, and it is
283
+ right: a `POST` that failed after reaching the server may have applied, and
284
+ nothing at this layer can distinguish that from one that did not.
285
+
286
+ ### What is not in the defaults
287
+
288
+ **`staleTime`.** How long a screen may show a stale number is a product
289
+ decision. A package-level value would be wrong silently, in whichever direction
290
+ it was wrong.
291
+
292
+ **A `QueryCache.onError` driven by per-query `meta`.** Every consumer wants one,
293
+ which is an argument for shipping it, and the two reasons not to are both
294
+ concrete. The half with the value in it is the sink — the app's toast or logger
295
+ — which this package has no business owning. And typing the `meta` it reads
296
+ requires augmenting react-query's `Register` interface, a global declaration
297
+ that can be made exactly once in a module graph; making it here would collide
298
+ with the app's own augmentation and leave the consumer worse off than if the
299
+ package had stayed out of it. The README carries it as a recipe instead.
300
+
301
+ ### Overriding
302
+
303
+ The retry policy has to be replaceable without giving up the rest, so
304
+ `createQueryClient` takes react-query's own `QueryClientConfig` and merges it one
305
+ option deep: a caller's `defaultOptions.queries.retry` replaces the default of
306
+ that name and leaves `refetchOnWindowFocus` standing. Anything outside
307
+ `defaultOptions` — `queryCache`, `mutationCache` — passes straight through.
308
+
309
+ The `queries` merge drops the caller's explicitly-`undefined` keys first, and
310
+ that step is the difference between a sound override and a silent one. A spread
311
+ cannot tell an absent key from one present with the value `undefined`, and the
312
+ second shape is how conditional config is ordinarily written:
313
+ `{ retry: cond ? false : undefined }`. Spread raw, the else-branch copies
314
+ `retry: undefined` over the predicate, react-query resolves it as `retry ?? 3`,
315
+ and a mounted query asks four times where it should ask once — reinstating the
316
+ retry-everything default this whole section exists to replace. Nothing looks
317
+ wrong from outside, either: `refetchOnWindowFocus: false` survives the same
318
+ spread, so the client still reads as configured. Dropping the key instead is
319
+ lossless, because react-query resolves each of these options with `?? <default>`
320
+ or an `=== undefined` check, so absent and `undefined` already mean the same
321
+ thing to it — there is no option where "present but undefined" says something an
322
+ explicit value could not say more plainly.
323
+
324
+ For the middle case, where the status policy is right but the shape around it is
325
+ not, the policy is exported in three pieces: `shouldRetryRequest` (the
326
+ react-query-shaped predicate, for wrapping), `isRetryableError` (the error
327
+ policy, for changing how many times a request is repeated), and
328
+ `isRetryableHttpStatus` (the status policy alone). A consumer composes rather
329
+ than restates, so a change to the status table reaches them.
330
+
331
+ ## Type-level guarantees
332
+
333
+ `createQueryApi`'s implementation is written against loose types and cast once,
334
+ at the return. `src/query-api.types.test.ts` is what holds the cast and the
335
+ declared surface in agreement — it asserts data/error/param/variable inference
336
+ and `@ts-expect-error`s the calls that must not compile. It is checked by
337
+ `npm run type:check`, not by the vitest run.
338
+
339
+ `TPaths` is constrained by `QueryApiPaths` on `createQueryApi` itself, not only
340
+ inside the option types — the constraint is the same one either side, so there is
341
+ one statement of what a paths type is. Its shape is dictated by the generated
342
+ output: every method **optional**, because `openapi-typescript` emits absent
343
+ operations as `put?: never`, and every operation payload `any`, because
344
+ narrowing it makes `TPaths[TPath][TMethod]` resolve to `<payload> | undefined`,
345
+ which fails `FetchResponse`'s own `Record<string | number, any>` constraint.
346
+ Nothing is lost by the `any`: the types the api reports are read off the
347
+ caller's `TPaths`, never off the constraint.
348
+
349
+ The limit worth knowing: this rejects a bad *explicit* type argument, not a bad
350
+ *client*. Given a client whose paths type is not a paths map, inference finds no
351
+ candidate for `TPaths` and falls back to the constraint, so `createQueryApi`
352
+ returns `QueryApi<QueryApiPaths>` — an api on which no `queryOptions` call
353
+ typechecks. The error surfaces at the calls rather than at the construction.
354
+
355
+ Both `queryKey` and `queryOptions` wrap their return in `NoInfer`. Without it, a
356
+ contextual type on the result — the `queryKey` property of an
357
+ `invalidateQueries` filter, say — becomes an inference site that leaves `TInit`
358
+ unresolved and makes TypeScript demand the optional `init` argument.
359
+
360
+ ## Deliberately out of v1
361
+
362
+ - **Optimistic updates and cache updaters.** No `onMutate` rollback helpers, no
363
+ string-DSL updaters. They need per-endpoint knowledge of how a mutation's
364
+ result maps into a query's shape, which is the one thing the OpenAPI document
365
+ does not describe. Invalidation is correct without that knowledge.
366
+ - **Cross-tab sync.** No `BroadcastChannel`; use react-query's own persistence
367
+ and broadcast plugins if a consumer needs it.
368
+ - **`POST`-backed reads.** `queryOptions` accepts `get` and `head` only. A
369
+ `POST /search` used as a read has to go through `mutationOptions` for now.
370
+ - **Infinite queries and suspense wrappers.** Neither has a consumer yet; both
371
+ are additive.
372
+ - **Header-varying cache identity.** See the query key contract above.
373
+
374
+ ## Deprecated
375
+
376
+ `createQueryOptions(queryKey, queryFn)` — the pre-`createQueryApi` shim that
377
+ takes a hand-written key and function. Kept working for existing call sites,
378
+ marked `@deprecated`, and not to be used in new code.