@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,222 @@
1
+ /**
2
+ * Test-only fixtures. Excluded from `tsconfig.build.json` alongside the specs,
3
+ * so nothing here ships, but still covered by `type:check` — which matters,
4
+ * because `TestPaths` mirrors `openapi-typescript` output closely enough
5
+ * (every HTTP method present, absent ones as `?: never`) to prove the query
6
+ * layer's inference against a real generated `paths` type.
7
+ */
8
+
9
+ export type User = { id: string; name: string };
10
+ export type ApiFault = { message: string };
11
+ export type ValidationFault = { message: string; field: string };
12
+
13
+ type NoBody = { requestBody?: never };
14
+ type NoParams = {
15
+ query?: never;
16
+ header?: never;
17
+ path?: never;
18
+ cookie?: never;
19
+ };
20
+
21
+ export type TestPaths = {
22
+ '/users': {
23
+ parameters: NoParams;
24
+ get: NoBody & {
25
+ parameters: {
26
+ query?: { limit?: number; search?: string };
27
+ header?: never;
28
+ path?: never;
29
+ cookie?: never;
30
+ };
31
+ responses: {
32
+ 200: {
33
+ headers: Record<string, unknown>;
34
+ content: {
35
+ 'application/json': User[];
36
+ };
37
+ };
38
+ 500: {
39
+ headers: Record<string, unknown>;
40
+ content: {
41
+ 'application/json': ApiFault;
42
+ };
43
+ };
44
+ };
45
+ };
46
+ post: {
47
+ parameters: NoParams;
48
+ requestBody: { content: { 'application/json': { name: string } } };
49
+ responses: {
50
+ 201: {
51
+ headers: Record<string, unknown>;
52
+ content: {
53
+ 'application/json': User;
54
+ };
55
+ };
56
+ 422: {
57
+ headers: Record<string, unknown>;
58
+ content: {
59
+ 'application/json': ValidationFault;
60
+ };
61
+ };
62
+ };
63
+ };
64
+ /**
65
+ * A real HEAD operation, not `head?: never`: `queryOptions` accepts `head`,
66
+ * so leaving it absent everywhere would let the query method union claim
67
+ * support nothing type-checks. Its 200 declares no content, which is what
68
+ * `openapi-typescript` emits for a bodyless response.
69
+ */
70
+ head: NoBody & {
71
+ parameters: {
72
+ query?: { search?: string };
73
+ header?: never;
74
+ path?: never;
75
+ cookie?: never;
76
+ };
77
+ responses: {
78
+ 200: { headers: Record<string, unknown>; content?: never };
79
+ 500: {
80
+ headers: Record<string, unknown>;
81
+ content: {
82
+ 'application/json': ApiFault;
83
+ };
84
+ };
85
+ };
86
+ };
87
+ put?: never;
88
+ delete?: never;
89
+ options?: never;
90
+ patch?: never;
91
+ trace?: never;
92
+ };
93
+ '/users/{id}': {
94
+ parameters: NoParams;
95
+ get: NoBody & {
96
+ parameters: {
97
+ query?: { expand?: string };
98
+ header?: never;
99
+ path: { id: string };
100
+ cookie?: never;
101
+ };
102
+ responses: {
103
+ 200: {
104
+ headers: Record<string, unknown>;
105
+ content: {
106
+ 'application/json': User;
107
+ };
108
+ };
109
+ 404: {
110
+ headers: Record<string, unknown>;
111
+ content: {
112
+ 'application/json': ApiFault;
113
+ };
114
+ };
115
+ };
116
+ };
117
+ delete: NoBody & {
118
+ parameters: {
119
+ query?: never;
120
+ header?: never;
121
+ path: { id: string };
122
+ cookie?: never;
123
+ };
124
+ responses: {
125
+ 204: { headers: Record<string, unknown>; content?: never };
126
+ 404: {
127
+ headers: Record<string, unknown>;
128
+ content: {
129
+ 'application/json': ApiFault;
130
+ };
131
+ };
132
+ };
133
+ };
134
+ put?: never;
135
+ post?: never;
136
+ options?: never;
137
+ head?: never;
138
+ patch?: never;
139
+ trace?: never;
140
+ };
141
+ };
142
+
143
+ export type TestTag = 'users' | 'user';
144
+
145
+ export type RecordedRequest = { method: string; url: string; body?: string };
146
+
147
+ /**
148
+ * A `fetch` stand-in for `createApiClient`'s client options. Routes are matched
149
+ * on `${method} ${pathname}` so the specs exercise the real `openapi-fetch`
150
+ * path and query serialization rather than a hand-faked client object.
151
+ */
152
+ export function createFetchStub(
153
+ routes: Record<
154
+ string,
155
+ (request: Request, url: URL) => Response | Promise<Response>
156
+ >,
157
+ ): {
158
+ fetch: (request: Request) => Promise<Response>;
159
+ requests: RecordedRequest[];
160
+ } {
161
+ const requests: RecordedRequest[] = [];
162
+
163
+ return {
164
+ requests,
165
+ fetch: async (request) => {
166
+ const url = new URL(request.url);
167
+ requests.push({
168
+ method: request.method,
169
+ url: `${url.pathname}${url.search}`,
170
+ body: request.body ? await request.clone().text() : undefined,
171
+ });
172
+
173
+ const route = routes[`${request.method} ${url.pathname}`];
174
+ if (!route) {
175
+ return new Response(JSON.stringify({ message: 'no stub route' }), {
176
+ status: 501,
177
+ headers: { 'Content-Type': 'application/json' },
178
+ });
179
+ }
180
+
181
+ return route(request, url);
182
+ },
183
+ };
184
+ }
185
+
186
+ /** A 2xx JSON response. */
187
+ export function jsonResponse(body: unknown, status = 200): Response {
188
+ return new Response(JSON.stringify(body), {
189
+ status,
190
+ headers: { 'Content-Type': 'application/json' },
191
+ });
192
+ }
193
+
194
+ /** A no-content response, for the 204 branch of the query function. */
195
+ export function emptyResponse(status = 204): Response {
196
+ return new Response(null, { status });
197
+ }
198
+
199
+ /**
200
+ * A failure with an explicitly empty body. `openapi-fetch` short-circuits on
201
+ * `Content-Length: 0` and resolves `error: undefined`, which is the case
202
+ * `HttpRequestError.body`'s `| undefined` exists for. Without the header the
203
+ * body is parsed as text and comes back as `''`, not `undefined`.
204
+ */
205
+ export function emptyFaultResponse(status = 500): Response {
206
+ return new Response(null, { status, headers: { 'Content-Length': '0' } });
207
+ }
208
+
209
+ /**
210
+ * A HEAD response: 200, no body, and a `Content-Length` echoing the size of the
211
+ * entity the matching GET would return. That header is what makes HEAD its own
212
+ * case — neither the 204 nor the `Content-Length: 0` branch catches it.
213
+ */
214
+ export function headResponse(contentLength = '42'): Response {
215
+ return new Response(null, {
216
+ status: 200,
217
+ headers: {
218
+ 'Content-Length': contentLength,
219
+ 'Content-Type': 'application/json',
220
+ },
221
+ });
222
+ }
@@ -0,0 +1,150 @@
1
+ import { getComponentSchemaFromOpenApi } from '@r0hitsharma/http-client-core';
2
+
3
+ import type {
4
+ QueryApiMiddleware,
5
+ QueryApiRequestContext,
6
+ } from './middleware.js';
7
+ import { operationToken } from './query-key.js';
8
+
9
+ /** A single schema violation, flattened so consumers never touch zod's types. */
10
+ export type ResponseValidationIssue = {
11
+ /** Dot-joined path to the offending value, `''` at the root. */
12
+ path: string;
13
+ message: string;
14
+ };
15
+
16
+ /** Thrown when a response body does not match its OpenAPI component schema. */
17
+ export class ZodResponseValidationError extends Error {
18
+ readonly name = 'ZodResponseValidationError';
19
+ readonly method: string;
20
+ readonly path: string;
21
+ /** The `components.schemas` name the body was validated against. */
22
+ readonly schemaName: string;
23
+ readonly issues: readonly ResponseValidationIssue[];
24
+
25
+ constructor(init: {
26
+ method: string;
27
+ path: string;
28
+ schemaName: string;
29
+ issues: readonly ResponseValidationIssue[];
30
+ cause?: unknown;
31
+ }) {
32
+ super(
33
+ `${init.method.toUpperCase()} ${init.path} response failed ${init.schemaName} validation: ${init.issues
34
+ .map((issue) => `${issue.path || '<root>'} ${issue.message}`)
35
+ .join('; ')}`,
36
+ { cause: init.cause },
37
+ );
38
+ this.method = init.method;
39
+ this.path = init.path;
40
+ this.schemaName = init.schemaName;
41
+ this.issues = init.issues;
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Narrows a caught value to {@link ZodResponseValidationError}.
47
+ *
48
+ * Matches on `name` rather than `instanceof` for the same reason
49
+ * `isHttpRequestError` does: a consumer can end up with two copies of this
50
+ * package in its module graph, and an error thrown by one is not `instanceof`
51
+ * the class the other closed over.
52
+ */
53
+ export function isZodResponseValidationError(
54
+ value: unknown,
55
+ ): value is ZodResponseValidationError {
56
+ return value instanceof Error && value.name === 'ZodResponseValidationError';
57
+ }
58
+
59
+ /**
60
+ * How to find the component schema for an operation: either a lookup table
61
+ * keyed `${method} ${path}` (`'get /users/{id}'`), or a function for specs
62
+ * whose naming is derivable.
63
+ */
64
+ export type ResponseSchemaSource =
65
+ | Readonly<Record<string, string>>
66
+ | ((ctx: QueryApiRequestContext) => string | undefined);
67
+
68
+ export type ZodResponseMiddlewareOptions = {
69
+ /** The parsed OpenAPI document the `TPaths` types were generated from. */
70
+ document: unknown;
71
+ schemas: ResponseSchemaSource;
72
+ /**
73
+ * Called instead of throwing when a body fails validation. Use it to report
74
+ * drift without breaking the screen; the unmodified body is passed through.
75
+ */
76
+ onInvalid?: (error: ZodResponseValidationError) => void;
77
+ };
78
+
79
+ function normalizeIssues(error: unknown): readonly ResponseValidationIssue[] {
80
+ const issues = (error as { issues?: unknown } | undefined)?.issues;
81
+ if (!Array.isArray(issues)) return [];
82
+
83
+ return issues.map((issue: unknown) => {
84
+ const entry = issue as { path?: unknown; message?: unknown };
85
+ const path = Array.isArray(entry.path) ? entry.path.join('.') : '';
86
+ return {
87
+ path,
88
+ message: typeof entry.message === 'string' ? entry.message : 'invalid',
89
+ };
90
+ });
91
+ }
92
+
93
+ /**
94
+ * Validates response bodies against the OpenAPI document at runtime, reusing
95
+ * `getComponentSchemaFromOpenApi` from http-client-core so the schema and the
96
+ * `TPaths` types come from the same spec.
97
+ *
98
+ * The validated body is passed through **unmodified** — the middleware never
99
+ * substitutes zod's parse output, so the runtime value always matches the
100
+ * statically inferred one and no coercion happens behind the caller's back.
101
+ *
102
+ * Compiled schemas are memoized per middleware instance;
103
+ * `z.fromJSONSchema` is far too expensive to run per request.
104
+ */
105
+ export function createZodResponseMiddleware(
106
+ options: ZodResponseMiddlewareOptions,
107
+ ): QueryApiMiddleware {
108
+ const { document, schemas, onInvalid } = options;
109
+ const compiled = new Map<
110
+ string,
111
+ ReturnType<typeof getComponentSchemaFromOpenApi>
112
+ >();
113
+
114
+ const resolveSchemaName = (
115
+ ctx: QueryApiRequestContext,
116
+ ): string | undefined => {
117
+ if (typeof schemas === 'function') return schemas(ctx);
118
+ return schemas[operationToken(ctx.method, ctx.path)];
119
+ };
120
+
121
+ return async (ctx, next) => {
122
+ const data = await next();
123
+ const schemaName = resolveSchemaName(ctx);
124
+ if (!schemaName) return data;
125
+
126
+ let schema = compiled.get(schemaName);
127
+ if (!schema) {
128
+ schema = getComponentSchemaFromOpenApi(document, schemaName);
129
+ compiled.set(schemaName, schema);
130
+ }
131
+
132
+ const result = schema.safeParse(data);
133
+ if (result.success) return data;
134
+
135
+ const error = new ZodResponseValidationError({
136
+ method: ctx.method,
137
+ path: ctx.path,
138
+ schemaName,
139
+ issues: normalizeIssues(result.error),
140
+ cause: result.error,
141
+ });
142
+
143
+ if (onInvalid) {
144
+ onInvalid(error);
145
+ return data;
146
+ }
147
+
148
+ throw error;
149
+ };
150
+ }
@@ -0,0 +1,21 @@
1
+ {
2
+ "extends": "@r0hitsharma/tsconfig/react",
3
+ "compilerOptions": {
4
+ "allowImportingTsExtensions": false,
5
+ "noEmit": false,
6
+ "emitDeclarationOnly": false,
7
+ "declaration": true,
8
+ "declarationMap": false,
9
+ "rootDir": "src",
10
+ "outDir": "dist"
11
+ },
12
+ "include": [
13
+ "src/**/*.ts",
14
+ "src/**/*.tsx"
15
+ ],
16
+ "exclude": [
17
+ "src/**/*.test.ts",
18
+ "src/**/*.test.tsx",
19
+ "src/test-fixtures.ts"
20
+ ]
21
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,7 @@
1
+ {
2
+ "extends": "@r0hitsharma/tsconfig/react",
3
+ "include": [
4
+ "src/**/*.ts",
5
+ "src/**/*.tsx"
6
+ ]
7
+ }
@@ -0,0 +1,11 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ // Node environment is sufficient: the query layer is plain logic over
6
+ // key derivation, a middleware chain, and a QueryClient — the specs drive
7
+ // it directly with an injected `fetch` rather than rendering a component.
8
+ environment: 'node',
9
+ include: ['src/**/*.test.ts'],
10
+ },
11
+ });