@sapporta/rest-core 3.52.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,1330 @@
1
+ import * as fetchMock from 'fetch-mock-jest';
2
+ import {
3
+ FetchOptions,
4
+ HTTPStatusCode,
5
+ initContract,
6
+ OverridableClientArgs,
7
+ StandardSchemaError,
8
+ } from '..';
9
+ import { ApiFetcherArgs, initClient, getCompleteUrl } from './client';
10
+ import { Equal, Expect } from './test-helpers';
11
+ import { z, ZodError } from 'zod';
12
+ import * as v from 'valibot';
13
+
14
+ const c = initContract();
15
+
16
+ const postSchema = z.object({
17
+ id: z.string(),
18
+ title: z.string(),
19
+ description: z.string().nullable(),
20
+ content: z.string().nullable(),
21
+ published: z.boolean(),
22
+ authorId: z.string(),
23
+ });
24
+ export type Post = z.infer<typeof postSchema>;
25
+
26
+ export type User = {
27
+ id: string;
28
+ email: string;
29
+ name: string | null;
30
+ };
31
+
32
+ const postsRouter = c.router({
33
+ getPost: {
34
+ method: 'GET',
35
+ path: `/posts/:id`,
36
+ headers: {
37
+ 'x-api-key': z.string().optional(),
38
+ },
39
+ responses: {
40
+ 200: postSchema.nullable(),
41
+ },
42
+ },
43
+ getPostWithCoercedParams: {
44
+ method: 'GET',
45
+ path: `/posts/:id`,
46
+ pathParams: z.object({
47
+ id: z.coerce.number().optional(),
48
+ }),
49
+ responses: {
50
+ 200: postSchema.nullable(),
51
+ },
52
+ },
53
+ getPosts: {
54
+ method: 'GET',
55
+ path: '/posts',
56
+ headers: {
57
+ 'x-pagination': z.coerce.number().optional(),
58
+ },
59
+ responses: {
60
+ 200: c.type<Post[]>(),
61
+ },
62
+ query: z.object({
63
+ take: z.number().optional(),
64
+ skip: z.number().optional(),
65
+ order: z.string().optional(),
66
+ }),
67
+ },
68
+ createPost: {
69
+ method: 'POST',
70
+ path: '/posts',
71
+ responses: {
72
+ 200: c.type<Post>(),
73
+ },
74
+ body: z.object({
75
+ title: z.string(),
76
+ content: z.string(),
77
+ published: z.boolean().optional(),
78
+ description: z.string().optional(),
79
+ authorId: z.string(),
80
+ }),
81
+ },
82
+ echoPostXForm: {
83
+ method: 'POST',
84
+ path: '/echo',
85
+ contentType: 'application/x-www-form-urlencoded',
86
+ body: z.object({
87
+ foo: z.string(),
88
+ bar: z.string(),
89
+ }),
90
+ responses: {
91
+ 200: c.otherResponse({
92
+ contentType: 'text/plain',
93
+ body: z.string(),
94
+ }),
95
+ },
96
+ },
97
+ mutationWithQuery: {
98
+ method: 'POST',
99
+ path: '/posts',
100
+ responses: {
101
+ 200: c.type<Post>(),
102
+ },
103
+ body: z.object({}),
104
+ query: z.object({
105
+ test: z.string(),
106
+ }),
107
+ },
108
+ updatePost: {
109
+ method: 'PUT',
110
+ path: `/posts/:id`,
111
+ responses: {
112
+ 200: c.type<Post>(),
113
+ },
114
+ body: z.object({
115
+ title: z.string(),
116
+ content: z.string(),
117
+ published: z.boolean().optional(),
118
+ description: z.string().optional(),
119
+ authorId: z.string(),
120
+ }),
121
+ },
122
+ patchPost: {
123
+ method: 'PATCH',
124
+ path: `/posts/:id`,
125
+ responses: {
126
+ 200: c.type<Post>(),
127
+ },
128
+ headers: {
129
+ 'content-type': z.literal('application/merge-patch+json'),
130
+ },
131
+ body: z.object({}).passthrough(),
132
+ },
133
+ deletePost: {
134
+ method: 'DELETE',
135
+ path: `/posts/:id`,
136
+ body: c.noBody(),
137
+ responses: {
138
+ 204: c.noBody(),
139
+ },
140
+ },
141
+ deletePostUndefinedBody: {
142
+ method: 'DELETE',
143
+ path: `/posts/:id`,
144
+ responses: {
145
+ 204: c.noBody(),
146
+ },
147
+ },
148
+ });
149
+
150
+ // Three endpoints, two for posts, and one for health
151
+ export const router = c.router(
152
+ {
153
+ posts: postsRouter,
154
+ health: {
155
+ method: 'GET',
156
+ path: '/health',
157
+ responses: {
158
+ 200: c.type<{ message: string }>(),
159
+ },
160
+ },
161
+ upload: {
162
+ method: 'POST',
163
+ path: '/upload',
164
+ body: c.type<{ file: File }>(),
165
+ responses: {
166
+ 200: c.type<{ message: string }>(),
167
+ },
168
+ contentType: 'multipart/form-data',
169
+ },
170
+ uploadArray: {
171
+ method: 'POST',
172
+ path: '/upload-array',
173
+ body: c.type<{ files: File[] }>(),
174
+ responses: {
175
+ 200: c.type<{ message: string }>(),
176
+ },
177
+ contentType: 'multipart/form-data',
178
+ },
179
+ },
180
+ {
181
+ baseHeaders: {
182
+ 'x-api-key': z.string(),
183
+ 'x-test': z.string().optional(),
184
+ 'base-header': z.string().optional(),
185
+ },
186
+ },
187
+ );
188
+
189
+ const routerStrict = c.router(router, {
190
+ strictStatusCodes: true,
191
+ });
192
+
193
+ const client = initClient(router, {
194
+ baseUrl: 'https://api.com/',
195
+ baseHeaders: {
196
+ 'X-Api-Key': 'foo',
197
+ },
198
+ });
199
+
200
+ const clientWithoutBaseHeaders = initClient(router, {
201
+ baseUrl: 'https://api.com',
202
+ });
203
+
204
+ const clientStrict = initClient(routerStrict, {
205
+ baseUrl: 'https://api.com',
206
+ baseHeaders: {
207
+ 'X-Api-Key': 'foo',
208
+ },
209
+ });
210
+
211
+ /**
212
+ * @name ClientGetPostsWithBaseHeaders
213
+ * Expect the client.posts.getPosts parameters to be optional when base headers are provided,
214
+ * allowing all headers to be optional since they're merged with base headers
215
+ */
216
+ type ClientGetPostsWithBaseHeaders = Parameters<
217
+ typeof client.posts.getPosts
218
+ >[0];
219
+ type TestClientGetPostsWithBaseHeaders = Expect<
220
+ Equal<
221
+ ClientGetPostsWithBaseHeaders,
222
+ | {
223
+ query?: {
224
+ take?: number;
225
+ skip?: number;
226
+ order?: string;
227
+ };
228
+ headers?: {
229
+ 'x-pagination'?: unknown;
230
+ 'x-test'?: string | undefined;
231
+ 'base-header'?: string | undefined;
232
+ 'x-api-key'?: string | undefined;
233
+ };
234
+ extraHeaders?: {
235
+ 'x-pagination'?: undefined;
236
+ 'x-test'?: undefined;
237
+ 'base-header'?: undefined;
238
+ 'x-api-key'?: undefined;
239
+ } & Record<string, string>;
240
+ fetchOptions?: FetchOptions;
241
+ overrideClientOptions?: Partial<OverridableClientArgs>;
242
+ cache?: FetchOptions['cache'];
243
+ }
244
+ | undefined
245
+ >
246
+ >;
247
+
248
+ it('should require header when no base headers are provided', () => {
249
+ type Actual = Parameters<typeof clientWithoutBaseHeaders.posts.getPosts>[0];
250
+ type Assert = Expect<
251
+ Equal<
252
+ Actual,
253
+ {
254
+ headers: {
255
+ 'x-api-key': string;
256
+ 'x-pagination'?: unknown;
257
+ 'x-test'?: string | undefined;
258
+ 'base-header'?: string | undefined;
259
+ };
260
+ cache?: RequestCache | undefined;
261
+ fetchOptions?: FetchOptions | undefined;
262
+ extraHeaders?:
263
+ | ({
264
+ 'x-pagination'?: undefined;
265
+ 'x-test'?: undefined;
266
+ 'base-header'?: undefined;
267
+ 'x-api-key'?: undefined;
268
+ } & Record<string, string>)
269
+ | undefined;
270
+ overrideClientOptions?: Partial<OverridableClientArgs> | undefined;
271
+ query?:
272
+ | {
273
+ take?: number | undefined;
274
+ skip?: number | undefined;
275
+ order?: string | undefined;
276
+ }
277
+ | undefined;
278
+ }
279
+ >
280
+ >;
281
+ });
282
+
283
+ /**
284
+ * @name ClientGetPostWithParams
285
+ * Expect the client.posts.getPost parameters to require params for path parameters,
286
+ * while headers remain optional due to base headers being provided
287
+ */
288
+ type ClientGetPostWithParams = Omit<
289
+ Parameters<typeof client.posts.getPost>[0],
290
+ 'next'
291
+ >;
292
+ type TestClientGetPostWithParams = Expect<
293
+ Equal<
294
+ ClientGetPostWithParams,
295
+ {
296
+ params: {
297
+ id: string;
298
+ };
299
+ headers?: {
300
+ 'x-test'?: string;
301
+ 'base-header'?: string;
302
+ 'x-api-key'?: string;
303
+ };
304
+ extraHeaders?: {
305
+ 'x-test'?: never;
306
+ 'base-header'?: never;
307
+ 'x-api-key'?: never;
308
+ } & Record<string, string>;
309
+ fetchOptions?: FetchOptions;
310
+ overrideClientOptions?: Partial<OverridableClientArgs>;
311
+ cache?: FetchOptions['cache'];
312
+ }
313
+ >
314
+ >;
315
+
316
+ /**
317
+ * @name RouterStrictStatusCodesHealth
318
+ * Expect the router with strict status codes to have strictStatusCodes property set to true
319
+ * for the health endpoint
320
+ */
321
+ type RouterStrictStatusCodesHealth =
322
+ (typeof routerStrict.health)['strictStatusCodes'];
323
+ type TestRouterStrictStatusCodesHealth = Expect<
324
+ Equal<RouterStrictStatusCodesHealth, true>
325
+ >;
326
+
327
+ /**
328
+ * @name RouterStrictStatusCodesGetPost
329
+ * Expect the router with strict status codes to have strictStatusCodes property set to true
330
+ * for the nested posts.getPost endpoint
331
+ */
332
+ type RouterStrictStatusCodesGetPost =
333
+ (typeof routerStrict.posts.getPost)['strictStatusCodes'];
334
+ type TestRouterStrictStatusCodesGetPost = Expect<
335
+ Equal<RouterStrictStatusCodesGetPost, true>
336
+ >;
337
+
338
+ /**
339
+ * @name ClientStrictHealthResponse
340
+ * Expect the strict client health response to have exact status code type (200) instead of union,
341
+ * demonstrating strict status code enforcement
342
+ */
343
+ type ClientStrictHealthResponse = Awaited<
344
+ ReturnType<typeof clientStrict.health>
345
+ >;
346
+ type TestClientStrictHealthResponse = Expect<
347
+ Equal<
348
+ ClientStrictHealthResponse,
349
+ { status: 200; body: { message: string }; headers: Headers }
350
+ >
351
+ >;
352
+
353
+ describe('client', () => {
354
+ beforeEach(() => {
355
+ fetchMock.mockReset();
356
+ });
357
+
358
+ describe('get', () => {
359
+ it('w/ no parameters', async () => {
360
+ const value = { key: 'value' };
361
+ fetchMock.getOnce(
362
+ {
363
+ url: 'https://api.com/posts',
364
+ },
365
+ { body: value, status: 200 },
366
+ );
367
+
368
+ const result = await client.posts.getPosts({});
369
+
370
+ expect(result.body).toStrictEqual(value);
371
+ expect(result.status).toBe(200);
372
+ expect(result.headers.get('Content-Length')).toBe('15');
373
+ expect(result.headers.get('Content-Type')).toBe('application/json');
374
+ });
375
+
376
+ it('w/ no query parameters', async () => {
377
+ const value = { key: 'value' };
378
+ fetchMock.getOnce(
379
+ {
380
+ url: 'https://api.com/posts',
381
+ },
382
+ { body: value, status: 200 },
383
+ );
384
+
385
+ const result = await client.posts.getPosts({ query: {} });
386
+
387
+ expect(result.body).toStrictEqual(value);
388
+ expect(result.status).toBe(200);
389
+ expect(result.headers.get('Content-Length')).toBe('15');
390
+ expect(result.headers.get('Content-Type')).toBe('application/json');
391
+ });
392
+
393
+ it('w/ no parameters (not provided)', async () => {
394
+ const value = { key: 'value' };
395
+ fetchMock.getOnce(
396
+ {
397
+ url: 'https://api.com/posts',
398
+ },
399
+ { body: value, status: 200 },
400
+ );
401
+
402
+ const result = await client.posts.getPosts();
403
+
404
+ expect(result.body).toStrictEqual(value);
405
+ expect(result.status).toBe(200);
406
+ expect(result.headers.get('Content-Length')).toBe('15');
407
+ expect(result.headers.get('Content-Type')).toBe('application/json');
408
+ });
409
+
410
+ it('w/ query parameters', async () => {
411
+ const value = { key: 'value' };
412
+ fetchMock.getOnce(
413
+ {
414
+ url: 'https://api.com/posts?take=10',
415
+ },
416
+ { body: value, status: 200 },
417
+ );
418
+
419
+ const result = await client.posts.getPosts({ query: { take: 10 } });
420
+
421
+ expect(result.body).toStrictEqual(value);
422
+ expect(result.status).toBe(200);
423
+ expect(result.headers.get('Content-Length')).toBe('15');
424
+ expect(result.headers.get('Content-Type')).toBe('application/json');
425
+ });
426
+
427
+ it('w/ json query parameters', async () => {
428
+ const client = initClient(
429
+ c.router({
430
+ getPosts: {
431
+ ...postsRouter.getPosts,
432
+ query: postsRouter.getPosts.query.extend({
433
+ published: z.boolean(),
434
+ filter: z.object({
435
+ title: z.string(),
436
+ }),
437
+ }),
438
+ },
439
+ }),
440
+ {
441
+ baseUrl: 'https://api.com',
442
+ baseHeaders: {},
443
+ jsonQuery: true,
444
+ },
445
+ );
446
+
447
+ const value = { key: 'value' };
448
+ fetchMock.getOnce(
449
+ {
450
+ url: `https://api.com/posts?take=10&order=asc&published=true&filter=${encodeURIComponent(
451
+ '{"title":"test"}',
452
+ )}`,
453
+ },
454
+ { body: value, status: 200 },
455
+ );
456
+
457
+ const result = await client.getPosts({
458
+ query: {
459
+ take: 10,
460
+ order: 'asc',
461
+ published: true,
462
+ filter: { title: 'test' },
463
+ },
464
+ });
465
+
466
+ expect(result.body).toStrictEqual(value);
467
+ expect(result.status).toBe(200);
468
+ expect(result.headers.get('Content-Length')).toBe('15');
469
+ expect(result.headers.get('Content-Type')).toBe('application/json');
470
+ });
471
+
472
+ it('w/ undefined query parameters', async () => {
473
+ const value = { key: 'value' };
474
+ fetchMock.getOnce(
475
+ {
476
+ url: 'https://api.com/posts?take=10',
477
+ },
478
+ { body: value, status: 200 },
479
+ );
480
+
481
+ const result = await client.posts.getPosts({
482
+ query: { take: 10, skip: undefined },
483
+ });
484
+
485
+ expect(result.body).toStrictEqual(value);
486
+ expect(result.status).toBe(200);
487
+ expect(result.headers.get('Content-Length')).toBe('15');
488
+ });
489
+
490
+ it('w/ sub path', async () => {
491
+ const value = { key: 'value' };
492
+ fetchMock.getOnce(
493
+ {
494
+ url: 'https://api.com/posts/1',
495
+ },
496
+ { body: value, status: 200 },
497
+ );
498
+
499
+ const result = await client.posts.getPost({ params: { id: '1' } });
500
+
501
+ expect(result.body).toStrictEqual(value);
502
+ expect(result.status).toBe(200);
503
+ expect(result.headers.get('Content-Length')).toBe('15');
504
+ });
505
+
506
+ describe('coerced params', () => {
507
+ it('w/ sub path with non zero', async () => {
508
+ const value = { key: 'value' };
509
+ fetchMock.getOnce(
510
+ {
511
+ url: 'https://api.com/posts/1',
512
+ },
513
+ { body: value, status: 200 },
514
+ );
515
+
516
+ const result = await client.posts.getPostWithCoercedParams({
517
+ params: { id: 1 },
518
+ });
519
+
520
+ expect(result.body).toStrictEqual(value);
521
+ expect(result.status).toBe(200);
522
+ expect(result.headers.get('Content-Length')).toBe('15');
523
+ });
524
+
525
+ it('w/ sub path with zero', async () => {
526
+ const value = { key: 'value' };
527
+ fetchMock.getOnce(
528
+ {
529
+ url: 'https://api.com/posts/0',
530
+ },
531
+ { body: value, status: 200 },
532
+ );
533
+
534
+ const result = await client.posts.getPostWithCoercedParams({
535
+ params: { id: 0 },
536
+ });
537
+
538
+ expect(result.body).toStrictEqual(value);
539
+ expect(result.status).toBe(200);
540
+ expect(result.headers.get('Content-Length')).toBe('15');
541
+ });
542
+
543
+ it('w/ sub path with undefined', async () => {
544
+ const value = { key: 'value' };
545
+ fetchMock.getOnce(
546
+ {
547
+ url: 'https://api.com/posts/undefined',
548
+ },
549
+ { body: value, status: 200 },
550
+ );
551
+
552
+ const result = await client.posts.getPostWithCoercedParams({
553
+ params: { id: undefined },
554
+ });
555
+
556
+ expect(result.body).toStrictEqual(value);
557
+ expect(result.status).toBe(200);
558
+ expect(result.headers.get('Content-Length')).toBe('15');
559
+ });
560
+ });
561
+
562
+ it('w/ a non json response (string, text/plain)', async () => {
563
+ fetchMock.getOnce(
564
+ {
565
+ url: 'https://api.com/posts',
566
+ },
567
+ {
568
+ headers: {
569
+ 'Content-Type': 'text/plain',
570
+ },
571
+ body: 'string',
572
+ status: 200,
573
+ },
574
+ );
575
+
576
+ const result = await client.posts.getPosts({});
577
+
578
+ expect(result.body).toStrictEqual('string');
579
+ expect(result.status).toBe(200);
580
+ expect(result.headers.get('Content-Length')).toBe('6');
581
+ expect(result.headers.get('Content-Type')).toBe('text/plain');
582
+ });
583
+ });
584
+
585
+ it('w/ a non json response (string, text/html)', async () => {
586
+ fetchMock.getOnce(
587
+ {
588
+ url: 'https://api.com/posts',
589
+ },
590
+ {
591
+ headers: {
592
+ 'Content-Type': 'text/html',
593
+ },
594
+ body: 'string',
595
+ status: 200,
596
+ },
597
+ );
598
+
599
+ const result = await client.posts.getPosts({});
600
+
601
+ expect(result.body).toStrictEqual('string');
602
+ expect(result.status).toBe(200);
603
+ expect(result.headers.get('Content-Length')).toBe('6');
604
+ expect(result.headers.get('Content-Type')).toBe('text/html');
605
+ });
606
+
607
+ describe('post', () => {
608
+ it('w/ body', async () => {
609
+ const value = { key: 'value' };
610
+ fetchMock.postOnce(
611
+ {
612
+ url: 'https://api.com/posts',
613
+ headers: {
614
+ 'Content-Type': 'application/json',
615
+ },
616
+ },
617
+ { body: value, status: 200 },
618
+ );
619
+
620
+ const result = await client.posts.createPost({
621
+ body: { title: 'title', content: 'content', authorId: 'authorId' },
622
+ });
623
+
624
+ expect(result.body).toStrictEqual(value);
625
+ expect(result.status).toBe(200);
626
+ expect(result.headers.get('Content-Length')).toBe('15');
627
+ expect(result.headers.get('Content-Type')).toBe('application/json');
628
+ });
629
+
630
+ it('w/ query params', async () => {
631
+ fetchMock.postOnce(
632
+ {
633
+ url: 'https://api.com/posts?test=test',
634
+ headers: {
635
+ 'Content-Type': 'application/json',
636
+ },
637
+ body: {},
638
+ },
639
+ { body: {}, status: 200 },
640
+ );
641
+
642
+ const result = await client.posts.mutationWithQuery({
643
+ query: { test: 'test' },
644
+ body: {},
645
+ });
646
+
647
+ expect(result.body).toStrictEqual({});
648
+ expect(result.status).toBe(200);
649
+ expect(result.headers.get('Content-Length')).toBe('2');
650
+ expect(result.headers.get('Content-Type')).toBe('application/json');
651
+ });
652
+ });
653
+
654
+ describe('put', () => {
655
+ it('w/ sub path and body', async () => {
656
+ const value = { key: 'value' };
657
+ fetchMock.putOnce(
658
+ {
659
+ url: 'https://api.com/posts/1',
660
+ headers: {
661
+ 'Content-Type': 'application/json',
662
+ },
663
+ body: {
664
+ title: 'title',
665
+ content: 'content',
666
+ authorId: 'authorId',
667
+ },
668
+ },
669
+ { body: value, status: 200 },
670
+ );
671
+
672
+ const result = await client.posts.updatePost({
673
+ params: { id: '1' },
674
+ body: { title: 'title', content: 'content', authorId: 'authorId' },
675
+ });
676
+
677
+ expect(result.body).toStrictEqual(value);
678
+ expect(result.status).toBe(200);
679
+ expect(result.headers.get('Content-Length')).toBe('15');
680
+ expect(result.headers.get('Content-Type')).toBe('application/json');
681
+ });
682
+ });
683
+
684
+ describe('patch', () => {
685
+ it('w/ body', async () => {
686
+ const value = { key: 'value' };
687
+
688
+ fetchMock.patchOnce(
689
+ {
690
+ url: 'https://api.com/posts/1',
691
+ },
692
+ (_, req) => ({
693
+ body: {
694
+ contentType: (req.headers as any)['content-type'],
695
+ reqBody: JSON.parse(req.body as string),
696
+ },
697
+ status: 200,
698
+ }),
699
+ );
700
+
701
+ const result = await client.posts.patchPost({
702
+ params: { id: '1' },
703
+ headers: {
704
+ 'content-type': 'application/merge-patch+json',
705
+ },
706
+ body: value,
707
+ });
708
+
709
+ expect(result.body).toEqual({
710
+ contentType: 'application/merge-patch+json',
711
+ reqBody: value,
712
+ });
713
+ expect(result.status).toBe(200);
714
+ expect(result.headers.get('Content-Type')).toBe('application/json');
715
+ });
716
+ });
717
+
718
+ describe('delete', () => {
719
+ it('w/ no body', async () => {
720
+ fetchMock.deleteOnce(
721
+ {
722
+ url: 'https://api.com/posts/1',
723
+ },
724
+ { status: 204 },
725
+ );
726
+
727
+ const result = await client.posts.deletePost({
728
+ params: { id: '1' },
729
+ });
730
+
731
+ expect((result.body as Blob).size).toStrictEqual(0);
732
+ expect(result.status).toBe(204);
733
+ expect(result.headers.has('Content-Length')).toBe(false);
734
+ expect(result.headers.has('Content-Type')).toBe(false);
735
+ });
736
+
737
+ it('w/ undefined body', async () => {
738
+ fetchMock.deleteOnce(
739
+ {
740
+ url: 'https://api.com/posts/1',
741
+ },
742
+ { status: 204 },
743
+ );
744
+
745
+ const result = await client.posts.deletePostUndefinedBody({
746
+ params: { id: '1' },
747
+ });
748
+
749
+ expect((result.body as Blob).size).toStrictEqual(0);
750
+ expect(result.status).toBe(204);
751
+ expect(result.headers.has('Content-Length')).toBe(false);
752
+ expect(result.headers.has('Content-Type')).toBe(false);
753
+ });
754
+
755
+ it('w/ undefined body and content-type json', async () => {
756
+ fetchMock.deleteOnce(
757
+ {
758
+ url: 'https://api.com/posts/2',
759
+ },
760
+ {
761
+ status: 204,
762
+ headers: {
763
+ 'Content-Type': 'application/json; charset=utf-8',
764
+ },
765
+ },
766
+ );
767
+
768
+ const result = await client.posts.deletePostUndefinedBody({
769
+ params: { id: '2' },
770
+ });
771
+
772
+ expect(result.body).toBeUndefined();
773
+ expect(result.status).toBe(204);
774
+ expect(result.headers.has('Content-Length')).toBe(false);
775
+ expect(result.headers.get('Content-Type')).toBe(
776
+ 'application/json; charset=utf-8',
777
+ );
778
+ });
779
+ });
780
+
781
+ describe('multipart/form-data', () => {
782
+ it('w/ body', async () => {
783
+ const value = { key: 'value' };
784
+ fetchMock.postOnce(
785
+ {
786
+ url: 'https://api.com/upload',
787
+ },
788
+ { body: value, status: 200 },
789
+ );
790
+
791
+ const file = new File([''], 'filename', { type: 'text/plain' });
792
+
793
+ const result = await client.upload({
794
+ body: { file },
795
+ });
796
+
797
+ expect(result.body).toStrictEqual(value);
798
+ expect(result.status).toBe(200);
799
+ expect(result.headers.get('Content-Length')).toBe('15');
800
+ expect(result.headers.get('Content-Type')).toBe('application/json');
801
+
802
+ expect(fetchMock).toHaveLastFetched(true, {
803
+ matcher: (_, options) => {
804
+ const formData = options.body as FormData;
805
+ return formData.get('file') === file;
806
+ },
807
+ });
808
+ });
809
+
810
+ it('w/ FormData', async () => {
811
+ const value = { key: 'value' };
812
+ fetchMock.postOnce(
813
+ {
814
+ url: 'https://api.com/upload',
815
+ },
816
+ { body: value, status: 200 },
817
+ );
818
+
819
+ const formData = new FormData();
820
+ formData.append('test', 'test');
821
+
822
+ const result = await client.upload({
823
+ body: formData,
824
+ });
825
+
826
+ expect(result.body).toStrictEqual(value);
827
+ expect(result.status).toBe(200);
828
+ expect(result.headers.get('Content-Length')).toBe('15');
829
+ expect(result.headers.get('Content-Type')).toBe('application/json');
830
+
831
+ expect(fetchMock).toHaveLastFetched(true, {
832
+ matcher: (_, options) => {
833
+ const formData = options.body as FormData;
834
+ return formData.get('test') === 'test';
835
+ },
836
+ });
837
+ });
838
+
839
+ it('w/ File Array', async () => {
840
+ const value = { key: 'value' };
841
+ fetchMock.postOnce(
842
+ {
843
+ url: 'https://api.com/upload-array',
844
+ },
845
+ { body: value, status: 200 },
846
+ );
847
+
848
+ const files = [
849
+ new File([''], 'filename-1', { type: 'text/plain' }),
850
+ new File([''], 'filename-2', { type: 'text/plain' }),
851
+ ];
852
+
853
+ const result = await client.uploadArray({
854
+ body: { files },
855
+ });
856
+
857
+ expect(result.body).toStrictEqual(value);
858
+ expect(result.status).toBe(200);
859
+ expect(result.headers.get('Content-Length')).toBe('15');
860
+ expect(result.headers.get('Content-Type')).toBe('application/json');
861
+
862
+ expect(fetchMock).toHaveLastFetched(true, {
863
+ matcher: (_, options) => {
864
+ const formData = options.body as FormData;
865
+ const formDataFiles = formData.getAll('files');
866
+ return formDataFiles[0] === files[0] && formDataFiles[1] === files[1];
867
+ },
868
+ });
869
+ });
870
+ });
871
+
872
+ describe('application/x-www-form-urlencoded', () => {
873
+ it('w/object', async () => {
874
+ fetchMock.postOnce(
875
+ {
876
+ url: 'https://api.com/echo',
877
+ headers: {
878
+ 'content-type': 'application/x-www-form-urlencoded',
879
+ },
880
+ },
881
+ (_, req) => {
882
+ expect(req.body).toBeInstanceOf(URLSearchParams);
883
+
884
+ return {
885
+ body: req.body!.toString(),
886
+ status: 200,
887
+ };
888
+ },
889
+ );
890
+
891
+ const result = await client.posts.echoPostXForm({
892
+ body: {
893
+ foo: 'foo',
894
+ bar: 'bar',
895
+ },
896
+ });
897
+
898
+ expect(result.status).toBe(200);
899
+ expect(result.headers.get('Content-Type')).toBe(
900
+ 'text/plain;charset=UTF-8',
901
+ );
902
+ expect(result.body).toBe('foo=foo&bar=bar');
903
+ });
904
+
905
+ it('w/string', async () => {
906
+ fetchMock.postOnce(
907
+ {
908
+ url: 'https://api.com/echo',
909
+ headers: {
910
+ 'content-type': 'application/x-www-form-urlencoded',
911
+ },
912
+ },
913
+ (_, req) => {
914
+ expect(typeof req.body).toBe('string');
915
+
916
+ return {
917
+ body: req.body,
918
+ status: 200,
919
+ };
920
+ },
921
+ );
922
+
923
+ const result = await client.posts.echoPostXForm({
924
+ body: 'foo=foo&bar=bar',
925
+ });
926
+
927
+ expect(result.status).toBe(200);
928
+ expect(result.headers.get('Content-Type')).toBe(
929
+ 'text/plain;charset=UTF-8',
930
+ );
931
+ expect(result.body).toBe('foo=foo&bar=bar');
932
+ });
933
+ });
934
+
935
+ describe('next', () => {
936
+ it('should include "next" property in the fetch request', async () => {
937
+ const client = initClient(router, {
938
+ baseHeaders: {},
939
+ baseUrl: 'http://localhost:5002',
940
+ });
941
+
942
+ globalThis.fetch = jest.fn(() =>
943
+ Promise.resolve({
944
+ json: () =>
945
+ Promise.resolve({
946
+ id: '1',
947
+ name: 'John',
948
+ email: 'some@email',
949
+ }),
950
+ headers: new Headers({
951
+ 'content-type': 'application/json',
952
+ }),
953
+ } as Response),
954
+ );
955
+
956
+ await client.posts.getPost({
957
+ params: { id: '1' },
958
+ fetchOptions: {
959
+ next: {
960
+ revalidate: 1,
961
+ tags: ['user1'],
962
+ },
963
+ } as RequestInit,
964
+ });
965
+
966
+ expect(globalThis.fetch).toHaveBeenCalledWith(
967
+ 'http://localhost:5002/posts/1',
968
+ {
969
+ cache: undefined,
970
+ headers: {},
971
+ body: undefined,
972
+ credentials: undefined,
973
+ method: 'GET',
974
+ signal: undefined,
975
+ next: {
976
+ revalidate: 1,
977
+ tags: ['user1'],
978
+ },
979
+ },
980
+ );
981
+ (globalThis.fetch as jest.Mock).mockClear();
982
+ });
983
+ });
984
+ });
985
+
986
+ const argsCalledMock = jest.fn();
987
+
988
+ const customClient = initClient(router, {
989
+ baseUrl: 'https://api.com',
990
+ baseHeaders: {
991
+ 'Base-Header': 'foo',
992
+ },
993
+ api: async (
994
+ args: ApiFetcherArgs & { uploadProgress?: (progress: number) => void },
995
+ ) => {
996
+ args.uploadProgress?.(10);
997
+
998
+ // Do something with the path, body, etc.
999
+
1000
+ args.uploadProgress?.(100);
1001
+
1002
+ argsCalledMock(args);
1003
+
1004
+ return {
1005
+ status: 200,
1006
+ body: { message: 'Hello' },
1007
+ headers: new Headers(),
1008
+ };
1009
+ },
1010
+ });
1011
+
1012
+ /**
1013
+ * @name CustomClientGetPostsWithUploadProgress
1014
+ * Expect the custom client.posts.getPosts to include uploadProgress callback in parameters
1015
+ * when using a custom API implementation that supports upload progress tracking
1016
+ */
1017
+ type CustomClientGetPostsWithUploadProgress = Pick<
1018
+ Parameters<typeof customClient.posts.getPosts>[0],
1019
+ 'uploadProgress'
1020
+ >;
1021
+ type TestCustomClientGetPostsWithUploadProgress = Expect<
1022
+ Equal<
1023
+ CustomClientGetPostsWithUploadProgress,
1024
+ {
1025
+ uploadProgress?: (progress: number) => void;
1026
+ }
1027
+ >
1028
+ >;
1029
+
1030
+ describe('custom api', () => {
1031
+ beforeEach(() => {
1032
+ argsCalledMock.mockReset();
1033
+ fetchMock.mockReset();
1034
+ });
1035
+
1036
+ it('should allow a uploadProgress attribute on the api call', async () => {
1037
+ const uploadProgress = jest.fn();
1038
+ await customClient.posts.getPost({
1039
+ params: { id: '1' },
1040
+ uploadProgress,
1041
+ });
1042
+ expect(uploadProgress).toBeCalledWith(10);
1043
+ expect(uploadProgress).toBeCalledWith(100);
1044
+
1045
+ expect(argsCalledMock).toBeCalledWith(
1046
+ expect.objectContaining({
1047
+ uploadProgress,
1048
+ }),
1049
+ );
1050
+ });
1051
+
1052
+ it('should allow extra headers to be passed in', async () => {
1053
+ await customClient.posts.getPost({
1054
+ params: { id: '1' },
1055
+ headers: {
1056
+ 'x-test': 'test',
1057
+ },
1058
+ });
1059
+
1060
+ expect(argsCalledMock).toBeCalledWith(
1061
+ expect.objectContaining({
1062
+ headers: {
1063
+ 'base-header': 'foo',
1064
+ 'x-test': 'test',
1065
+ },
1066
+ }),
1067
+ );
1068
+ });
1069
+
1070
+ it('extra headers should override base headers', async () => {
1071
+ await customClient.posts.getPost({
1072
+ params: { id: '1' },
1073
+ headers: {
1074
+ 'base-header': 'bar',
1075
+ },
1076
+ extraHeaders: {
1077
+ 'content-type': 'application/html',
1078
+ },
1079
+ });
1080
+
1081
+ expect(argsCalledMock).toBeCalledWith(
1082
+ expect.objectContaining({
1083
+ headers: {
1084
+ 'base-header': 'bar',
1085
+ 'content-type': 'application/html',
1086
+ },
1087
+ }),
1088
+ );
1089
+ });
1090
+
1091
+ it('works for mutations', async () => {
1092
+ await customClient.posts.mutationWithQuery({
1093
+ query: { test: 'test' },
1094
+ body: {},
1095
+ headers: {
1096
+ 'x-api-key': '123',
1097
+ 'x-test': 'test',
1098
+ },
1099
+ uploadProgress: () => {
1100
+ // noop
1101
+ },
1102
+ });
1103
+
1104
+ expect(argsCalledMock).toBeCalledWith(
1105
+ expect.objectContaining({
1106
+ headers: {
1107
+ 'x-api-key': '123',
1108
+ 'base-header': 'foo',
1109
+ 'content-type': 'application/json',
1110
+ 'x-test': 'test',
1111
+ },
1112
+ uploadProgress: expect.any(Function),
1113
+ }),
1114
+ );
1115
+ });
1116
+
1117
+ it('has correct types when throwOnUnknownStatus only is configured', async () => {
1118
+ const client = initClient(router, {
1119
+ baseUrl: 'https://api.com',
1120
+ baseHeaders: {
1121
+ 'X-Api-Key': 'foo',
1122
+ },
1123
+ throwOnUnknownStatus: true,
1124
+ });
1125
+
1126
+ fetchMock.getOnce({ url: 'https://api.com/posts' }, { status: 200 });
1127
+
1128
+ const result = await client.posts.getPosts({});
1129
+
1130
+ /**
1131
+ * @name ClientResponseStatusWithThrowOnUnknownStatus
1132
+ * Expect the response status to be HTTPStatusCode union when throwOnUnknownStatus is enabled,
1133
+ * allowing any valid HTTP status code but enabling runtime validation
1134
+ */
1135
+ type ClientResponseStatusWithThrowOnUnknownStatus = typeof result.status;
1136
+ type TestClientResponseStatusWithThrowOnUnknownStatus = Expect<
1137
+ Equal<ClientResponseStatusWithThrowOnUnknownStatus, HTTPStatusCode>
1138
+ >;
1139
+ });
1140
+
1141
+ it('has correct types when strictStatusCode is configured', async () => {
1142
+ fetchMock.getOnce({ url: 'https://api.com/posts' }, { status: 200 });
1143
+
1144
+ const result = await clientStrict.posts.getPosts({});
1145
+
1146
+ /**
1147
+ * @name ClientResponseStatusWithStrictStatusCodes
1148
+ * Expect the response status to be exact literal type (200) when strictStatusCodes is enabled,
1149
+ * providing compile-time guarantees about response status codes
1150
+ */
1151
+ type ClientResponseStatusWithStrictStatusCodes = typeof result.status;
1152
+ type TestClientResponseStatusWithStrictStatusCodes = Expect<
1153
+ Equal<ClientResponseStatusWithStrictStatusCodes, 200>
1154
+ >;
1155
+ });
1156
+
1157
+ it('throws an error when throwOnUnknownStatus is configured and response is unknown', async () => {
1158
+ const client = initClient(router, {
1159
+ baseUrl: 'https://isolated.com',
1160
+ baseHeaders: {
1161
+ 'X-Api-Key': 'foo',
1162
+ },
1163
+ throwOnUnknownStatus: true,
1164
+ });
1165
+
1166
+ fetchMock.getOnce({ url: 'https://isolated.com/posts' }, { status: 419 });
1167
+
1168
+ await expect(client.posts.getPosts({})).rejects.toThrowError(
1169
+ 'Server returned unexpected response. Expected one of: 200 got: 419',
1170
+ );
1171
+ });
1172
+
1173
+ it('throw an error when validateResponse is configured and response is invalid', async () => {
1174
+ const client = initClient(router, {
1175
+ baseUrl: 'https://isolated.com',
1176
+ baseHeaders: {
1177
+ 'X-Api-Key': 'foo',
1178
+ },
1179
+ validateResponse: true,
1180
+ });
1181
+ fetchMock.getOnce(
1182
+ { url: 'https://isolated.com/posts/1' },
1183
+ { status: 200, body: { key: 'invalid value' } },
1184
+ );
1185
+
1186
+ await expect(
1187
+ client.posts.getPost({ params: { id: '1' } }),
1188
+ ).rejects.toThrowError(StandardSchemaError);
1189
+ });
1190
+ });
1191
+
1192
+ describe('getCompleteUrl', () => {
1193
+ describe('should avoid double slashes if both path and baseUrl have trailing slashes', () => {
1194
+ it.each([
1195
+ {
1196
+ baseUrl: 'https://api.com/',
1197
+ path: '/posts/:id',
1198
+ expected: 'https://api.com/posts/123',
1199
+ },
1200
+ {
1201
+ baseUrl: 'https://api.com',
1202
+ path: '/posts/:id',
1203
+ expected: 'https://api.com/posts/123',
1204
+ },
1205
+ {
1206
+ baseUrl: 'https://api.com',
1207
+ path: '/posts/:id',
1208
+ expected: 'https://api.com/posts/123',
1209
+ },
1210
+ {
1211
+ baseUrl: 'https://api.com/',
1212
+ path: 'posts/:id',
1213
+ expected: 'https://api.com/posts/123',
1214
+ },
1215
+ ])(
1216
+ 'should avoid double slashes if both path and baseUrl have trailing slashes',
1217
+ ({ baseUrl, path, expected }) => {
1218
+ const result = getCompleteUrl(
1219
+ null,
1220
+ baseUrl,
1221
+ { id: '123' },
1222
+ {
1223
+ method: 'GET' as const,
1224
+ responses: { 200: z.string() },
1225
+ path,
1226
+ },
1227
+ false,
1228
+ );
1229
+
1230
+ expect(result).toBe(expected);
1231
+ },
1232
+ );
1233
+ });
1234
+ });
1235
+
1236
+ describe('valibot tests ', () => {
1237
+ const contractValibot = c.router(
1238
+ {
1239
+ routeBasic: {
1240
+ method: 'GET',
1241
+ path: '/route-basic',
1242
+ responses: {
1243
+ 200: v.object({
1244
+ message: v.string(),
1245
+ }),
1246
+ },
1247
+ },
1248
+ routeRemovedApiKey: {
1249
+ method: 'GET',
1250
+ path: '/route-removed-api-key',
1251
+ responses: {
1252
+ 200: v.object({
1253
+ message: v.string(),
1254
+ }),
1255
+ },
1256
+ headers: {
1257
+ 'x-api-key': null,
1258
+ },
1259
+ },
1260
+ routeWithModifiedHeaders: {
1261
+ method: 'GET',
1262
+ path: '/route-with-modified-headers',
1263
+ responses: {
1264
+ 200: v.object({
1265
+ message: v.string(),
1266
+ }),
1267
+ },
1268
+ headers: {
1269
+ 'x-api-key': null, // removed this one
1270
+ 'x-test': v.string(), // added this one
1271
+ },
1272
+ },
1273
+ },
1274
+ {
1275
+ baseHeaders: {
1276
+ 'x-api-key': v.string(),
1277
+ },
1278
+ },
1279
+ );
1280
+ const clientValibot = initClient(contractValibot, {
1281
+ baseUrl: 'https://api.com',
1282
+ });
1283
+
1284
+ /**
1285
+ * @name HeadersRequiredWhenBaseHeaders
1286
+ * Expect the headers object is required, as the contract has base headers defined
1287
+ */
1288
+ type HeadersRequiredWhenBaseHeaders = Parameters<
1289
+ typeof clientValibot.routeBasic
1290
+ >[0];
1291
+ type TestHeadersRequiredWhenBaseHeaders = Expect<
1292
+ Equal<
1293
+ HeadersRequiredWhenBaseHeaders['headers'],
1294
+ {
1295
+ 'x-api-key': string;
1296
+ } // <- Required
1297
+ >
1298
+ >;
1299
+
1300
+ /**
1301
+ * @name HeadersOptionalWhenBaseHeadersNullified
1302
+ * Expect the headers object is now entirely optional, as the AppRoute forced the `x-api-key`
1303
+ * header to be undefined
1304
+ */
1305
+ type HeadersOptionalWhenBaseHeadersNullified = NonNullable<
1306
+ Parameters<typeof clientValibot.routeRemovedApiKey>[0]
1307
+ >['headers'];
1308
+ type TestHeadersOptionalWhenBaseHeadersNullified = Expect<
1309
+ Equal<
1310
+ HeadersOptionalWhenBaseHeadersNullified,
1311
+ {} | undefined // <- Became optional
1312
+ >
1313
+ >;
1314
+
1315
+ /**
1316
+ * @name HeadersWithModifiedHeaders
1317
+ * Expect headers to have been changed, removing the `x-api-key` header and adding the `x-test` header
1318
+ */
1319
+ type HeadersWithModifiedHeaders = NonNullable<
1320
+ Parameters<typeof clientValibot.routeWithModifiedHeaders>[0]
1321
+ >['headers'];
1322
+ type TestHeadersWithModifiedHeaders = Expect<
1323
+ Equal<
1324
+ HeadersWithModifiedHeaders,
1325
+ {
1326
+ 'x-test': string;
1327
+ } // <- Required
1328
+ >
1329
+ >;
1330
+ });