@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.
@@ -0,0 +1,512 @@
1
+ import type {
2
+ ApiClient,
3
+ FetchResponse,
4
+ HttpMethod,
5
+ MaybeOptionalInit,
6
+ MediaType,
7
+ PathsWithMethod,
8
+ RequiredKeysOf,
9
+ } from '@r0hitsharma/http-client-core';
10
+ import type {
11
+ DataTag,
12
+ QueryClient,
13
+ QueryFilters,
14
+ QueryKey,
15
+ SkipToken,
16
+ UseMutationOptions,
17
+ UseQueryOptions,
18
+ } from '@tanstack/react-query';
19
+
20
+ import { HttpRequestError } from './errors.js';
21
+ import {
22
+ composeMiddleware,
23
+ type QueryApiMiddleware,
24
+ type QueryApiOperationType,
25
+ type QueryApiRequestContext,
26
+ } from './middleware.js';
27
+ import { buildQueryApiKey, type QueryApiKey } from './query-key.js';
28
+ import { createTagRegistry } from './tags.js';
29
+
30
+ /**
31
+ * The shape an `openapi-typescript`-generated `paths` type has: every path maps
32
+ * to an object carrying the HTTP methods that endpoint implements, plus the
33
+ * path-level `parameters`. This is the constraint on `createQueryApi` and on
34
+ * every option type below, and it is what makes `TPaths[TPath][TMethod]` legal.
35
+ *
36
+ * Every method is *optional* and typed `any` for two reasons that come straight
37
+ * from the generated output. `openapi-typescript` emits absent operations as
38
+ * `put?: never`, so a required key rejects real generated paths; and the
39
+ * operation payload has to widen to `any`, because narrowing it makes
40
+ * TypeScript resolve `TPaths[TPath][TMethod]` to `<payload> | undefined`, which
41
+ * then fails `FetchResponse`'s own `Record<string | number, any>` constraint.
42
+ * Nothing is lost by that: the operation types the api actually reports are
43
+ * read back off the caller's own `TPaths`, never off this shape.
44
+ *
45
+ * What it therefore does *not* do is reject a badly typed client. If the paths
46
+ * type behind `client` is not a paths map, inference finds no candidate for
47
+ * `TPaths` and falls back to this constraint, so `createQueryApi` still returns
48
+ * — as `QueryApi<QueryApiPaths>`, on which no `queryOptions` call typechecks.
49
+ * The rejection lands on the calls rather than on the construction.
50
+ */
51
+ export type QueryApiPaths = Record<
52
+ string,
53
+ // Suppressed for this one line, not the file. `no-explicit-any` ships in the
54
+ // opt-in `react-strict` oxlint preset, and this is the site that preset
55
+ // cannot express an exception for: oxlint's rule takes only `fixToUnknown`
56
+ // and `ignoreRestArgs`, neither of which distinguishes a generic constraint
57
+ // from a value annotation. `unknown` here is not a safer spelling of the same
58
+ // type — it breaks the inference described above and the constraint stops
59
+ // doing its job. The trade is recorded here rather than configured away.
60
+ // oxlint-disable-next-line typescript/no-explicit-any
61
+ { [TMethod in HttpMethod]?: any } & { parameters?: any }
62
+ >;
63
+
64
+ /**
65
+ * Methods `queryOptions` accepts: the safe, bodyless, cacheable ones. A
66
+ * POST-backed read (`POST /search`) is deliberately out of v1 — see DESIGN.md.
67
+ */
68
+ export type QueryApiQueryMethod = Extract<HttpMethod, 'get' | 'head'>;
69
+
70
+ /** Methods `mutationOptions` accepts. */
71
+ export type QueryApiMutationMethod = Extract<
72
+ HttpMethod,
73
+ 'post' | 'put' | 'patch' | 'delete'
74
+ >;
75
+
76
+ /**
77
+ * What a failed query or mutation rejects with. A request that reached the
78
+ * server rejects with {@link HttpRequestError} (status plus parsed error body);
79
+ * a transport failure or a middleware — response validation, for instance —
80
+ * rejects with whatever it threw. Narrow with `isHttpRequestError` before
81
+ * reading `status`.
82
+ */
83
+ export type QueryApiError<TErrorBody> = HttpRequestError<TErrorBody> | Error;
84
+
85
+ type InitWithUnknowns<TInit> = TInit & { [key: string]: unknown };
86
+
87
+ type InferSelectReturnType<TData, TSelect> = TSelect extends (
88
+ data: TData,
89
+ ) => infer TSelected
90
+ ? TSelected
91
+ : TData;
92
+
93
+ /** Per-call options for `queryOptions`: react-query's, plus cache tags. */
94
+ export type QueryApiCallOptions<
95
+ TQueryFnData,
96
+ TError,
97
+ TData,
98
+ TKey extends QueryKey,
99
+ TTag extends string,
100
+ > = Omit<
101
+ UseQueryOptions<TQueryFnData, TError, TData, TKey>,
102
+ 'queryKey' | 'queryFn'
103
+ > & {
104
+ /** Tags this endpoint belongs to, for mutation-driven invalidation. */
105
+ tags?: readonly TTag[];
106
+ /** Middleware appended to the instance chain for this call only. */
107
+ middleware?: readonly QueryApiMiddleware[];
108
+ };
109
+
110
+ /**
111
+ * A tag to invalidate on mutation success, either fixed or derived from the
112
+ * mutation's own result and variables.
113
+ */
114
+ export type MutationInvalidation<TTag extends string, TData, TVariables> =
115
+ | TTag
116
+ | ((data: TData, variables: TVariables) => TTag | readonly TTag[]);
117
+
118
+ /** Per-call options for `mutationOptions`: react-query's, plus `invalidates`. */
119
+ export type QueryApiMutationCallOptions<
120
+ TData,
121
+ TError,
122
+ TVariables,
123
+ TOnMutateResult,
124
+ TTag extends string,
125
+ > = Omit<
126
+ UseMutationOptions<TData, TError, TVariables, TOnMutateResult>,
127
+ 'mutationKey' | 'mutationFn'
128
+ > & {
129
+ invalidates?: readonly MutationInvalidation<TTag, TData, TVariables>[];
130
+ /** Middleware appended to the instance chain for this call only. */
131
+ middleware?: readonly QueryApiMiddleware[];
132
+ };
133
+
134
+ export type QueryApiKeyFn<
135
+ TPaths extends QueryApiPaths,
136
+ TMedia extends MediaType,
137
+ > = <
138
+ TMethod extends QueryApiQueryMethod,
139
+ TPath extends PathsWithMethod<TPaths, TMethod>,
140
+ TInit extends MaybeOptionalInit<TPaths[TPath], TMethod>,
141
+ TResponse extends Required<
142
+ FetchResponse<TPaths[TPath][TMethod], TInit, TMedia>
143
+ >,
144
+ >(
145
+ method: TMethod,
146
+ path: TPath,
147
+ ...[init]: RequiredKeysOf<TInit> extends never
148
+ ? [init?: InitWithUnknowns<TInit>]
149
+ : [init: InitWithUnknowns<TInit>]
150
+ // `NoInfer` matters here: without it, a contextual type on the result — the
151
+ // `queryKey` property of an `invalidateQueries` filter, say — becomes an
152
+ // inference site for `TResponse`, which leaves `TInit` unresolved and makes
153
+ // TypeScript demand the optional `init` argument.
154
+ ) => NoInfer<
155
+ DataTag<
156
+ QueryApiKey<TMethod, TPath>,
157
+ TResponse['data'],
158
+ QueryApiError<TResponse['error']>
159
+ >
160
+ >;
161
+
162
+ export type QueryApiQueryOptionsFn<
163
+ TPaths extends QueryApiPaths,
164
+ TTag extends string,
165
+ TMedia extends MediaType,
166
+ > = <
167
+ TMethod extends QueryApiQueryMethod,
168
+ TPath extends PathsWithMethod<TPaths, TMethod>,
169
+ TInit extends MaybeOptionalInit<TPaths[TPath], TMethod>,
170
+ // `Required` here so the option types below never repeat `NonNullable`.
171
+ TResponse extends Required<
172
+ FetchResponse<TPaths[TPath][TMethod], TInit, TMedia>
173
+ >,
174
+ TOptions extends QueryApiCallOptions<
175
+ TResponse['data'],
176
+ QueryApiError<TResponse['error']>,
177
+ InferSelectReturnType<TResponse['data'], TOptions['select']>,
178
+ QueryApiKey<TMethod, TPath>,
179
+ TTag
180
+ >,
181
+ >(
182
+ method: TMethod,
183
+ path: TPath,
184
+ ...[init, options]: RequiredKeysOf<TInit> extends never
185
+ ? [init?: InitWithUnknowns<TInit>, options?: TOptions]
186
+ : [init: InitWithUnknowns<TInit>, options?: TOptions]
187
+ ) => NoInfer<
188
+ Omit<
189
+ UseQueryOptions<
190
+ TResponse['data'],
191
+ QueryApiError<TResponse['error']>,
192
+ InferSelectReturnType<TResponse['data'], TOptions['select']>,
193
+ QueryApiKey<TMethod, TPath>
194
+ >,
195
+ 'queryKey' | 'queryFn'
196
+ > & {
197
+ queryKey: DataTag<
198
+ QueryApiKey<TMethod, TPath>,
199
+ TResponse['data'],
200
+ QueryApiError<TResponse['error']>
201
+ >;
202
+ queryFn: Exclude<
203
+ UseQueryOptions<
204
+ TResponse['data'],
205
+ QueryApiError<TResponse['error']>,
206
+ InferSelectReturnType<TResponse['data'], TOptions['select']>,
207
+ QueryApiKey<TMethod, TPath>
208
+ >['queryFn'],
209
+ SkipToken | undefined
210
+ >;
211
+ }
212
+ >;
213
+
214
+ export type QueryApiMutationOptionsFn<
215
+ TPaths extends QueryApiPaths,
216
+ TTag extends string,
217
+ TMedia extends MediaType,
218
+ > = <
219
+ TMethod extends QueryApiMutationMethod,
220
+ TPath extends PathsWithMethod<TPaths, TMethod>,
221
+ TInit extends MaybeOptionalInit<TPaths[TPath], TMethod>,
222
+ TResponse extends Required<
223
+ FetchResponse<TPaths[TPath][TMethod], TInit, TMedia>
224
+ >,
225
+ TOnMutateResult = unknown,
226
+ >(
227
+ method: TMethod,
228
+ path: TPath,
229
+ options?: QueryApiMutationCallOptions<
230
+ TResponse['data'],
231
+ QueryApiError<TResponse['error']>,
232
+ InitWithUnknowns<TInit>,
233
+ TOnMutateResult,
234
+ TTag
235
+ >,
236
+ ) => NoInfer<
237
+ Omit<
238
+ UseMutationOptions<
239
+ TResponse['data'],
240
+ QueryApiError<TResponse['error']>,
241
+ InitWithUnknowns<TInit>,
242
+ TOnMutateResult
243
+ >,
244
+ 'mutationKey' | 'mutationFn'
245
+ > & {
246
+ mutationKey: readonly [method: TMethod, path: TPath];
247
+ mutationFn: NonNullable<
248
+ UseMutationOptions<
249
+ TResponse['data'],
250
+ QueryApiError<TResponse['error']>,
251
+ InitWithUnknowns<TInit>,
252
+ TOnMutateResult
253
+ >['mutationFn']
254
+ >;
255
+ }
256
+ >;
257
+
258
+ /** Instance-level configuration for {@link createQueryApi}. */
259
+ export type QueryApiOptions<TTag extends string> = {
260
+ /**
261
+ * The tag vocabulary. Passing it both infers `TTag` (so `tags` and
262
+ * `invalidates` are checked against a closed set) and makes an unknown tag
263
+ * throw at runtime, which is what catches typos from untyped call sites.
264
+ */
265
+ tags?: readonly TTag[];
266
+ /** Middleware applied to every request from this instance, outermost first. */
267
+ middleware?: readonly QueryApiMiddleware[];
268
+ };
269
+
270
+ export type QueryApi<
271
+ TPaths extends QueryApiPaths,
272
+ TTag extends string = string,
273
+ TMedia extends MediaType = MediaType,
274
+ > = {
275
+ /**
276
+ * The derived key for an operation, for targeting `getQueryData`,
277
+ * `setQueryData`, or `invalidateQueries` without a hand-built key factory.
278
+ * Slice it to `[method, path]` to target every cached variant of an endpoint.
279
+ */
280
+ queryKey: QueryApiKeyFn<TPaths, TMedia>;
281
+ queryOptions: QueryApiQueryOptionsFn<TPaths, TTag, TMedia>;
282
+ mutationOptions: QueryApiMutationOptionsFn<TPaths, TTag, TMedia>;
283
+ /** A react-query filter matching every query registered under `tag`. */
284
+ tagFilter: (tag: TTag) => QueryFilters;
285
+ invalidateTags: (client: QueryClient, tags: readonly TTag[]) => Promise<void>;
286
+ /** The `${method} ${path}` tokens currently registered under `tag`. */
287
+ taggedEndpoints: (tag: TTag) => readonly string[];
288
+ };
289
+
290
+ type LooseFetchResult = {
291
+ data?: unknown;
292
+ error?: unknown;
293
+ response: Response;
294
+ };
295
+
296
+ type LooseClientMethod = (
297
+ path: string,
298
+ init?: Record<string, unknown>,
299
+ ) => Promise<LooseFetchResult>;
300
+
301
+ /**
302
+ * Binds a TanStack Query surface to an `openapi-fetch` client.
303
+ *
304
+ * The generated `TPaths` type is the only endpoint definition: methods, paths,
305
+ * params, request bodies, and response types are all read off it, and both
306
+ * `TPaths` and the tag vocabulary are inferred from the arguments — prefer
307
+ * `createQueryApi(client, { tags: [...] })` over passing type arguments
308
+ * explicitly, since naming one disables inference for the rest.
309
+ */
310
+ export function createQueryApi<
311
+ TPaths extends QueryApiPaths,
312
+ TTag extends string = string,
313
+ TMedia extends MediaType = MediaType,
314
+ >(
315
+ client: ApiClient<TPaths, TMedia>,
316
+ options: QueryApiOptions<TTag> = {},
317
+ ): QueryApi<TPaths, TTag, TMedia> {
318
+ const registry = createTagRegistry(options.tags);
319
+ const instanceMiddleware = options.middleware ?? [];
320
+ const methods = client as unknown as Record<string, LooseClientMethod>;
321
+
322
+ const send = async (ctx: QueryApiRequestContext): Promise<unknown> => {
323
+ const method = ctx.method.toUpperCase();
324
+ const call = methods[method];
325
+ if (!call) {
326
+ throw new Error(`http-client-react: client has no ${method} method`);
327
+ }
328
+
329
+ const { data, error, response } = await call(ctx.path, ctx.init);
330
+
331
+ if (!response.ok || error !== undefined) {
332
+ throw new HttpRequestError({
333
+ method: ctx.method,
334
+ path: ctx.path,
335
+ body: error,
336
+ response,
337
+ });
338
+ }
339
+
340
+ // A HEAD response has no body by definition, so `openapi-fetch` resolves
341
+ // `data: undefined` whatever the status and whatever `Content-Length`
342
+ // claims — and it claims the size the matching GET would have returned, so
343
+ // the emptiness checks below do not catch it. react-query rejects
344
+ // `undefined` as query data.
345
+ if (method === 'HEAD') return data ?? null;
346
+
347
+ // Same conversion for the other two ways a 2xx legitimately has no body.
348
+ if (
349
+ response.status === 204 ||
350
+ response.headers.get('Content-Length') === '0'
351
+ ) {
352
+ return data ?? null;
353
+ }
354
+
355
+ return data;
356
+ };
357
+
358
+ const request = (
359
+ operationType: QueryApiOperationType,
360
+ method: string,
361
+ path: string,
362
+ init: Record<string, unknown> | undefined,
363
+ callMiddleware: readonly QueryApiMiddleware[] | undefined,
364
+ ): Promise<unknown> => {
365
+ const chain = callMiddleware?.length
366
+ ? [...instanceMiddleware, ...callMiddleware]
367
+ : instanceMiddleware;
368
+
369
+ return composeMiddleware(
370
+ chain,
371
+ send,
372
+ )({
373
+ method,
374
+ path,
375
+ operationType,
376
+ init,
377
+ });
378
+ };
379
+
380
+ const tagFilter = (tag: TTag): QueryFilters => {
381
+ registry.assertKnown(tag);
382
+ return { predicate: (query) => registry.matches(tag, query.queryKey) };
383
+ };
384
+
385
+ const invalidateTags = async (
386
+ client_: QueryClient,
387
+ tags: readonly TTag[],
388
+ ): Promise<void> => {
389
+ await Promise.all(
390
+ tags.map((tag) => client_.invalidateQueries(tagFilter(tag))),
391
+ );
392
+ };
393
+
394
+ /**
395
+ * Runs after the mutation has already succeeded, which is what makes the
396
+ * asymmetry below the right one: a literal tag was checked when
397
+ * `mutationOptions` was called, so it can still throw; a tag a callback
398
+ * derives from the result can only be checked here, and throwing here would
399
+ * turn a successful mutation into a failed one and skip the caller's own
400
+ * `onSuccess`. So an unknown derived tag is warned about and dropped.
401
+ */
402
+ const resolveInvalidations = (
403
+ invalidates: readonly MutationInvalidation<TTag, never, never>[],
404
+ data: unknown,
405
+ variables: unknown,
406
+ ): readonly TTag[] => {
407
+ const resolved = new Set<TTag>();
408
+
409
+ for (const entry of invalidates) {
410
+ if (typeof entry !== 'function') {
411
+ resolved.add(entry);
412
+ continue;
413
+ }
414
+
415
+ const produced = (
416
+ entry as (d: unknown, v: unknown) => TTag | readonly TTag[]
417
+ )(data, variables);
418
+
419
+ for (const tag of typeof produced === 'string' ? [produced] : produced) {
420
+ if (registry.acceptOrWarn(tag)) resolved.add(tag);
421
+ }
422
+ }
423
+
424
+ return [...resolved];
425
+ };
426
+
427
+ /**
428
+ * The public surface is the generic type above; the implementation is written
429
+ * against loose types and cast once, here. `query-api.types.test.ts` is what
430
+ * holds the two in agreement.
431
+ */
432
+ const api = {
433
+ queryKey: (method: string, path: string, init?: Record<string, unknown>) =>
434
+ buildQueryApiKey(method, path, init),
435
+
436
+ queryOptions: (
437
+ method: string,
438
+ path: string,
439
+ init?: Record<string, unknown>,
440
+ callOptions?: QueryApiCallOptions<
441
+ unknown,
442
+ Error,
443
+ unknown,
444
+ QueryKey,
445
+ TTag
446
+ >,
447
+ ) => {
448
+ const { tags, middleware, ...queryRest } = callOptions ?? {};
449
+
450
+ for (const tag of tags ?? []) registry.register(tag, method, path);
451
+
452
+ return {
453
+ ...queryRest,
454
+ queryKey: buildQueryApiKey(method, path, init),
455
+ queryFn: ({ signal }: { signal: AbortSignal }) =>
456
+ request('query', method, path, { ...init, signal }, middleware),
457
+ };
458
+ },
459
+
460
+ mutationOptions: (
461
+ method: string,
462
+ path: string,
463
+ callOptions?: QueryApiMutationCallOptions<
464
+ never,
465
+ Error,
466
+ never,
467
+ unknown,
468
+ TTag
469
+ >,
470
+ ) => {
471
+ const { invalidates, middleware, onSuccess, ...mutationRest } =
472
+ callOptions ?? {};
473
+
474
+ for (const entry of invalidates ?? []) {
475
+ if (typeof entry === 'string') registry.assertKnown(entry);
476
+ }
477
+
478
+ return {
479
+ ...mutationRest,
480
+ mutationKey: [method, path],
481
+ mutationFn: (variables?: Record<string, unknown>) =>
482
+ request('mutation', method, path, variables, middleware),
483
+ onSuccess: async (
484
+ data: never,
485
+ variables: never,
486
+ onMutateResult: unknown,
487
+ context: { client: QueryClient },
488
+ ) => {
489
+ if (invalidates?.length) {
490
+ await invalidateTags(
491
+ context.client,
492
+ resolveInvalidations(invalidates, data, variables),
493
+ );
494
+ }
495
+
496
+ return await onSuccess?.(
497
+ data,
498
+ variables,
499
+ onMutateResult as never,
500
+ context as never,
501
+ );
502
+ },
503
+ };
504
+ },
505
+
506
+ tagFilter,
507
+ invalidateTags,
508
+ taggedEndpoints: registry.endpoints,
509
+ };
510
+
511
+ return api as unknown as QueryApi<TPaths, TTag, TMedia>;
512
+ }