galbe 0.1.0

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,1198 @@
1
+ import { describe, test, expect, beforeAll } from 'bun:test'
2
+ import {
3
+ formdata,
4
+ type Case,
5
+ fileHash,
6
+ schema_object,
7
+ handleBody,
8
+ schema_objectBase,
9
+ handleUrlFormStream,
10
+ isAsyncIterator
11
+ } from './test.utils'
12
+ import { Galbe, T } from '../src'
13
+
14
+ const port = 7357
15
+
16
+ describe('parser', () => {
17
+ beforeAll(async () => {
18
+ const galbe = new Galbe()
19
+
20
+ galbe.get(
21
+ '/headers/schema',
22
+ {
23
+ headers: {
24
+ string: T.String(),
25
+ zero: T.Number(),
26
+ number: T.Number(),
27
+ 'neg-number': T.Number(),
28
+ float: T.Number(),
29
+ integer: T.Integer(),
30
+ 'boolean-true': T.Boolean(),
31
+ 'boolean-false': T.Boolean()
32
+ }
33
+ },
34
+ ctx => {
35
+ return ctx.headers
36
+ }
37
+ )
38
+
39
+ galbe.get(
40
+ '/params/schema/:p1/:p2/:p3/:p4',
41
+ {
42
+ params: {
43
+ p1: T.String(),
44
+ p2: T.Number(),
45
+ p3: T.Integer(),
46
+ p4: T.Boolean()
47
+ }
48
+ },
49
+ ctx => {
50
+ return ctx.params
51
+ }
52
+ )
53
+
54
+ galbe.get(
55
+ '/query/params/schema',
56
+ {
57
+ query: {
58
+ p1: T.String(),
59
+ p2: T.Number(),
60
+ p3: T.Boolean(),
61
+ p4: T.Union([T.Number(), T.Boolean()]),
62
+ p5: T.Optional(T.String())
63
+ }
64
+ },
65
+ ctx => {
66
+ return ctx.query
67
+ }
68
+ )
69
+ galbe.get(
70
+ '/query/params/schema/constraints',
71
+ {
72
+ query: {
73
+ default: T.Optional(T.String({ default: 'DEFAULT_VALUE' })),
74
+ int: T.Integer({ exclusiveMinimum: 10, maximum: 42 }),
75
+ num: T.Number({ minimum: 10, exclusiveMaximum: 42 }),
76
+ str: T.String({ minLength: 4, maxLength: 8, pattern: '^a.*z' }),
77
+ array: T.Optional(T.Array(T.Integer(), { minItems: 3, maxItems: 5, uniqueItems: true }))
78
+ }
79
+ },
80
+ ctx => {
81
+ return ctx.query
82
+ }
83
+ )
84
+
85
+ galbe.post('/obj/schema/base', { body: T.Object(schema_object) }, handleBody)
86
+
87
+ galbe.post(
88
+ '/form/schema/base',
89
+ {
90
+ body: T.UrlForm({
91
+ ...schema_objectBase,
92
+ union: T.Optional(T.Union([T.Number(), T.Boolean()])),
93
+ literal: T.Optional(T.Literal('x')),
94
+ array: T.Array(T.Any())
95
+ })
96
+ },
97
+ handleBody
98
+ )
99
+ galbe.post(
100
+ '/form/stream/schema/base',
101
+ {
102
+ body: T.Stream(
103
+ T.UrlForm({
104
+ ...schema_objectBase,
105
+ union: T.Optional(T.Union([T.Number(), T.Boolean()])),
106
+ literal: T.Optional(T.Literal('x')),
107
+ array: T.Array(T.Any()),
108
+ numArray: T.Optional(T.Array(T.Number()))
109
+ })
110
+ )
111
+ },
112
+ handleUrlFormStream
113
+ )
114
+ galbe.post(
115
+ '/mp/schema/base',
116
+ {
117
+ body: T.MultipartForm({
118
+ ...schema_objectBase,
119
+ union: T.Optional(T.Union([T.Number(), T.Boolean()])),
120
+ literal: T.Optional(T.Literal('x')),
121
+ array: T.Array(T.Any())
122
+ })
123
+ },
124
+ handleBody
125
+ )
126
+ galbe.post(
127
+ '/mp/stream/schema/base',
128
+ {
129
+ body: T.Stream(
130
+ T.MultipartForm({
131
+ ...schema_objectBase,
132
+ union: T.Optional(T.Union([T.Number(), T.Boolean()])),
133
+ literal: T.Optional(T.Literal('x')),
134
+ array: T.Array(T.Any())
135
+ })
136
+ )
137
+ },
138
+ handleBody
139
+ )
140
+
141
+ let schema_jsonFile = {
142
+ string: T.String(),
143
+ number: T.Number(),
144
+ bool: T.Boolean(),
145
+ arrayStr: T.Array(T.String()),
146
+ arrayNumber: T.Array(T.Number()),
147
+ arrayBool: T.Array(T.Boolean())
148
+ }
149
+
150
+ galbe.post(
151
+ '/mp/file',
152
+ { body: T.MultipartForm({ imgFile: T.ByteArray(), jsonFile: T.Object(schema_jsonFile) }) },
153
+ handleBody
154
+ )
155
+ galbe.post(
156
+ '/mp/stream/file',
157
+ { body: T.Stream(T.MultipartForm({ imgFile: T.ByteArray(), jsonFile: T.Object(schema_jsonFile) })) },
158
+ async ctx => {
159
+ if (isAsyncIterator(ctx.body)) {
160
+ const chunks: any[] = []
161
+ for await (const chunk of ctx.body) {
162
+ if (chunk.content instanceof Uint8Array) chunk.content = await fileHash(chunk.content)
163
+ chunks.push(chunk)
164
+ }
165
+ return { type: 'AsyncIterator', content: chunks }
166
+ } else {
167
+ return { type: null, content: 'error' }
168
+ }
169
+ }
170
+ )
171
+ galbe.post('/ba/file', { body: T.ByteArray() }, async ctx => {
172
+ if (ctx?.body instanceof Uint8Array) {
173
+ return { type: 'ByteArray', content: await fileHash(ctx.body) }
174
+ } else {
175
+ return { type: null, content: 'error' }
176
+ }
177
+ })
178
+ galbe.post('/ba/stream/file', { body: T.Stream(T.ByteArray()) }, async ctx => {
179
+ if (isAsyncIterator(ctx.body)) {
180
+ let bytes = new Uint8Array()
181
+ for await (const b of ctx.body) {
182
+ bytes = new Uint8Array([...bytes, ...b])
183
+ }
184
+ return { type: 'AsyncIterator', content: await fileHash(bytes) }
185
+ } else {
186
+ return { type: null, content: 'error' }
187
+ }
188
+ })
189
+
190
+ await galbe.listen(port)
191
+ })
192
+
193
+ test('headers', async () => {
194
+ const cases: any = [
195
+ {
196
+ h: {
197
+ string: 'Hello',
198
+ zero: '0',
199
+ number: '42',
200
+ 'Neg-Number': '-10',
201
+ float: '3.14',
202
+ integer: '42',
203
+ 'boolean-true': 'true',
204
+ 'boolean-false': 'false'
205
+ },
206
+ expected: {
207
+ body: {
208
+ string: 'Hello',
209
+ zero: 0,
210
+ number: 42,
211
+ 'neg-number': -10,
212
+ float: 3.14,
213
+ integer: 42,
214
+ 'boolean-true': true,
215
+ 'boolean-false': false
216
+ }
217
+ }
218
+ },
219
+ {
220
+ h: {
221
+ string: 'Hello',
222
+ zero: '0',
223
+ number: '42',
224
+ float: '3.14',
225
+ integer: '42',
226
+ 'boolean-true': 'a',
227
+ 'boolean-false': 'false'
228
+ },
229
+ expected: {
230
+ status: 400,
231
+ body: {
232
+ headers: {
233
+ 'neg-number': 'Required',
234
+ 'boolean-true': "a is not a valid boolean. Should be 'true' or 'false'"
235
+ }
236
+ }
237
+ }
238
+ }
239
+ ]
240
+
241
+ for (let { h, expected } of cases) {
242
+ let resp = await fetch(`http://localhost:${port}/headers/schema`, { headers: h })
243
+ let body = await resp.json()
244
+ expect(resp.status).toBe(expected.status ?? 200)
245
+ expect(body).toMatchObject(expected.body)
246
+ }
247
+ })
248
+
249
+ test('path params, schema', async () => {
250
+ const cases: any = [
251
+ {
252
+ p: { p1: 'one', p2: '3.14', p3: '42', p4: 'true' },
253
+ expected: { body: { p1: 'one', p2: 3.14, p3: 42, p4: true } }
254
+ },
255
+ {
256
+ p: { p1: '_', p2: 'test', p3: '42.5', p4: 'a' },
257
+ expected: {
258
+ status: 400,
259
+ body: {
260
+ params: {
261
+ p2: 'test is not a valid number',
262
+ p3: '42.5 is not a valid integer',
263
+ p4: "a is not a valid boolean. Should be 'true' or 'false'"
264
+ }
265
+ }
266
+ }
267
+ }
268
+ ]
269
+ for (let { p, expected } of cases) {
270
+ let resp = await fetch(`http://localhost:${port}/params/schema/${p.p1}/${p.p2}/${p.p3}/${p.p4}`)
271
+ let body = await resp.json()
272
+ expect(resp.status).toBe(expected.status ?? 200)
273
+ expect(body).toEqual(expected.body)
274
+ }
275
+ })
276
+
277
+ test('query params', async () => {
278
+ const cases: any = [
279
+ {
280
+ p: { p1: 'one', p2: '3.14', p3: 'false', p4: '42' },
281
+ expected: { body: { p1: 'one', p2: 3.14, p3: false, p4: 42 } }
282
+ },
283
+ {
284
+ p: { p1: 'one', p2: '3.14', p3: 'true', p4: 'true', p5: 'hello' },
285
+ expected: { body: { p1: 'one', p2: 3.14, p3: true, p4: true, p5: 'hello' } }
286
+ },
287
+ {
288
+ p: { p1: '36', p2: 'a', p3: '0' },
289
+ expected: {
290
+ status: 400,
291
+ body: {
292
+ query: {
293
+ p2: 'a is not a valid number',
294
+ p3: "0 is not a valid boolean. Should be 'true' or 'false'",
295
+ p4: 'Required'
296
+ }
297
+ }
298
+ }
299
+ }
300
+ ]
301
+
302
+ for (let { p, expected } of cases) {
303
+ let search = new URLSearchParams()
304
+ for (const [k, v] of Object.entries(p)) search.append(k, v as string)
305
+
306
+ let resp = await fetch(`http://localhost:${port}/query/params/schema?${search.toString()}`)
307
+ let body = await resp.json()
308
+
309
+ expect(resp.status).toBe(expected.status ?? 200)
310
+ expect(body).toEqual(expected.body)
311
+ }
312
+ })
313
+
314
+ test('body, json, schema object', async () => {
315
+ const type = 'application/json'
316
+ const schema = 'obj/schema/base'
317
+ const cases: Case[] = [
318
+ {
319
+ body: JSON.stringify({
320
+ ba: '',
321
+ string: '',
322
+ number: 0,
323
+ bool: false,
324
+ object: {},
325
+ array: [],
326
+ any: false
327
+ }),
328
+ type,
329
+ schema,
330
+ expected: {
331
+ status: 200,
332
+ type: 'object',
333
+ resp: { ba: '', string: '', number: 0, bool: false, object: {}, array: [], any: false }
334
+ }
335
+ },
336
+ {
337
+ body: JSON.stringify({
338
+ ba: 'Hello',
339
+ string: 'Mom!',
340
+ number: 42,
341
+ bool: true,
342
+ object: { foo: 'bar' },
343
+ array: [false, 'one', 2],
344
+ any: '36',
345
+ optional: 'optional'
346
+ }),
347
+ type,
348
+ schema,
349
+ expected: {
350
+ status: 200,
351
+ type: 'object',
352
+ resp: {
353
+ ba: 'Hello',
354
+ string: 'Mom!',
355
+ number: 42,
356
+ bool: true,
357
+ object: { foo: 'bar' },
358
+ array: [false, 'one', 2],
359
+ any: '36',
360
+ optional: 'optional'
361
+ }
362
+ }
363
+ },
364
+ {
365
+ body: JSON.stringify({
366
+ ba: false,
367
+ string: 42,
368
+ number: 'a',
369
+ bool: 'x',
370
+ object: [],
371
+ array: {},
372
+ any: {}
373
+ }),
374
+ type,
375
+ schema,
376
+ expected: {
377
+ status: 400,
378
+ resp: {
379
+ body: {
380
+ ba: 'Not a valid ByteArray',
381
+ string: '42 is not a valid string',
382
+ number: 'a is not a valid number',
383
+ bool: "x is not a valid boolean. Should be 'true' or 'false'",
384
+ object: 'Expected an object, not an array',
385
+ array: 'Not a valid array'
386
+ }
387
+ }
388
+ }
389
+ }
390
+ ]
391
+ for (let { body, type, schema, expected } of cases) {
392
+ let resp = await fetch(`http://localhost:${port}/${schema}`, {
393
+ method: 'POST',
394
+ body,
395
+ headers: { ...(type ? { 'content-type': type } : {}) }
396
+ })
397
+ let respBody = (await resp.json()) as { type: string; content: any }
398
+
399
+ expect(resp.status).toBe(expected.status)
400
+ if (resp.status === 200) {
401
+ if (expected.type) expect(respBody.type).toEqual(expected.type)
402
+ if (respBody.type === 'object' && expected.resp === null) expect(respBody.content).toBeNull
403
+ else expect(respBody.content).toEqual(expected.resp)
404
+ } else {
405
+ if (expected.resp === null) expect(respBody.content).toBeNull
406
+ else expect(respBody).toEqual(expected.resp)
407
+ }
408
+ }
409
+ })
410
+
411
+ test('body, UrlForm, schema object', async () => {
412
+ const type = 'application/x-www-form-urlencoded'
413
+ const schema = 'form/schema/base'
414
+
415
+ const cases: Case[] = [
416
+ {
417
+ body: 'ba=&string=&number=0&bool=false&any=false&union=true',
418
+ type,
419
+ schema,
420
+ expected: {
421
+ status: 200,
422
+ type: 'object',
423
+ resp: {
424
+ ba: 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855',
425
+ string: '',
426
+ number: 0,
427
+ bool: false,
428
+ any: 'false',
429
+ union: true,
430
+ array: []
431
+ }
432
+ }
433
+ },
434
+ {
435
+ body: 'ba=Hello&string=Mom!&number=42&bool=true&any=36&optional=optional&array=1&array=2&literal=x&union=42',
436
+ type,
437
+ schema,
438
+ expected: {
439
+ status: 200,
440
+ type: 'object',
441
+ resp: {
442
+ ba: '185f8db32271fe25f561a6fc938b2e264306ec304eda518007d1764826381969',
443
+ string: 'Mom!',
444
+ number: 42,
445
+ bool: true,
446
+ any: '36',
447
+ optional: 'optional',
448
+ literal: 'x',
449
+ union: 42,
450
+ array: ['1', '2']
451
+ }
452
+ }
453
+ },
454
+ {
455
+ body: 'optional=optional',
456
+ type,
457
+ schema,
458
+ expected: {
459
+ status: 400,
460
+ type: 'object',
461
+ resp: {
462
+ body: 'Missing fields: ba, string, number, bool, any'
463
+ }
464
+ }
465
+ },
466
+ {
467
+ body: 'ba=&string=Hello&string=Mom!&number=aaa&bool=1&any=36&literal=y&union=X',
468
+ type,
469
+ schema,
470
+ expected: {
471
+ status: 400,
472
+ resp: {
473
+ body: {
474
+ string: 'Multiple values found',
475
+ number: 'aaa is not a valid number',
476
+ bool: "1 is not a valid boolean. Should be 'true' or 'false'",
477
+ literal: 'y is not a valid value',
478
+ union: 'X could not be parsed to any of Number, Boolean'
479
+ }
480
+ }
481
+ }
482
+ }
483
+ ]
484
+ for (let { body, type, schema, expected } of cases) {
485
+ let resp = await fetch(`http://localhost:${port}/${schema}`, {
486
+ method: 'POST',
487
+ body,
488
+ headers: { ...(type ? { 'content-type': type } : {}) }
489
+ })
490
+ let respBody = (await resp.json()) as { type: string; content: any }
491
+
492
+ expect(resp.status).toBe(expected.status)
493
+ if (resp.status === 200) {
494
+ if (expected.type) expect(respBody.type).toEqual(expected.type)
495
+ if (respBody.type === 'object' && expected.resp === null) expect(respBody.content).toBeNull
496
+ else expect(respBody.content).toEqual(expected.resp)
497
+ } else {
498
+ if (expected.resp === null) expect(respBody.content).toBeNull
499
+ else expect(respBody).toEqual(expected.resp)
500
+ }
501
+ }
502
+ })
503
+
504
+ test('body, UrlForm Stream, schema object', async () => {
505
+ const type = 'application/x-www-form-urlencoded'
506
+ const schema = 'form/stream/schema/base'
507
+
508
+ const cases: Case[] = [
509
+ {
510
+ body: 'ba=&string=&number=0&bool=false&any=false&union=true',
511
+ type,
512
+ schema,
513
+ expected: {
514
+ status: 200,
515
+ type: 'object',
516
+ resp: {
517
+ ba: new Uint8Array(),
518
+ string: '',
519
+ number: 0,
520
+ bool: false,
521
+ any: 'false',
522
+ union: true,
523
+ array: []
524
+ }
525
+ }
526
+ },
527
+ {
528
+ body: 'ba=Hello&string=Mom!&number=42&bool=true&any=36&optional=optional&array=1&array=2&literal=x&union=42',
529
+ type,
530
+ schema,
531
+ expected: {
532
+ status: 200,
533
+ type: 'object',
534
+ resp: {
535
+ ba: new Uint8Array([72, 101, 108, 108, 111]),
536
+ string: 'Mom!',
537
+ number: 42,
538
+ bool: true,
539
+ any: '36',
540
+ optional: 'optional',
541
+ literal: 'x',
542
+ union: 42,
543
+ array: ['1', '2']
544
+ }
545
+ }
546
+ },
547
+ {
548
+ body: 'ba=Hello&string=Mom!&number=42&bool=true&any=36&optional=optional&array=1&array=2&literal=x&union=42&numArray=x',
549
+ type,
550
+ schema,
551
+ expected: {
552
+ status: 400,
553
+ type: 'object',
554
+ resp: {
555
+ body: { numArray: 'x is not a valid number' }
556
+ }
557
+ }
558
+ },
559
+ {
560
+ body: 'ba=&string=Hello&string=Mom!&number=1&bool=true&any=36&literal=y&union=X',
561
+ type,
562
+ schema,
563
+ expected: {
564
+ status: 400,
565
+ resp: {
566
+ body: {
567
+ literal: 'y is not a valid value'
568
+ }
569
+ }
570
+ }
571
+ },
572
+ {
573
+ body: 'optional=otpional',
574
+ type,
575
+ schema,
576
+ expected: {
577
+ status: 400,
578
+ resp: {
579
+ body: 'Missing fields: ba, string, number, bool, any'
580
+ }
581
+ }
582
+ }
583
+ ]
584
+ for (let { body, type, schema, expected } of cases) {
585
+ let resp = await fetch(`http://localhost:${port}/${schema}`, {
586
+ method: 'POST',
587
+ body,
588
+ headers: { ...(type ? { 'content-type': type } : {}) }
589
+ })
590
+ let respBody = (await resp.json()) as any
591
+
592
+ expect(resp.status).toBe(expected.status)
593
+ if (resp.status === 200) {
594
+ if (expected.type) expect(respBody.type).toEqual(expected.type)
595
+ if (expected.type === 'object' && expected.resp.content === null) expect(respBody).toBeNull
596
+ else expect(respBody.content).toEqual(expected.resp)
597
+ } else {
598
+ if (expected.resp === null) expect(respBody).toBeNull
599
+ else expect(respBody).toEqual(expected.resp)
600
+ }
601
+ }
602
+ })
603
+
604
+ test('body, MultipartForm, schema object', async () => {
605
+ const schema = 'mp/schema/base'
606
+
607
+ const cases: Case[] = [
608
+ {
609
+ body: formdata({ ba: '', string: '', number: '0', bool: 'false', any: 'false', union: 'true' }),
610
+ schema,
611
+ expected: {
612
+ status: 200,
613
+ type: 'object',
614
+ resp: {
615
+ ba: {
616
+ content: 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855',
617
+ headers: {
618
+ name: 'ba'
619
+ }
620
+ },
621
+ string: {
622
+ content: '',
623
+ headers: {
624
+ name: 'string'
625
+ }
626
+ },
627
+ number: {
628
+ content: 0,
629
+ headers: {
630
+ name: 'number'
631
+ }
632
+ },
633
+ bool: {
634
+ content: false,
635
+ headers: {
636
+ name: 'bool'
637
+ }
638
+ },
639
+ any: {
640
+ content: 'false',
641
+ headers: {
642
+ name: 'any'
643
+ }
644
+ },
645
+ union: {
646
+ content: true,
647
+ headers: {
648
+ name: 'union'
649
+ }
650
+ },
651
+ array: {
652
+ content: [],
653
+ headers: {
654
+ name: 'array'
655
+ }
656
+ }
657
+ }
658
+ }
659
+ },
660
+ {
661
+ body: formdata({
662
+ ba: 'Hello',
663
+ string: 'Mom!',
664
+ number: '42',
665
+ bool: 'true',
666
+ any: '36',
667
+ optional: 'optional',
668
+ array: ['1', '2'],
669
+ literal: 'x',
670
+ union: '42'
671
+ }),
672
+ schema,
673
+ expected: {
674
+ status: 200,
675
+ type: 'object',
676
+ resp: {
677
+ ba: {
678
+ content: '185f8db32271fe25f561a6fc938b2e264306ec304eda518007d1764826381969',
679
+ headers: {
680
+ name: 'ba'
681
+ }
682
+ },
683
+ string: {
684
+ content: 'Mom!',
685
+ headers: {
686
+ name: 'string'
687
+ }
688
+ },
689
+ number: {
690
+ content: 42,
691
+ headers: {
692
+ name: 'number'
693
+ }
694
+ },
695
+ bool: {
696
+ content: true,
697
+ headers: {
698
+ name: 'bool'
699
+ }
700
+ },
701
+ any: {
702
+ content: '36',
703
+ headers: {
704
+ name: 'any'
705
+ }
706
+ },
707
+ optional: {
708
+ content: 'optional',
709
+ headers: {
710
+ name: 'optional'
711
+ }
712
+ },
713
+ array: {
714
+ content: ['1', '2'],
715
+ headers: {
716
+ name: 'array'
717
+ }
718
+ },
719
+ literal: {
720
+ content: 'x',
721
+ headers: {
722
+ name: 'literal'
723
+ }
724
+ },
725
+ union: {
726
+ content: 42,
727
+ headers: {
728
+ name: 'union'
729
+ }
730
+ }
731
+ }
732
+ }
733
+ },
734
+ {
735
+ body: formdata({ optional: 'optional' }),
736
+ schema,
737
+ expected: {
738
+ status: 400,
739
+ type: 'object',
740
+ resp: {
741
+ body: 'Missing fields: ba, string, number, bool, any'
742
+ }
743
+ }
744
+ },
745
+ {
746
+ body: formdata({
747
+ ba: '',
748
+ string: ['Hello', 'Mom!'],
749
+ number: 'aaa',
750
+ bool: '1',
751
+ any: '36',
752
+ literal: 'y',
753
+ union: 'X'
754
+ }),
755
+ schema,
756
+ expected: {
757
+ status: 400,
758
+ resp: {
759
+ body: {
760
+ string: 'Multiple values found',
761
+ number: 'aaa is not a valid number',
762
+ bool: "1 is not a valid boolean. Should be 'true' or 'false'",
763
+ literal: 'y is not a valid value',
764
+ union: 'X could not be parsed to any of: Number, Boolean'
765
+ }
766
+ }
767
+ }
768
+ }
769
+ ]
770
+ for (let { body, type, schema, expected } of cases) {
771
+ let resp = await fetch(`http://localhost:${port}/${schema}`, {
772
+ method: 'POST',
773
+ body,
774
+ headers: { ...(type ? { 'content-type': type } : {}) }
775
+ })
776
+ let respBody = (await resp.json()) as { type: string; content: any }
777
+
778
+ expect(resp.status).toBe(expected.status)
779
+ if (resp.status === 200) {
780
+ if (expected.type) expect(respBody.type).toEqual(expected.type)
781
+ if (respBody.type === 'object' && expected.resp === null) expect(respBody.content).toBeNull
782
+ else expect(respBody.content).toEqual(expected.resp)
783
+ } else {
784
+ if (expected.resp === null) expect(respBody.content).toBeNull
785
+ else expect(respBody).toEqual(expected.resp)
786
+ }
787
+ }
788
+ })
789
+
790
+ test('body, MultipartForm Stream, schema object', async () => {
791
+ const schema = 'mp/stream/schema/base'
792
+
793
+ const cases: Case[] = [
794
+ {
795
+ body: formdata({ ba: '', string: '', number: '0', bool: 'false', any: 'false', union: 'true' }),
796
+ schema,
797
+ expected: {
798
+ status: 200,
799
+ type: 'AsyncIterator',
800
+ resp: [
801
+ {
802
+ content: new Uint8Array(),
803
+ headers: {
804
+ name: 'ba'
805
+ }
806
+ },
807
+ {
808
+ content: '',
809
+ headers: {
810
+ name: 'string'
811
+ }
812
+ },
813
+ {
814
+ content: 0,
815
+ headers: {
816
+ name: 'number'
817
+ }
818
+ },
819
+ {
820
+ content: false,
821
+ headers: {
822
+ name: 'bool'
823
+ }
824
+ },
825
+ {
826
+ content: 'false',
827
+ headers: {
828
+ name: 'any'
829
+ }
830
+ },
831
+ {
832
+ content: true,
833
+ headers: {
834
+ name: 'union'
835
+ }
836
+ },
837
+ {
838
+ content: [],
839
+ headers: {
840
+ name: 'array'
841
+ }
842
+ }
843
+ ]
844
+ }
845
+ },
846
+ {
847
+ body: formdata({
848
+ ba: 'Hello',
849
+ string: 'Mom!',
850
+ number: '42',
851
+ bool: 'true',
852
+ any: '36',
853
+ optional: 'optional',
854
+ array: ['1', '2'],
855
+ literal: 'x',
856
+ union: '42'
857
+ }),
858
+ schema,
859
+ expected: {
860
+ status: 200,
861
+ type: 'AsyncIterator',
862
+ resp: [
863
+ {
864
+ content: Uint8Array.from('Hello', c => c.charCodeAt(0)),
865
+ headers: {
866
+ name: 'ba'
867
+ }
868
+ },
869
+ {
870
+ content: 'Mom!',
871
+ headers: {
872
+ name: 'string'
873
+ }
874
+ },
875
+ {
876
+ content: 42,
877
+ headers: {
878
+ name: 'number'
879
+ }
880
+ },
881
+ {
882
+ content: true,
883
+ headers: {
884
+ name: 'bool'
885
+ }
886
+ },
887
+ {
888
+ content: '36',
889
+ headers: {
890
+ name: 'any'
891
+ }
892
+ },
893
+ {
894
+ content: 'optional',
895
+ headers: {
896
+ name: 'optional'
897
+ }
898
+ },
899
+ {
900
+ content: '1',
901
+ headers: {
902
+ name: 'array'
903
+ }
904
+ },
905
+ {
906
+ content: '2',
907
+ headers: {
908
+ name: 'array'
909
+ }
910
+ },
911
+ {
912
+ content: 'x',
913
+ headers: {
914
+ name: 'literal'
915
+ }
916
+ },
917
+ {
918
+ content: 42,
919
+ headers: {
920
+ name: 'union'
921
+ }
922
+ }
923
+ ]
924
+ }
925
+ },
926
+ {
927
+ body: formdata({ optional: 'optional' }),
928
+ schema,
929
+ expected: {
930
+ status: 400,
931
+ type: 'object',
932
+ resp: {
933
+ body: 'Missing fields: ba, string, number, bool, any'
934
+ }
935
+ }
936
+ },
937
+ {
938
+ body: formdata({
939
+ ba: '',
940
+ string: ['Hello', 'Mom!'],
941
+ number: 'aaa',
942
+ bool: '1',
943
+ any: '36',
944
+ literal: 'y',
945
+ union: 'X'
946
+ }),
947
+ schema,
948
+ expected: {
949
+ status: 400,
950
+ resp: {
951
+ body: {
952
+ number: 'aaa is not a valid number'
953
+ }
954
+ }
955
+ }
956
+ }
957
+ ]
958
+ for (let { body, type, schema, expected } of cases) {
959
+ let resp = await fetch(`http://localhost:${port}/${schema}`, {
960
+ method: 'POST',
961
+ body,
962
+ headers: { ...(type ? { 'content-type': type } : {}) }
963
+ })
964
+ let respBody = (await resp.json()) as { type: string; content: any }
965
+
966
+ expect(resp.status).toBe(expected.status)
967
+ if (resp.status === 200) {
968
+ if (expected.type) expect(respBody.type).toEqual(expected.type)
969
+ if (respBody.type === 'object' && expected.resp === null) expect(respBody.content).toBeNull
970
+ else expect(respBody.content).toEqual(expected.resp)
971
+ } else {
972
+ if (expected.resp === null) expect(respBody.content).toBeNull
973
+ else expect(respBody).toEqual(expected.resp)
974
+ }
975
+ }
976
+ })
977
+
978
+ test('body, FileUpload', async () => {
979
+ const imgFile = Bun.file('test/resources/image.png')
980
+ const imgFileBytes = new Uint8Array(await imgFile.arrayBuffer())
981
+
982
+ const jsonFile = Bun.file('test/resources/object.json')
983
+ const missingJsonFile = Bun.file('test/resources/object.missing.json')
984
+ const badSyntaxJsonFile = Bun.file('test/resources/object.badSyntax.json')
985
+
986
+ const cases: Case[] = [
987
+ {
988
+ body: formdata({ imgFile, jsonFile }),
989
+ schema: '/mp/file',
990
+ expected: {
991
+ status: 200,
992
+ type: 'object',
993
+ resp: {
994
+ imgFile: {
995
+ content: await fileHash(imgFileBytes),
996
+ headers: {
997
+ name: 'imgFile',
998
+ filename: 'test/resources/image.png',
999
+ type: 'image/png'
1000
+ }
1001
+ },
1002
+ jsonFile: {
1003
+ content: {
1004
+ string: 'test',
1005
+ number: 3.14,
1006
+ bool: true,
1007
+ arrayStr: ['un', 'deux', 'trois'],
1008
+ arrayNumber: [0],
1009
+ arrayBool: [true, false]
1010
+ },
1011
+ headers: {
1012
+ name: 'jsonFile',
1013
+ filename: 'test/resources/object.json',
1014
+ type: 'application/json'
1015
+ }
1016
+ }
1017
+ }
1018
+ }
1019
+ },
1020
+ {
1021
+ body: formdata({ imgFile, jsonFile: missingJsonFile }),
1022
+ schema: '/mp/file',
1023
+ expected: {
1024
+ status: 400,
1025
+ resp: {
1026
+ body: { jsonFile: { arrayBool: 'Required', arrayStr: 'Required' } }
1027
+ }
1028
+ }
1029
+ },
1030
+ {
1031
+ body: formdata({ imgFile, jsonFile: badSyntaxJsonFile }),
1032
+ schema: '/mp/file',
1033
+ expected: {
1034
+ status: 400,
1035
+ resp: {
1036
+ body: { jsonFile: "JSON Parse error: Expected '}'" }
1037
+ }
1038
+ }
1039
+ },
1040
+ {
1041
+ body: imgFileBytes,
1042
+ schema: '/ba/file',
1043
+ expected: {
1044
+ status: 200,
1045
+ type: 'ByteArray',
1046
+ resp: await fileHash(imgFileBytes)
1047
+ }
1048
+ }
1049
+ ]
1050
+ for (let { body, type, schema, expected } of cases) {
1051
+ let resp = await fetch(`http://localhost:${port}/${schema}`, {
1052
+ method: 'POST',
1053
+ body,
1054
+ headers: { ...(type ? { 'content-type': type } : {}) }
1055
+ })
1056
+ let respBody = (await resp.json()) as { type: string; content: any }
1057
+
1058
+ expect(resp.status).toBe(expected.status)
1059
+ if (resp.status === 200) {
1060
+ if (expected.type) expect(respBody.type).toEqual(expected.type)
1061
+ if (respBody.type === 'object' && expected.resp === null) expect(respBody.content).toBeNull
1062
+ else expect(respBody.content).toEqual(expected.resp)
1063
+ } else {
1064
+ if (expected.resp === null) expect(respBody.content).toBeNull
1065
+ else expect(respBody).toEqual(expected.resp)
1066
+ }
1067
+ }
1068
+ })
1069
+
1070
+ test('body, FileUpload Stream', async () => {
1071
+ const imgFile = Bun.file('test/resources/image.png')
1072
+ const imgFileBytes = new Uint8Array(await imgFile.arrayBuffer())
1073
+
1074
+ const jsonFile = Bun.file('test/resources/object.json')
1075
+ const missingJsonFile = Bun.file('test/resources/object.missing.json')
1076
+ const badSyntaxJsonFile = Bun.file('test/resources/object.badSyntax.json')
1077
+
1078
+ const cases: Case[] = [
1079
+ {
1080
+ body: formdata({ imgFile, jsonFile }),
1081
+ schema: '/mp/stream/file',
1082
+ expected: {
1083
+ status: 200,
1084
+ type: 'AsyncIterator',
1085
+ resp: [
1086
+ {
1087
+ content: await fileHash(imgFileBytes),
1088
+ headers: {
1089
+ name: 'imgFile',
1090
+ filename: 'test/resources/image.png',
1091
+ type: 'image/png'
1092
+ }
1093
+ },
1094
+ {
1095
+ content: {
1096
+ string: 'test',
1097
+ number: 3.14,
1098
+ bool: true,
1099
+ arrayStr: ['un', 'deux', 'trois'],
1100
+ arrayNumber: [0],
1101
+ arrayBool: [true, false]
1102
+ },
1103
+ headers: {
1104
+ name: 'jsonFile',
1105
+ filename: 'test/resources/object.json',
1106
+ type: 'application/json'
1107
+ }
1108
+ }
1109
+ ]
1110
+ }
1111
+ },
1112
+ {
1113
+ body: formdata({ imgFile, jsonFile: missingJsonFile }),
1114
+ schema: '/mp/stream/file',
1115
+ expected: {
1116
+ status: 400,
1117
+ resp: {
1118
+ body: { jsonFile: { arrayBool: 'Required', arrayStr: 'Required' } }
1119
+ }
1120
+ }
1121
+ },
1122
+ {
1123
+ body: formdata({ imgFile, jsonFile: badSyntaxJsonFile }),
1124
+ schema: '/mp/stream/file',
1125
+ expected: {
1126
+ status: 400,
1127
+ resp: {
1128
+ body: { jsonFile: "JSON Parse error: Expected '}'" }
1129
+ }
1130
+ }
1131
+ },
1132
+ {
1133
+ body: imgFileBytes,
1134
+ schema: '/ba/stream/file',
1135
+ expected: {
1136
+ status: 200,
1137
+ type: 'AsyncIterator',
1138
+ resp: await fileHash(imgFileBytes)
1139
+ }
1140
+ }
1141
+ ]
1142
+ for (let { body, type, schema, expected } of cases) {
1143
+ let resp = await fetch(`http://localhost:${port}/${schema}`, {
1144
+ method: 'POST',
1145
+ body,
1146
+ headers: { ...(type ? { 'content-type': type } : {}) }
1147
+ })
1148
+ let respBody = (await resp.json()) as { type: string; content: any }
1149
+
1150
+ expect(resp.status).toBe(expected.status)
1151
+ if (resp.status === 200) {
1152
+ if (expected.type) expect(respBody.type).toEqual(expected.type)
1153
+ if (respBody.type === 'object' && expected.resp === null) expect(respBody.content).toBeNull
1154
+ else expect(respBody.content).toEqual(expected.resp)
1155
+ } else {
1156
+ if (expected.resp === null) expect(respBody.content).toBeNull
1157
+ else expect(respBody).toEqual(expected.resp)
1158
+ }
1159
+ }
1160
+ })
1161
+
1162
+ test('type constraints', async () => {
1163
+ const cases: any = [
1164
+ {
1165
+ p: { int: '11', num: '10', str: 'aaaz', array: ['1', '2', '3'] },
1166
+ expected: { body: { default: 'DEFAULT_VALUE', int: 11, num: 10, str: 'aaaz', array: [1, 2, 3] } }
1167
+ },
1168
+ {
1169
+ p: { default: '', int: '42', num: '41.5', str: 'a______z', array: ['1', '2', '3', '4', '5'] },
1170
+ expected: { body: { default: '', int: 42, num: 41.5, str: 'a______z', array: [1, 2, 3, 4, 5] } }
1171
+ },
1172
+ {
1173
+ p: { int: '10', num: '42.01', str: 'xxxxxxxxxx' },
1174
+ expected: {
1175
+ status: 400,
1176
+ body: {
1177
+ query: {
1178
+ int: '10 is less or equal to 10',
1179
+ num: '42.01 is greater or equal to 42',
1180
+ str: ['xxxxxxxxxx length is too large (8 char max)', 'xxxxxxxxxx does not match pattern ^a.*z']
1181
+ }
1182
+ }
1183
+ }
1184
+ }
1185
+ ]
1186
+
1187
+ for (let { p, expected } of cases) {
1188
+ let search = new URLSearchParams()
1189
+ for (const [k, v] of Object.entries(p)) search.append(k, v as string)
1190
+
1191
+ let resp = await fetch(`http://localhost:${port}/query/params/schema/constraints?${search.toString()}`)
1192
+ let body = await resp.json()
1193
+
1194
+ expect(resp.status).toBe(expected.status ?? 200)
1195
+ expect(body).toEqual(expected.body)
1196
+ }
1197
+ })
1198
+ })