@geekmidas/client 0.0.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/README.md +518 -0
- package/dist/chunk-CUT6urMc.cjs +30 -0
- package/dist/fetcher-DLDD_7Sa.mjs +86 -0
- package/dist/fetcher-DLDD_7Sa.mjs.map +1 -0
- package/dist/fetcher-KdwHgdAl.cjs +98 -0
- package/dist/fetcher-KdwHgdAl.cjs.map +1 -0
- package/dist/fetcher.cjs +4 -0
- package/dist/fetcher.d.cts +18 -0
- package/dist/fetcher.d.mts +18 -0
- package/dist/fetcher.mjs +3 -0
- package/dist/openapi-hooks.cjs +44 -0
- package/dist/openapi-hooks.cjs.map +1 -0
- package/dist/openapi-hooks.d.cts +99 -0
- package/dist/openapi-hooks.d.mts +99 -0
- package/dist/openapi-hooks.mjs +43 -0
- package/dist/openapi-hooks.mjs.map +1 -0
- package/dist/openapi-types.d.cjs +0 -0
- package/dist/openapi-types.d.cts +443 -0
- package/dist/openapi-types.d.mts +443 -0
- package/dist/openapi.cjs +526 -0
- package/dist/openapi.cjs.map +1 -0
- package/dist/openapi.mjs +501 -0
- package/dist/openapi.mjs.map +1 -0
- package/dist/react-query.cjs +147 -0
- package/dist/react-query.cjs.map +1 -0
- package/dist/react-query.d.cts +77 -0
- package/dist/react-query.d.mts +77 -0
- package/dist/react-query.mjs +141 -0
- package/dist/react-query.mjs.map +1 -0
- package/dist/types-csACmD6U.d.cts +68 -0
- package/dist/types-tMp85Lt_.d.mts +68 -0
- package/dist/types.cjs +0 -0
- package/dist/types.d.cts +2 -0
- package/dist/types.d.mts +2 -0
- package/dist/types.mjs +0 -0
- package/package.json +59 -0
- package/src/__tests__/fetcher.spec.ts +409 -0
- package/src/__tests__/method-restrictions.spec.tsx +227 -0
- package/src/__tests__/openapi-hooks.spec.tsx +558 -0
- package/src/__tests__/react-query-infinite.spec.tsx +1003 -0
- package/src/__tests__/react-query-invalidation.spec.tsx +238 -0
- package/src/__tests__/react-query.spec.tsx +582 -0
- package/src/__tests__/setup.ts +281 -0
- package/src/__tests__/types.spec.ts +134 -0
- package/src/__tests__/url-parsing.spec.ts +196 -0
- package/src/fetcher.ts +173 -0
- package/src/openapi-hooks.ts +193 -0
- package/src/openapi-types.d.ts +440 -0
- package/src/openapi.json +595 -0
- package/src/react-query.ts +341 -0
- package/src/types.ts +149 -0
package/README.md
ADDED
|
@@ -0,0 +1,518 @@
|
|
|
1
|
+
# @geekmidas/client
|
|
2
|
+
|
|
3
|
+
Type-safe client library for consuming HTTP APIs with full TypeScript support, React Query integration, and automatic code generation from OpenAPI specifications.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Type-Safe Fetcher**: Fully typed HTTP client with automatic type inference
|
|
8
|
+
- **React Query Integration**: Pre-built hooks with TypeScript support
|
|
9
|
+
- **OpenAPI Code Generation**: Generate React Query hooks from OpenAPI specs
|
|
10
|
+
- **Infinite Queries**: Built-in support for pagination and infinite scroll
|
|
11
|
+
- **Automatic Validation**: Request/response validation with StandardSchema
|
|
12
|
+
- **Error Handling**: Type-safe error handling with HTTP status codes
|
|
13
|
+
- **Query Invalidation**: Type-safe cache invalidation
|
|
14
|
+
- **Method Restrictions**: Type-level enforcement of HTTP methods per endpoint
|
|
15
|
+
|
|
16
|
+
## Installation
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pnpm add @geekmidas/client
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
For React Query integration:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pnpm add @geekmidas/client @tanstack/react-query
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Package Exports
|
|
29
|
+
|
|
30
|
+
```typescript
|
|
31
|
+
// Type-safe fetcher
|
|
32
|
+
import { createTypedFetcher } from '@geekmidas/client';
|
|
33
|
+
|
|
34
|
+
// React Query client
|
|
35
|
+
import { createTypedQueryClient } from '@geekmidas/client/react-query';
|
|
36
|
+
|
|
37
|
+
// OpenAPI hooks generation
|
|
38
|
+
import { generateReactQueryHooks } from '@geekmidas/client/openapi';
|
|
39
|
+
|
|
40
|
+
// Type utilities
|
|
41
|
+
import type { TypedFetcherOptions } from '@geekmidas/client/types';
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Quick Start
|
|
45
|
+
|
|
46
|
+
### Type-Safe Fetcher
|
|
47
|
+
|
|
48
|
+
Create a typed fetcher for your API:
|
|
49
|
+
|
|
50
|
+
```typescript
|
|
51
|
+
import { createTypedFetcher } from '@geekmidas/client';
|
|
52
|
+
|
|
53
|
+
// Define your API types
|
|
54
|
+
interface API {
|
|
55
|
+
'GET /users': {
|
|
56
|
+
response: {
|
|
57
|
+
id: string;
|
|
58
|
+
name: string;
|
|
59
|
+
email: string;
|
|
60
|
+
}[];
|
|
61
|
+
};
|
|
62
|
+
'POST /users': {
|
|
63
|
+
body: {
|
|
64
|
+
name: string;
|
|
65
|
+
email: string;
|
|
66
|
+
};
|
|
67
|
+
response: {
|
|
68
|
+
id: string;
|
|
69
|
+
name: string;
|
|
70
|
+
email: string;
|
|
71
|
+
};
|
|
72
|
+
};
|
|
73
|
+
'GET /users/:id': {
|
|
74
|
+
params: {
|
|
75
|
+
id: string;
|
|
76
|
+
};
|
|
77
|
+
response: {
|
|
78
|
+
id: string;
|
|
79
|
+
name: string;
|
|
80
|
+
email: string;
|
|
81
|
+
};
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Create typed fetcher
|
|
86
|
+
const api = createTypedFetcher<API>({
|
|
87
|
+
baseUrl: 'https://api.example.com'
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// Use with full type safety
|
|
91
|
+
const users = await api('GET /users');
|
|
92
|
+
// users is typed as Array<{ id: string; name: string; email: string }>
|
|
93
|
+
|
|
94
|
+
const user = await api('POST /users', {
|
|
95
|
+
body: { name: 'John Doe', email: 'john@example.com' }
|
|
96
|
+
});
|
|
97
|
+
// user is typed as { id: string; name: string; email: string }
|
|
98
|
+
|
|
99
|
+
const singleUser = await api('GET /users/:id', {
|
|
100
|
+
params: { id: '123' }
|
|
101
|
+
});
|
|
102
|
+
// singleUser is typed as { id: string; name: string; email: string }
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### React Query Integration
|
|
106
|
+
|
|
107
|
+
Use with React Query for automatic caching and state management:
|
|
108
|
+
|
|
109
|
+
```typescript
|
|
110
|
+
import { createTypedQueryClient } from '@geekmidas/client/react-query';
|
|
111
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
112
|
+
|
|
113
|
+
// Create query client
|
|
114
|
+
const queryClient = new QueryClient();
|
|
115
|
+
const api = createTypedQueryClient<API>({
|
|
116
|
+
baseUrl: 'https://api.example.com'
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// In your component
|
|
120
|
+
function UsersList() {
|
|
121
|
+
const { data, isLoading, error } = api.useQuery('GET /users');
|
|
122
|
+
|
|
123
|
+
if (isLoading) return <div>Loading...</div>;
|
|
124
|
+
if (error) return <div>Error: {error.message}</div>;
|
|
125
|
+
|
|
126
|
+
return (
|
|
127
|
+
<ul>
|
|
128
|
+
{data.map(user => (
|
|
129
|
+
<li key={user.id}>{user.name}</li>
|
|
130
|
+
))}
|
|
131
|
+
</ul>
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Mutations
|
|
136
|
+
function CreateUser() {
|
|
137
|
+
const createUser = api.useMutation('POST /users');
|
|
138
|
+
|
|
139
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
140
|
+
e.preventDefault();
|
|
141
|
+
await createUser.mutateAsync({
|
|
142
|
+
body: { name: 'Jane Doe', email: 'jane@example.com' }
|
|
143
|
+
});
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
return (
|
|
147
|
+
<form onSubmit={handleSubmit}>
|
|
148
|
+
<button disabled={createUser.isPending}>
|
|
149
|
+
Create User
|
|
150
|
+
</button>
|
|
151
|
+
</form>
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
### Query Parameters
|
|
157
|
+
|
|
158
|
+
Handle query parameters with full type safety:
|
|
159
|
+
|
|
160
|
+
```typescript
|
|
161
|
+
interface API {
|
|
162
|
+
'GET /users/search': {
|
|
163
|
+
query: {
|
|
164
|
+
q: string;
|
|
165
|
+
limit?: number;
|
|
166
|
+
offset?: number;
|
|
167
|
+
};
|
|
168
|
+
response: {
|
|
169
|
+
users: Array<{ id: string; name: string }>;
|
|
170
|
+
total: number;
|
|
171
|
+
};
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Usage
|
|
176
|
+
const result = await api('GET /users/search', {
|
|
177
|
+
query: { q: 'john', limit: 10 }
|
|
178
|
+
});
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
### Infinite Queries
|
|
182
|
+
|
|
183
|
+
Implement infinite scroll pagination:
|
|
184
|
+
|
|
185
|
+
```typescript
|
|
186
|
+
function InfiniteUsersList() {
|
|
187
|
+
const {
|
|
188
|
+
data,
|
|
189
|
+
fetchNextPage,
|
|
190
|
+
hasNextPage,
|
|
191
|
+
isFetchingNextPage
|
|
192
|
+
} = api.useInfiniteQuery(
|
|
193
|
+
'GET /users',
|
|
194
|
+
{
|
|
195
|
+
query: { limit: 20 }
|
|
196
|
+
},
|
|
197
|
+
{
|
|
198
|
+
getNextPageParam: (lastPage, pages) => {
|
|
199
|
+
if (pages.length * 20 < lastPage.total) {
|
|
200
|
+
return { offset: pages.length * 20 };
|
|
201
|
+
}
|
|
202
|
+
return undefined;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
return (
|
|
208
|
+
<div>
|
|
209
|
+
{data?.pages.map((page, i) => (
|
|
210
|
+
<div key={i}>
|
|
211
|
+
{page.users.map(user => (
|
|
212
|
+
<div key={user.id}>{user.name}</div>
|
|
213
|
+
))}
|
|
214
|
+
</div>
|
|
215
|
+
))}
|
|
216
|
+
{hasNextPage && (
|
|
217
|
+
<button
|
|
218
|
+
onClick={() => fetchNextPage()}
|
|
219
|
+
disabled={isFetchingNextPage}
|
|
220
|
+
>
|
|
221
|
+
Load More
|
|
222
|
+
</button>
|
|
223
|
+
)}
|
|
224
|
+
</div>
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
## OpenAPI Code Generation
|
|
230
|
+
|
|
231
|
+
Generate React Query hooks from OpenAPI specifications:
|
|
232
|
+
|
|
233
|
+
```bash
|
|
234
|
+
# Using CLI
|
|
235
|
+
pnpm gkm generate:react-query --input api-docs.json --output ./src/api
|
|
236
|
+
|
|
237
|
+
# Programmatic usage
|
|
238
|
+
import { generateReactQueryHooks } from '@geekmidas/client/openapi';
|
|
239
|
+
import fs from 'fs/promises';
|
|
240
|
+
|
|
241
|
+
const spec = JSON.parse(await fs.readFile('api-docs.json', 'utf-8'));
|
|
242
|
+
const code = await generateReactQueryHooks(spec);
|
|
243
|
+
await fs.writeFile('./src/api/generated.ts', code);
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
Generated hooks example:
|
|
247
|
+
|
|
248
|
+
```typescript
|
|
249
|
+
// Generated from OpenAPI spec
|
|
250
|
+
export const api = createTypedQueryClient<{
|
|
251
|
+
'GET /users': {
|
|
252
|
+
response: User[];
|
|
253
|
+
};
|
|
254
|
+
'POST /users': {
|
|
255
|
+
body: CreateUserRequest;
|
|
256
|
+
response: User;
|
|
257
|
+
};
|
|
258
|
+
// ... all your endpoints
|
|
259
|
+
}>({
|
|
260
|
+
baseUrl: process.env.REACT_APP_API_URL
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
// Use generated hooks
|
|
264
|
+
function MyComponent() {
|
|
265
|
+
const { data: users } = api.useQuery('GET /users');
|
|
266
|
+
const createUser = api.useMutation('POST /users');
|
|
267
|
+
|
|
268
|
+
return (
|
|
269
|
+
// Your component
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
## Advanced Features
|
|
275
|
+
|
|
276
|
+
### Query Invalidation
|
|
277
|
+
|
|
278
|
+
Type-safe cache invalidation:
|
|
279
|
+
|
|
280
|
+
```typescript
|
|
281
|
+
// Invalidate specific query
|
|
282
|
+
await api.invalidateQueries('GET /users');
|
|
283
|
+
|
|
284
|
+
// Invalidate with params
|
|
285
|
+
await api.invalidateQueries('GET /users/:id', {
|
|
286
|
+
params: { id: '123' }
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
// Invalidate multiple queries
|
|
290
|
+
await Promise.all([
|
|
291
|
+
api.invalidateQueries('GET /users'),
|
|
292
|
+
api.invalidateQueries('GET /users/:id')
|
|
293
|
+
]);
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
### Optimistic Updates
|
|
297
|
+
|
|
298
|
+
Implement optimistic UI updates:
|
|
299
|
+
|
|
300
|
+
```typescript
|
|
301
|
+
const updateUser = api.useMutation('PUT /users/:id', {
|
|
302
|
+
onMutate: async (variables) => {
|
|
303
|
+
// Cancel outgoing refetches
|
|
304
|
+
await api.cancelQueries('GET /users/:id', {
|
|
305
|
+
params: { id: variables.params.id }
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
// Snapshot previous value
|
|
309
|
+
const previousUser = api.getQueryData('GET /users/:id', {
|
|
310
|
+
params: { id: variables.params.id }
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
// Optimistically update
|
|
314
|
+
api.setQueryData('GET /users/:id', {
|
|
315
|
+
params: { id: variables.params.id }
|
|
316
|
+
}, variables.body);
|
|
317
|
+
|
|
318
|
+
return { previousUser };
|
|
319
|
+
},
|
|
320
|
+
onError: (err, variables, context) => {
|
|
321
|
+
// Rollback on error
|
|
322
|
+
if (context?.previousUser) {
|
|
323
|
+
api.setQueryData('GET /users/:id', {
|
|
324
|
+
params: { id: variables.params.id }
|
|
325
|
+
}, context.previousUser);
|
|
326
|
+
}
|
|
327
|
+
},
|
|
328
|
+
onSettled: (data, error, variables) => {
|
|
329
|
+
// Refetch after mutation
|
|
330
|
+
api.invalidateQueries('GET /users/:id', {
|
|
331
|
+
params: { id: variables.params.id }
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
});
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
### Custom Headers
|
|
338
|
+
|
|
339
|
+
Add custom headers to requests:
|
|
340
|
+
|
|
341
|
+
```typescript
|
|
342
|
+
const api = createTypedFetcher<API>({
|
|
343
|
+
baseUrl: 'https://api.example.com',
|
|
344
|
+
headers: {
|
|
345
|
+
'Authorization': `Bearer ${token}`,
|
|
346
|
+
'X-Api-Key': apiKey
|
|
347
|
+
}
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
// Per-request headers
|
|
351
|
+
const user = await api('GET /users/:id', {
|
|
352
|
+
params: { id: '123' },
|
|
353
|
+
headers: {
|
|
354
|
+
'X-Request-ID': requestId
|
|
355
|
+
}
|
|
356
|
+
});
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
### Error Handling
|
|
360
|
+
|
|
361
|
+
Handle errors with full type safety:
|
|
362
|
+
|
|
363
|
+
```typescript
|
|
364
|
+
import { HttpError } from '@geekmidas/errors';
|
|
365
|
+
|
|
366
|
+
try {
|
|
367
|
+
const user = await api('GET /users/:id', {
|
|
368
|
+
params: { id: '123' }
|
|
369
|
+
});
|
|
370
|
+
} catch (error) {
|
|
371
|
+
if (error instanceof HttpError) {
|
|
372
|
+
if (error.statusCode === 404) {
|
|
373
|
+
console.log('User not found');
|
|
374
|
+
} else if (error.statusCode === 403) {
|
|
375
|
+
console.log('Access denied');
|
|
376
|
+
} else {
|
|
377
|
+
console.error('API error:', error.message);
|
|
378
|
+
}
|
|
379
|
+
} else {
|
|
380
|
+
console.error('Unexpected error:', error);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
### Request/Response Interceptors
|
|
386
|
+
|
|
387
|
+
Add interceptors for logging, auth, etc:
|
|
388
|
+
|
|
389
|
+
```typescript
|
|
390
|
+
const api = createTypedFetcher<API>({
|
|
391
|
+
baseUrl: 'https://api.example.com',
|
|
392
|
+
beforeRequest: async (url, options) => {
|
|
393
|
+
// Add auth token
|
|
394
|
+
const token = await getAuthToken();
|
|
395
|
+
options.headers = {
|
|
396
|
+
...options.headers,
|
|
397
|
+
'Authorization': `Bearer ${token}`
|
|
398
|
+
};
|
|
399
|
+
return { url, options };
|
|
400
|
+
},
|
|
401
|
+
afterResponse: async (response) => {
|
|
402
|
+
// Log response
|
|
403
|
+
console.log(`${response.status} ${response.url}`);
|
|
404
|
+
return response;
|
|
405
|
+
},
|
|
406
|
+
onError: async (error) => {
|
|
407
|
+
// Handle auth errors
|
|
408
|
+
if (error instanceof HttpError && error.statusCode === 401) {
|
|
409
|
+
await refreshAuthToken();
|
|
410
|
+
// Retry request
|
|
411
|
+
}
|
|
412
|
+
throw error;
|
|
413
|
+
}
|
|
414
|
+
});
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
### Prefetching
|
|
418
|
+
|
|
419
|
+
Prefetch queries for better UX:
|
|
420
|
+
|
|
421
|
+
```typescript
|
|
422
|
+
function UsersList() {
|
|
423
|
+
const { data: users } = api.useQuery('GET /users');
|
|
424
|
+
|
|
425
|
+
const handleUserHover = (userId: string) => {
|
|
426
|
+
// Prefetch user details on hover
|
|
427
|
+
api.prefetchQuery('GET /users/:id', {
|
|
428
|
+
params: { id: userId }
|
|
429
|
+
});
|
|
430
|
+
};
|
|
431
|
+
|
|
432
|
+
return (
|
|
433
|
+
<ul>
|
|
434
|
+
{users?.map(user => (
|
|
435
|
+
<li
|
|
436
|
+
key={user.id}
|
|
437
|
+
onMouseEnter={() => handleUserHover(user.id)}
|
|
438
|
+
>
|
|
439
|
+
{user.name}
|
|
440
|
+
</li>
|
|
441
|
+
))}
|
|
442
|
+
</ul>
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
```
|
|
446
|
+
|
|
447
|
+
## Type Utilities
|
|
448
|
+
|
|
449
|
+
### Infer API Types
|
|
450
|
+
|
|
451
|
+
```typescript
|
|
452
|
+
import type { InferAPIResponse, InferAPIRequest } from '@geekmidas/client/types';
|
|
453
|
+
|
|
454
|
+
type UsersResponse = InferAPIResponse<API, 'GET /users'>;
|
|
455
|
+
// type UsersResponse = Array<{ id: string; name: string; email: string }>
|
|
456
|
+
|
|
457
|
+
type CreateUserRequest = InferAPIRequest<API, 'POST /users'>;
|
|
458
|
+
// type CreateUserRequest = { body: { name: string; email: string } }
|
|
459
|
+
```
|
|
460
|
+
|
|
461
|
+
### Method Restrictions
|
|
462
|
+
|
|
463
|
+
Enforce correct HTTP methods at type level:
|
|
464
|
+
|
|
465
|
+
```typescript
|
|
466
|
+
// ✅ Correct - POST endpoint with body
|
|
467
|
+
await api('POST /users', {
|
|
468
|
+
body: { name: 'John' }
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
// ❌ Type error - GET endpoint can't have body
|
|
472
|
+
await api('GET /users', {
|
|
473
|
+
body: { name: 'John' } // Type error!
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
// ❌ Type error - Wrong method
|
|
477
|
+
await api('DELETE /users', {}); // Type error if endpoint not defined!
|
|
478
|
+
```
|
|
479
|
+
|
|
480
|
+
## Testing
|
|
481
|
+
|
|
482
|
+
Mock API calls in tests:
|
|
483
|
+
|
|
484
|
+
```typescript
|
|
485
|
+
import { createTypedFetcher } from '@geekmidas/client';
|
|
486
|
+
import { vi } from 'vitest';
|
|
487
|
+
|
|
488
|
+
const mockFetch = vi.fn();
|
|
489
|
+
global.fetch = mockFetch;
|
|
490
|
+
|
|
491
|
+
const api = createTypedFetcher<API>({
|
|
492
|
+
baseUrl: 'https://api.example.com'
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
// Mock response
|
|
496
|
+
mockFetch.mockResolvedValueOnce({
|
|
497
|
+
ok: true,
|
|
498
|
+
json: async () => [{ id: '1', name: 'John', email: 'john@example.com' }]
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
const users = await api('GET /users');
|
|
502
|
+
expect(users).toHaveLength(1);
|
|
503
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
504
|
+
'https://api.example.com/users',
|
|
505
|
+
expect.objectContaining({ method: 'GET' })
|
|
506
|
+
);
|
|
507
|
+
```
|
|
508
|
+
|
|
509
|
+
## Related Packages
|
|
510
|
+
|
|
511
|
+
- [@geekmidas/constructs](../constructs) - Build type-safe endpoints that this client consumes
|
|
512
|
+
- [@geekmidas/errors](../errors) - HTTP error classes for error handling
|
|
513
|
+
- [@geekmidas/cli](../cli) - Generate OpenAPI specs and React Query hooks
|
|
514
|
+
- [@tanstack/react-query](https://tanstack.com/query) - React Query library
|
|
515
|
+
|
|
516
|
+
## License
|
|
517
|
+
|
|
518
|
+
MIT
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
//#region rolldown:runtime
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __copyProps = (to, from, except, desc) => {
|
|
9
|
+
if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
|
|
10
|
+
key = keys[i];
|
|
11
|
+
if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
|
|
12
|
+
get: ((k) => from[k]).bind(null, key),
|
|
13
|
+
enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", {
|
|
19
|
+
value: mod,
|
|
20
|
+
enumerable: true
|
|
21
|
+
}) : target, mod));
|
|
22
|
+
|
|
23
|
+
//#endregion
|
|
24
|
+
|
|
25
|
+
Object.defineProperty(exports, '__toESM', {
|
|
26
|
+
enumerable: true,
|
|
27
|
+
get: function () {
|
|
28
|
+
return __toESM;
|
|
29
|
+
}
|
|
30
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
//#region src/fetcher.ts
|
|
2
|
+
var TypedFetcher = class TypedFetcher {
|
|
3
|
+
baseURL;
|
|
4
|
+
defaultHeaders;
|
|
5
|
+
options;
|
|
6
|
+
fetchFn;
|
|
7
|
+
static getFetchFn(fn) {
|
|
8
|
+
if (fn) return fn;
|
|
9
|
+
if (typeof window !== "undefined" && typeof window.fetch === "function") return window.fetch.bind(window);
|
|
10
|
+
if (typeof globalThis !== "undefined" && typeof globalThis.fetch === "function") return globalThis.fetch.bind(globalThis);
|
|
11
|
+
throw new Error("No fetch implementation found");
|
|
12
|
+
}
|
|
13
|
+
constructor(options = {}) {
|
|
14
|
+
this.baseURL = options.baseURL || "";
|
|
15
|
+
this.defaultHeaders = options.headers || {};
|
|
16
|
+
this.options = options;
|
|
17
|
+
this.fetchFn = TypedFetcher.getFetchFn(options.fetch);
|
|
18
|
+
}
|
|
19
|
+
async request(endpoint, config) {
|
|
20
|
+
const { method, route } = this.parseEndpoint(endpoint);
|
|
21
|
+
let url = route;
|
|
22
|
+
if (config && "params" in config && config.params) Object.entries(config.params).forEach(([key, value]) => {
|
|
23
|
+
url = url.replace(`{${key}}`, encodeURIComponent(String(value)));
|
|
24
|
+
});
|
|
25
|
+
if (config && "query" in config && config.query) {
|
|
26
|
+
const queryParams = new URLSearchParams();
|
|
27
|
+
const appendQueryParam = (prefix, value) => {
|
|
28
|
+
if (value === void 0 || value === null) return;
|
|
29
|
+
if (Array.isArray(value)) value.forEach((item) => {
|
|
30
|
+
queryParams.append(prefix, String(item));
|
|
31
|
+
});
|
|
32
|
+
else if (typeof value === "object") Object.entries(value).forEach(([subKey, subValue]) => {
|
|
33
|
+
appendQueryParam(`${prefix}.${subKey}`, subValue);
|
|
34
|
+
});
|
|
35
|
+
else queryParams.append(prefix, String(value));
|
|
36
|
+
};
|
|
37
|
+
Object.entries(config.query).forEach(([key, value]) => {
|
|
38
|
+
appendQueryParam(key, value);
|
|
39
|
+
});
|
|
40
|
+
const queryString = queryParams.toString();
|
|
41
|
+
if (queryString) url += `?${queryString}`;
|
|
42
|
+
}
|
|
43
|
+
let requestConfig = {
|
|
44
|
+
method: method.toUpperCase(),
|
|
45
|
+
headers: {
|
|
46
|
+
...this.defaultHeaders,
|
|
47
|
+
...config && "headers" in config && config.headers || {}
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
if (config && "body" in config && config.body) {
|
|
51
|
+
requestConfig.body = JSON.stringify(config.body);
|
|
52
|
+
requestConfig.headers = {
|
|
53
|
+
...requestConfig.headers,
|
|
54
|
+
"Content-Type": "application/json"
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
if (this.options.onRequest) requestConfig = await this.options.onRequest(requestConfig);
|
|
58
|
+
try {
|
|
59
|
+
let response = await this.fetchFn(`${this.baseURL}${url}`, requestConfig);
|
|
60
|
+
if (this.options.onResponse) response = await this.options.onResponse(response);
|
|
61
|
+
if (!response.ok) throw response;
|
|
62
|
+
if (response.status === 204 || response.headers.get("content-length") === "0") return void 0;
|
|
63
|
+
const data = await response.json();
|
|
64
|
+
return data;
|
|
65
|
+
} catch (error) {
|
|
66
|
+
if (this.options.onError) await this.options.onError(error);
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
parseEndpoint(endpoint) {
|
|
71
|
+
const [method, ...routeParts] = endpoint.split(" ");
|
|
72
|
+
const route = routeParts.join(" ");
|
|
73
|
+
return {
|
|
74
|
+
method: method.toLowerCase(),
|
|
75
|
+
route
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
function createTypedFetcher(options) {
|
|
80
|
+
const fetcher = new TypedFetcher(options);
|
|
81
|
+
return (endpoint, config) => fetcher.request(endpoint, config);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
//#endregion
|
|
85
|
+
export { TypedFetcher, createTypedFetcher };
|
|
86
|
+
//# sourceMappingURL=fetcher-DLDD_7Sa.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"fetcher-DLDD_7Sa.mjs","names":["fn?: FetchFn","options: FetcherOptions","endpoint: T","config?: FilteredRequestConfig<Paths, T>","prefix: string","value: unknown","requestConfig: RequestInit","options?: FetcherOptions"],"sources":["../src/fetcher.ts"],"sourcesContent":["import type {\n EndpointString,\n ExtractEndpointResponse,\n FetcherOptions,\n FilteredRequestConfig,\n ParseEndpoint,\n TypedEndpoint,\n} from './types';\n\nexport class TypedFetcher<Paths> {\n private baseURL: string;\n private defaultHeaders: Record<string, string>;\n private options: FetcherOptions;\n private fetchFn: FetchFn;\n\n static getFetchFn(fn?: FetchFn): FetchFn {\n if (fn) {\n return fn;\n }\n\n if (typeof window !== 'undefined' && typeof window.fetch === 'function') {\n return window.fetch.bind(window);\n }\n\n if (\n typeof globalThis !== 'undefined' &&\n typeof globalThis.fetch === 'function'\n ) {\n return globalThis.fetch.bind(globalThis);\n }\n\n throw new Error('No fetch implementation found');\n }\n\n constructor(options: FetcherOptions = {}) {\n this.baseURL = options.baseURL || '';\n this.defaultHeaders = options.headers || {};\n this.options = options;\n this.fetchFn = TypedFetcher.getFetchFn(options.fetch);\n }\n\n async request<T extends TypedEndpoint<Paths>>(\n endpoint: T,\n config?: FilteredRequestConfig<Paths, T>,\n ): Promise<ExtractEndpointResponse<Paths, T>> {\n const { method, route } = this.parseEndpoint(endpoint);\n\n // Replace path parameters\n let url = route;\n if (config && 'params' in config && config.params) {\n Object.entries(config.params as Record<string, unknown>).forEach(\n ([key, value]) => {\n url = url.replace(`{${key}}`, encodeURIComponent(String(value)));\n },\n );\n }\n\n // Add query parameters\n if (config && 'query' in config && config.query) {\n const queryParams = new URLSearchParams();\n\n // Recursive function to handle nested objects and arrays\n const appendQueryParam = (prefix: string, value: unknown) => {\n if (value === undefined || value === null) {\n return;\n }\n\n if (Array.isArray(value)) {\n // Handle arrays by appending multiple values with the same key\n value.forEach((item) => {\n queryParams.append(prefix, String(item));\n });\n } else if (typeof value === 'object') {\n // For objects, recursively flatten into dot notation\n Object.entries(value as Record<string, unknown>).forEach(\n ([subKey, subValue]) => {\n appendQueryParam(`${prefix}.${subKey}`, subValue);\n },\n );\n } else {\n queryParams.append(prefix, String(value));\n }\n };\n\n // Process all query parameters\n Object.entries(config.query as Record<string, unknown>).forEach(\n ([key, value]) => {\n appendQueryParam(key, value);\n },\n );\n\n const queryString = queryParams.toString();\n if (queryString) {\n url += `?${queryString}`;\n }\n }\n\n // Build request configuration\n let requestConfig: RequestInit = {\n method: method.toUpperCase(),\n headers: {\n ...this.defaultHeaders,\n ...((config && 'headers' in config && config.headers) || {}),\n },\n };\n\n // Add body if present\n if (config && 'body' in config && config.body) {\n requestConfig.body = JSON.stringify(config.body);\n requestConfig.headers = {\n ...requestConfig.headers,\n 'Content-Type': 'application/json',\n };\n }\n\n // Apply request interceptor\n if (this.options.onRequest) {\n requestConfig = await this.options.onRequest(requestConfig);\n }\n\n try {\n // Make the request\n let response = await this.fetchFn(`${this.baseURL}${url}`, requestConfig);\n\n // Apply response interceptor\n if (this.options.onResponse) {\n response = await this.options.onResponse(response);\n }\n\n // Handle errors\n if (!response.ok) {\n throw response;\n }\n\n // Handle empty responses (204 No Content, etc.)\n if (\n response.status === 204 ||\n response.headers.get('content-length') === '0'\n ) {\n return undefined as ExtractEndpointResponse<Paths, T>;\n }\n\n // Parse JSON response\n const data = await response.json();\n return data as ExtractEndpointResponse<Paths, T>;\n } catch (error) {\n // Apply error handler\n if (this.options.onError) {\n // @ts-ignore\n await this.options.onError(error);\n }\n throw error;\n }\n }\n\n private parseEndpoint<T extends EndpointString>(\n endpoint: T,\n ): ParseEndpoint<T> {\n const [method, ...routeParts] = endpoint.split(' ');\n const route = routeParts.join(' ');\n return { method: method.toLowerCase(), route } as ParseEndpoint<T>;\n }\n}\n\nexport function createTypedFetcher<Paths>(options?: FetcherOptions) {\n const fetcher = new TypedFetcher<Paths>(options);\n return <T extends TypedEndpoint<Paths>>(\n endpoint: T,\n config?: FilteredRequestConfig<Paths, T>,\n ) => fetcher.request(endpoint, config);\n}\n\nexport type FetchFn = typeof fetch;\n"],"mappings":"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"}
|