@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.
- package/.babelrc +10 -0
- package/.eslintrc.json +21 -0
- package/CHANGELOG.md +3 -0
- package/LICENCE +21 -0
- package/README.md +19 -0
- package/jest.config.ts +16 -0
- package/package.json +33 -0
- package/project.json +51 -0
- package/src/index.ts +15 -0
- package/src/lib/client.spec.ts +1330 -0
- package/src/lib/client.ts +481 -0
- package/src/lib/dsl.spec.ts +1308 -0
- package/src/lib/dsl.ts +472 -0
- package/src/lib/fetch.spec.ts +102 -0
- package/src/lib/infer-types.spec.ts +935 -0
- package/src/lib/infer-types.ts +282 -0
- package/src/lib/paths.spec.ts +138 -0
- package/src/lib/paths.ts +61 -0
- package/src/lib/query.spec.ts +329 -0
- package/src/lib/query.ts +114 -0
- package/src/lib/response-error.spec.ts +67 -0
- package/src/lib/response-error.ts +61 -0
- package/src/lib/response-validation-error.ts +24 -0
- package/src/lib/server.spec.ts +163 -0
- package/src/lib/server.ts +83 -0
- package/src/lib/standard-schema-utils.spec.ts +218 -0
- package/src/lib/standard-schema-utils.ts +280 -0
- package/src/lib/standard-schema.ts +71 -0
- package/src/lib/status-codes.ts +75 -0
- package/src/lib/test-helpers.ts +7 -0
- package/src/lib/type-guards.spec.ts +355 -0
- package/src/lib/type-guards.ts +99 -0
- package/src/lib/type-utils.spec.ts +59 -0
- package/src/lib/type-utils.ts +234 -0
- package/src/lib/unknown-status-error.ts +15 -0
- package/src/lib/validation-error.ts +36 -0
- package/tsconfig.json +22 -0
- package/tsconfig.lib.json +10 -0
- package/tsconfig.spec.json +9 -0
- package/typedoc.json +5 -0
|
@@ -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
|
+
});
|