@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/dist/tags.js ADDED
@@ -0,0 +1,53 @@
1
+ import { operationToken } from './query-key.js';
2
+ /**
3
+ * Bundlers statically replace `process.env.NODE_ENV`, so a production build
4
+ * collapses this to `false` and drops the warning call; the `typeof` guard
5
+ * keeps it safe to evaluate where no global `process` exists at all.
6
+ */
7
+ const IS_DEV = typeof process !== 'undefined' && process?.env?.NODE_ENV !== 'production';
8
+ export function createTagRegistry(vocabulary) {
9
+ const known = vocabulary ? new Set(vocabulary) : undefined;
10
+ const endpointsByTag = new Map();
11
+ const warned = new Set();
12
+ /** Names the vocabulary, for a message that has to report a tag outside it. */
13
+ const describeVocabulary = (declared) => `Declared tags: ${[...declared].join(', ') || '(none)'}`;
14
+ // Both checks below read `known` directly rather than through a shared
15
+ // predicate: with no vocabulary there is no closed set, so no tag is unknown
16
+ // and neither branch is reachable.
17
+ const assertKnown = (tag) => {
18
+ if (known && !known.has(tag)) {
19
+ throw new Error(`http-client-react: unknown tag "${tag}". ${describeVocabulary(known)}`);
20
+ }
21
+ };
22
+ const acceptOrWarn = (tag) => {
23
+ if (!known || known.has(tag))
24
+ return true;
25
+ if (IS_DEV && !warned.has(tag)) {
26
+ warned.add(tag);
27
+ console.warn(`http-client-react: skipping unknown tag "${tag}" produced by an ` +
28
+ `invalidates callback — nothing was invalidated for it. ` +
29
+ describeVocabulary(known));
30
+ }
31
+ return false;
32
+ };
33
+ return {
34
+ assertKnown,
35
+ acceptOrWarn,
36
+ register: (tag, method, path) => {
37
+ assertKnown(tag);
38
+ const tokens = endpointsByTag.get(tag) ?? new Set();
39
+ tokens.add(operationToken(method, path));
40
+ endpointsByTag.set(tag, tokens);
41
+ },
42
+ matches: (tag, queryKey) => {
43
+ const tokens = endpointsByTag.get(tag);
44
+ if (!tokens)
45
+ return false;
46
+ const [method, path] = queryKey;
47
+ if (typeof method !== 'string' || typeof path !== 'string')
48
+ return false;
49
+ return tokens.has(operationToken(method, path));
50
+ },
51
+ endpoints: (tag) => [...(endpointsByTag.get(tag) ?? [])],
52
+ };
53
+ }
@@ -0,0 +1,61 @@
1
+ import type { QueryApiMiddleware, QueryApiRequestContext } from './middleware.js';
2
+ /** A single schema violation, flattened so consumers never touch zod's types. */
3
+ export type ResponseValidationIssue = {
4
+ /** Dot-joined path to the offending value, `''` at the root. */
5
+ path: string;
6
+ message: string;
7
+ };
8
+ /** Thrown when a response body does not match its OpenAPI component schema. */
9
+ export declare class ZodResponseValidationError extends Error {
10
+ readonly name = "ZodResponseValidationError";
11
+ readonly method: string;
12
+ readonly path: string;
13
+ /** The `components.schemas` name the body was validated against. */
14
+ readonly schemaName: string;
15
+ readonly issues: readonly ResponseValidationIssue[];
16
+ constructor(init: {
17
+ method: string;
18
+ path: string;
19
+ schemaName: string;
20
+ issues: readonly ResponseValidationIssue[];
21
+ cause?: unknown;
22
+ });
23
+ }
24
+ /**
25
+ * Narrows a caught value to {@link ZodResponseValidationError}.
26
+ *
27
+ * Matches on `name` rather than `instanceof` for the same reason
28
+ * `isHttpRequestError` does: a consumer can end up with two copies of this
29
+ * package in its module graph, and an error thrown by one is not `instanceof`
30
+ * the class the other closed over.
31
+ */
32
+ export declare function isZodResponseValidationError(value: unknown): value is ZodResponseValidationError;
33
+ /**
34
+ * How to find the component schema for an operation: either a lookup table
35
+ * keyed `${method} ${path}` (`'get /users/{id}'`), or a function for specs
36
+ * whose naming is derivable.
37
+ */
38
+ export type ResponseSchemaSource = Readonly<Record<string, string>> | ((ctx: QueryApiRequestContext) => string | undefined);
39
+ export type ZodResponseMiddlewareOptions = {
40
+ /** The parsed OpenAPI document the `TPaths` types were generated from. */
41
+ document: unknown;
42
+ schemas: ResponseSchemaSource;
43
+ /**
44
+ * Called instead of throwing when a body fails validation. Use it to report
45
+ * drift without breaking the screen; the unmodified body is passed through.
46
+ */
47
+ onInvalid?: (error: ZodResponseValidationError) => void;
48
+ };
49
+ /**
50
+ * Validates response bodies against the OpenAPI document at runtime, reusing
51
+ * `getComponentSchemaFromOpenApi` from http-client-core so the schema and the
52
+ * `TPaths` types come from the same spec.
53
+ *
54
+ * The validated body is passed through **unmodified** — the middleware never
55
+ * substitutes zod's parse output, so the runtime value always matches the
56
+ * statically inferred one and no coercion happens behind the caller's back.
57
+ *
58
+ * Compiled schemas are memoized per middleware instance;
59
+ * `z.fromJSONSchema` is far too expensive to run per request.
60
+ */
61
+ export declare function createZodResponseMiddleware(options: ZodResponseMiddlewareOptions): QueryApiMiddleware;
@@ -0,0 +1,91 @@
1
+ import { getComponentSchemaFromOpenApi } from '@r0hitsharma/http-client-core';
2
+ import { operationToken } from './query-key.js';
3
+ /** Thrown when a response body does not match its OpenAPI component schema. */
4
+ export class ZodResponseValidationError extends Error {
5
+ name = 'ZodResponseValidationError';
6
+ method;
7
+ path;
8
+ /** The `components.schemas` name the body was validated against. */
9
+ schemaName;
10
+ issues;
11
+ constructor(init) {
12
+ super(`${init.method.toUpperCase()} ${init.path} response failed ${init.schemaName} validation: ${init.issues
13
+ .map((issue) => `${issue.path || '<root>'} ${issue.message}`)
14
+ .join('; ')}`, { cause: init.cause });
15
+ this.method = init.method;
16
+ this.path = init.path;
17
+ this.schemaName = init.schemaName;
18
+ this.issues = init.issues;
19
+ }
20
+ }
21
+ /**
22
+ * Narrows a caught value to {@link ZodResponseValidationError}.
23
+ *
24
+ * Matches on `name` rather than `instanceof` for the same reason
25
+ * `isHttpRequestError` does: a consumer can end up with two copies of this
26
+ * package in its module graph, and an error thrown by one is not `instanceof`
27
+ * the class the other closed over.
28
+ */
29
+ export function isZodResponseValidationError(value) {
30
+ return value instanceof Error && value.name === 'ZodResponseValidationError';
31
+ }
32
+ function normalizeIssues(error) {
33
+ const issues = error?.issues;
34
+ if (!Array.isArray(issues))
35
+ return [];
36
+ return issues.map((issue) => {
37
+ const entry = issue;
38
+ const path = Array.isArray(entry.path) ? entry.path.join('.') : '';
39
+ return {
40
+ path,
41
+ message: typeof entry.message === 'string' ? entry.message : 'invalid',
42
+ };
43
+ });
44
+ }
45
+ /**
46
+ * Validates response bodies against the OpenAPI document at runtime, reusing
47
+ * `getComponentSchemaFromOpenApi` from http-client-core so the schema and the
48
+ * `TPaths` types come from the same spec.
49
+ *
50
+ * The validated body is passed through **unmodified** — the middleware never
51
+ * substitutes zod's parse output, so the runtime value always matches the
52
+ * statically inferred one and no coercion happens behind the caller's back.
53
+ *
54
+ * Compiled schemas are memoized per middleware instance;
55
+ * `z.fromJSONSchema` is far too expensive to run per request.
56
+ */
57
+ export function createZodResponseMiddleware(options) {
58
+ const { document, schemas, onInvalid } = options;
59
+ const compiled = new Map();
60
+ const resolveSchemaName = (ctx) => {
61
+ if (typeof schemas === 'function')
62
+ return schemas(ctx);
63
+ return schemas[operationToken(ctx.method, ctx.path)];
64
+ };
65
+ return async (ctx, next) => {
66
+ const data = await next();
67
+ const schemaName = resolveSchemaName(ctx);
68
+ if (!schemaName)
69
+ return data;
70
+ let schema = compiled.get(schemaName);
71
+ if (!schema) {
72
+ schema = getComponentSchemaFromOpenApi(document, schemaName);
73
+ compiled.set(schemaName, schema);
74
+ }
75
+ const result = schema.safeParse(data);
76
+ if (result.success)
77
+ return data;
78
+ const error = new ZodResponseValidationError({
79
+ method: ctx.method,
80
+ path: ctx.path,
81
+ schemaName,
82
+ issues: normalizeIssues(result.error),
83
+ cause: result.error,
84
+ });
85
+ if (onInvalid) {
86
+ onInvalid(error);
87
+ return data;
88
+ }
89
+ throw error;
90
+ };
91
+ }
@@ -0,0 +1,5 @@
1
+ import formatConfig from '@r0hitsharma/oxfmt-config';
2
+
3
+ export default {
4
+ ...formatConfig,
5
+ };
@@ -0,0 +1,5 @@
1
+ import reactConfig from '@r0hitsharma/oxlint-config/react';
2
+
3
+ export default {
4
+ ...reactConfig,
5
+ };
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@r0hitsharma/http-client-react",
3
+ "publishConfig": {
4
+ "access": "public"
5
+ },
6
+ "type": "module",
7
+ "main": "dist/index.js",
8
+ "types": "dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "default": "./dist/index.js"
13
+ }
14
+ },
15
+ "scripts": {
16
+ "build": "tsc -p tsconfig.build.json",
17
+ "clean": "rm -rf dist",
18
+ "prepare": "npm run build",
19
+ "type:check": "tsc -p tsconfig.json --noEmit",
20
+ "lint": "oxlint -c oxlint.config.ts --max-warnings=0 src",
21
+ "lint:fix": "oxlint -c oxlint.config.ts --fix src",
22
+ "format": "oxfmt -c oxfmt.config.ts --write src",
23
+ "format:check": "oxfmt -c oxfmt.config.ts --check src",
24
+ "test": "vitest run"
25
+ },
26
+ "dependencies": {
27
+ "@r0hitsharma/http-client-core": "*"
28
+ },
29
+ "peerDependencies": {
30
+ "@tanstack/react-query": "^5.89.0",
31
+ "react": "^19.0.0",
32
+ "react-dom": "^19.0.0"
33
+ },
34
+ "devDependencies": {
35
+ "@r0hitsharma/oxfmt-config": "*",
36
+ "@r0hitsharma/oxlint-config": "*",
37
+ "@r0hitsharma/tsconfig": "*",
38
+ "@tanstack/react-query": "5.102.8",
39
+ "@types/react": "19.2.18",
40
+ "@types/react-dom": "19.2.7",
41
+ "oxfmt": "0.67.0",
42
+ "oxlint": "1.82.0",
43
+ "vitest": "^4.1.9"
44
+ },
45
+ "version": "0.12.0-rohit-fork-ci.1",
46
+ "repository": {
47
+ "url": "https://github.com/r0hitsharma/uikit"
48
+ }
49
+ }
@@ -0,0 +1,27 @@
1
+ import { QueryClient } from '@tanstack/react-query';
2
+ import { describe, expect, it } from 'vitest';
3
+
4
+ import { createQueryClient, createQueryOptions } from './index.js';
5
+
6
+ describe('createQueryOptions (deprecated shim)', () => {
7
+ it('still produces options a QueryClient can fetch', async () => {
8
+ const queryClient = createQueryClient();
9
+
10
+ expect(queryClient).toBeInstanceOf(QueryClient);
11
+ await expect(
12
+ queryClient.fetchQuery(
13
+ createQueryOptions(['legacy', 'users'], () =>
14
+ Promise.resolve([{ id: 'u1' }]),
15
+ ),
16
+ ),
17
+ ).resolves.toEqual([{ id: 'u1' }]);
18
+ });
19
+
20
+ it('keeps the caller-supplied query key verbatim', () => {
21
+ const options = createQueryOptions(['legacy', 'users', { page: 2 }], () =>
22
+ Promise.resolve(null),
23
+ );
24
+
25
+ expect(options.queryKey).toEqual(['legacy', 'users', { page: 2 }]);
26
+ });
27
+ });
package/src/errors.ts ADDED
@@ -0,0 +1,74 @@
1
+ /**
2
+ * `openapi-fetch` resolves both outcomes of a request into `{ data, error }`,
3
+ * so a 404 is a fulfilled promise. TanStack Query only treats a *rejected*
4
+ * query function as a failure, so the query layer has to convert one into the
5
+ * other. This is the carrier for that conversion: it keeps the HTTP status and
6
+ * the parsed error body instead of flattening the failure to a message string.
7
+ */
8
+ export class HttpRequestError<TBody = unknown> extends Error {
9
+ readonly name = 'HttpRequestError';
10
+ /** Lowercase OpenAPI method (`get`, `post`, …) that failed. */
11
+ readonly method: string;
12
+ /** The OpenAPI path template, braces intact (`/users/{id}`). */
13
+ readonly path: string;
14
+ readonly status: number;
15
+ readonly statusText: string;
16
+ /**
17
+ * The response body parsed by `openapi-fetch`, typed from the operation's
18
+ * error responses — and `| undefined`, because a failure need not have one: a
19
+ * 5xx from a proxy, or any non-2xx with an empty body, leaves nothing to
20
+ * parse. Declaring it as `TBody` alone would let a consumer read through to a
21
+ * property of a body that is not there.
22
+ */
23
+ readonly body: TBody | undefined;
24
+ readonly response: Response;
25
+
26
+ constructor(init: {
27
+ method: string;
28
+ path: string;
29
+ body: TBody | undefined;
30
+ response: Response;
31
+ }) {
32
+ super(
33
+ `${init.method.toUpperCase()} ${init.path} failed with status ${init.response.status}`,
34
+ );
35
+ this.method = init.method;
36
+ this.path = init.path;
37
+ this.status = init.response.status;
38
+ this.statusText = init.response.statusText;
39
+ this.body = init.body;
40
+ this.response = init.response;
41
+ }
42
+ }
43
+
44
+ /**
45
+ * What {@link isHttpRequestError} narrows `TValue` to: the `HttpRequestError`
46
+ * member `TValue` already declares, so the body type the operation described
47
+ * survives the guard, and a bare `HttpRequestError` when `TValue` declares none
48
+ * — a `catch` binding, where no body type is knowable.
49
+ */
50
+ type NarrowedHttpRequestError<TValue> = [
51
+ Extract<TValue, HttpRequestError<unknown>>,
52
+ ] extends [never]
53
+ ? HttpRequestError<unknown>
54
+ : Extract<TValue, HttpRequestError<unknown>>;
55
+
56
+ /**
57
+ * Narrows a caught value to {@link HttpRequestError}.
58
+ *
59
+ * Matches on `name` rather than `instanceof` so the guard still holds when a
60
+ * consumer ends up with two copies of this package in its module graph (a
61
+ * bundled app plus a linked workspace build, for instance).
62
+ *
63
+ * The body type is *derived* from the argument rather than taken as a type
64
+ * parameter. A runtime check on `name` can say nothing about the body's shape,
65
+ * so a `TBody` parameter would have let a call site name any type it liked and
66
+ * get it back unchecked; deriving it means narrowing a
67
+ * `QueryApiError<TErrorBody>` yields that operation's own error body, and
68
+ * narrowing an `unknown` yields `unknown` — which is the truth in both cases.
69
+ */
70
+ export function isHttpRequestError<TValue>(
71
+ value: TValue,
72
+ ): value is TValue & NarrowedHttpRequestError<TValue> {
73
+ return value instanceof Error && value.name === 'HttpRequestError';
74
+ }
package/src/index.tsx ADDED
@@ -0,0 +1,100 @@
1
+ import {
2
+ queryOptions,
3
+ type QueryClient,
4
+ QueryClientProvider,
5
+ } from '@tanstack/react-query';
6
+ import type { PropsWithChildren } from 'react';
7
+
8
+ import { createQueryClient } from './query-client.js';
9
+
10
+ export { createApiClient } from '@r0hitsharma/http-client-core';
11
+ export type {
12
+ ApiClient,
13
+ ApiClientOptions,
14
+ JsonSchema,
15
+ } from '@r0hitsharma/http-client-core';
16
+
17
+ export { createQueryApi } from './query-api.js';
18
+ export type {
19
+ MutationInvalidation,
20
+ QueryApi,
21
+ QueryApiCallOptions,
22
+ QueryApiError,
23
+ QueryApiKeyFn,
24
+ QueryApiMutationCallOptions,
25
+ QueryApiMutationMethod,
26
+ QueryApiMutationOptionsFn,
27
+ QueryApiOptions,
28
+ QueryApiPaths,
29
+ QueryApiQueryMethod,
30
+ QueryApiQueryOptionsFn,
31
+ } from './query-api.js';
32
+
33
+ export { HttpRequestError, isHttpRequestError } from './errors.js';
34
+
35
+ export { composeMiddleware } from './middleware.js';
36
+ export type {
37
+ QueryApiMiddleware,
38
+ QueryApiNext,
39
+ QueryApiOperationType,
40
+ QueryApiRequestContext,
41
+ } from './middleware.js';
42
+
43
+ export {
44
+ buildQueryApiKey,
45
+ canonicalizeQueryKeyValue,
46
+ operationToken,
47
+ sanitizeQueryInit,
48
+ } from './query-key.js';
49
+ export type { QueryApiKey, SanitizedQueryInit } from './query-key.js';
50
+
51
+ export {
52
+ createQueryClient,
53
+ isRetryableError,
54
+ isRetryableHttpStatus,
55
+ shouldRetryRequest,
56
+ } from './query-client.js';
57
+
58
+ export {
59
+ createZodResponseMiddleware,
60
+ isZodResponseValidationError,
61
+ ZodResponseValidationError,
62
+ } from './zod-response.js';
63
+ export type {
64
+ ResponseSchemaSource,
65
+ ResponseValidationIssue,
66
+ ZodResponseMiddlewareOptions,
67
+ } from './zod-response.js';
68
+
69
+ /**
70
+ * The client `HttpProvider` falls back to when the app does not pass one. A
71
+ * single module-scoped instance, so every consumer of the fallback shares one
72
+ * cache — but prefer creating it in the app: the fallback cannot be reset
73
+ * between tests and cannot be reached for `setQueryData` before render.
74
+ */
75
+ const defaultQueryClient = createQueryClient();
76
+
77
+ export type HttpProviderProps = PropsWithChildren<{
78
+ client?: QueryClient;
79
+ }>;
80
+
81
+ export function HttpProvider({ client, children }: HttpProviderProps) {
82
+ return (
83
+ <QueryClientProvider client={client ?? defaultQueryClient}>
84
+ {children}
85
+ </QueryClientProvider>
86
+ );
87
+ }
88
+
89
+ /**
90
+ * @deprecated Use `createQueryApi(client).queryOptions(method, path, init)`
91
+ * instead: it derives the query key from the operation, types the result from
92
+ * the OpenAPI response, and rejects with a typed error. This shim keeps working
93
+ * for the hand-written query keys that predate it.
94
+ */
95
+ export function createQueryOptions<TData>(
96
+ queryKey: readonly unknown[],
97
+ queryFn: () => Promise<TData>,
98
+ ) {
99
+ return queryOptions({ queryKey, queryFn });
100
+ }
@@ -0,0 +1,88 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+
3
+ import {
4
+ composeMiddleware,
5
+ type QueryApiRequestContext,
6
+ } from './middleware.js';
7
+
8
+ const ctx: QueryApiRequestContext = {
9
+ method: 'get',
10
+ path: '/users',
11
+ operationType: 'query',
12
+ init: undefined,
13
+ };
14
+
15
+ describe('composeMiddleware', () => {
16
+ it('returns the terminal handler unchanged for an empty chain', () => {
17
+ const terminal = vi.fn(async () => 'value');
18
+ expect(composeMiddleware([], terminal)).toBe(terminal);
19
+ });
20
+
21
+ it('runs the chain outermost first and unwinds innermost first', async () => {
22
+ const trace: string[] = [];
23
+ const record =
24
+ (name: string) =>
25
+ async (
26
+ current: QueryApiRequestContext,
27
+ next: (ctx?: QueryApiRequestContext) => Promise<unknown>,
28
+ ) => {
29
+ trace.push(`enter:${name}`);
30
+ const value = await next(current);
31
+ trace.push(`exit:${name}`);
32
+ return value;
33
+ };
34
+
35
+ const chain = composeMiddleware(
36
+ [record('a'), record('b')],
37
+ async () => 'value',
38
+ );
39
+
40
+ await expect(chain(ctx)).resolves.toBe('value');
41
+ expect(trace).toEqual(['enter:a', 'enter:b', 'exit:b', 'exit:a']);
42
+ });
43
+
44
+ it('passes the context through unchanged when next() takes no argument', async () => {
45
+ const terminal = vi.fn(async () => 'value');
46
+ const chain = composeMiddleware([(_ctx, next) => next()], terminal);
47
+
48
+ await chain(ctx);
49
+
50
+ expect(terminal).toHaveBeenCalledWith(ctx);
51
+ });
52
+
53
+ it('hands a rewritten context to the rest of the chain', async () => {
54
+ const terminal = vi.fn(async () => 'value');
55
+ const chain = composeMiddleware(
56
+ [(current, next) => next({ ...current, path: '/rewritten' })],
57
+ terminal,
58
+ );
59
+
60
+ await chain(ctx);
61
+
62
+ expect(terminal).toHaveBeenCalledWith({ ...ctx, path: '/rewritten' });
63
+ });
64
+
65
+ it('rejects when a middleware calls next() twice', async () => {
66
+ const terminal = vi.fn(async () => 'value');
67
+ const chain = composeMiddleware(
68
+ [
69
+ async (current, next) => {
70
+ await next(current);
71
+ return await next(current);
72
+ },
73
+ ],
74
+ terminal,
75
+ );
76
+
77
+ await expect(chain(ctx)).rejects.toThrow(/next\(\) more than once/);
78
+ expect(terminal).toHaveBeenCalledTimes(1);
79
+ });
80
+
81
+ it('lets a middleware short-circuit without reaching the terminal', async () => {
82
+ const terminal = vi.fn(async () => 'value');
83
+ const chain = composeMiddleware([async () => 'cached'], terminal);
84
+
85
+ await expect(chain(ctx)).resolves.toBe('cached');
86
+ expect(terminal).not.toHaveBeenCalled();
87
+ });
88
+ });
@@ -0,0 +1,84 @@
1
+ /** Which half of the API a request came from. */
2
+ export type QueryApiOperationType = 'query' | 'mutation';
3
+
4
+ /**
5
+ * What a middleware sees. `init` is the init as it will be handed to
6
+ * `openapi-fetch` — the caller's init plus the abort `signal` react-query
7
+ * supplies — not the sanitized cache-key form.
8
+ */
9
+ export type QueryApiRequestContext = {
10
+ /** Lowercase OpenAPI method (`get`, `post`, …). */
11
+ readonly method: string;
12
+ /** The OpenAPI path template, braces intact (`/users/{id}`). */
13
+ readonly path: string;
14
+ readonly operationType: QueryApiOperationType;
15
+ readonly init: Record<string, unknown> | undefined;
16
+ };
17
+
18
+ /**
19
+ * Invokes the rest of the chain. Called with no argument to pass the context
20
+ * through unchanged, or with a replacement to rewrite the request downstream.
21
+ */
22
+ export type QueryApiNext = (ctx?: QueryApiRequestContext) => Promise<unknown>;
23
+
24
+ /**
25
+ * Onion-style middleware over the *parsed result* of a request, not over
26
+ * `Request`/`Response`. That is the altitude for response validation, logging
27
+ * and timing, rewriting the request on the way in (`next(ctx)`), reshaping the
28
+ * result on the way out, and short-circuiting with a substitute result by never
29
+ * calling `next` at all.
30
+ *
31
+ * **Not retries.** `next` is single-use per invocation — a second call rejects —
32
+ * so a middleware cannot re-issue the request it is wrapping. Retrying belongs
33
+ * either to react-query, whose `retry`/`retryDelay` re-invoke the whole chain
34
+ * from the top, or to the `fetch` implementation handed to `createApiClient`,
35
+ * which owns the actual HTTP call. `openapi-fetch`'s own `use()` middleware
36
+ * remains available on the client for anything that needs the raw HTTP objects.
37
+ */
38
+ export type QueryApiMiddleware = (
39
+ ctx: QueryApiRequestContext,
40
+ next: QueryApiNext,
41
+ ) => Promise<unknown>;
42
+
43
+ type TerminalHandler = (ctx: QueryApiRequestContext) => Promise<unknown>;
44
+
45
+ /**
46
+ * Folds `middleware` around `terminal`, outermost first: given `[a, b]`, `a`
47
+ * runs before `b` on the way in and after `b` on the way out.
48
+ *
49
+ * Each composed handler is single-use per invocation — calling `next()` twice
50
+ * from one middleware rejects rather than issuing the request twice.
51
+ */
52
+ export function composeMiddleware(
53
+ middleware: readonly QueryApiMiddleware[],
54
+ terminal: TerminalHandler,
55
+ ): TerminalHandler {
56
+ if (middleware.length === 0) return terminal;
57
+
58
+ return (ctx) => {
59
+ let called = -1;
60
+
61
+ const dispatch = (
62
+ index: number,
63
+ current: QueryApiRequestContext,
64
+ ): Promise<unknown> => {
65
+ if (index <= called) {
66
+ return Promise.reject(
67
+ new Error(
68
+ 'http-client-react: middleware called next() more than once',
69
+ ),
70
+ );
71
+ }
72
+ called = index;
73
+
74
+ const handler = middleware[index];
75
+ if (!handler) return terminal(current);
76
+
77
+ return handler(current, (nextCtx) =>
78
+ dispatch(index + 1, nextCtx ?? current),
79
+ );
80
+ };
81
+
82
+ return dispatch(0, ctx);
83
+ };
84
+ }