@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 +378 -0
- package/README.md +427 -0
- package/dist/errors.d.ts +56 -0
- package/dist/errors.js +51 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.js +29 -0
- package/dist/middleware.d.ts +45 -0
- package/dist/middleware.js +25 -0
- package/dist/query-api.d.ts +114 -0
- package/dist/query-api.js +128 -0
- package/dist/query-client.d.ts +84 -0
- package/dist/query-client.js +152 -0
- package/dist/query-key.d.ts +50 -0
- package/dist/query-key.js +70 -0
- package/dist/tags.d.ts +28 -0
- package/dist/tags.js +53 -0
- package/dist/zod-response.d.ts +61 -0
- package/dist/zod-response.js +91 -0
- package/oxfmt.config.ts +5 -0
- package/oxlint.config.ts +5 -0
- package/package.json +49 -0
- package/src/deprecated.test.ts +27 -0
- package/src/errors.ts +74 -0
- package/src/index.tsx +100 -0
- package/src/middleware.test.ts +88 -0
- package/src/middleware.ts +84 -0
- package/src/query-api.test.ts +644 -0
- package/src/query-api.ts +512 -0
- package/src/query-api.types.test.ts +225 -0
- package/src/query-client.test.ts +315 -0
- package/src/query-client.ts +173 -0
- package/src/query-key.test.ts +121 -0
- package/src/query-key.ts +113 -0
- package/src/tags.ts +100 -0
- package/src/test-fixtures.ts +222 -0
- package/src/zod-response.ts +150 -0
- package/tsconfig.build.json +21 -0
- package/tsconfig.json +7 -0
- package/vitest.config.ts +11 -0
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
|
+
}
|
package/oxfmt.config.ts
ADDED
package/oxlint.config.ts
ADDED
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
|
+
}
|