envio 2.8.2 → 2.9.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,661 @@
1
+ /*
2
+ Vendored from: https://github.com/DZakh/rescript-rest
3
+ Version: 1.0.1
4
+
5
+ IF EDITING THIS FILE, PLEASE LIST THE CHANGES BELOW
6
+
7
+ here
8
+
9
+ */
10
+
11
+ @@uncurried
12
+
13
+ module Exn = {
14
+ type error
15
+
16
+ @new
17
+ external makeError: string => error = "Error"
18
+
19
+ let raiseAny = (any: 'any): 'a => any->Obj.magic->raise
20
+
21
+ let raiseError: error => 'a = raiseAny
22
+ }
23
+
24
+ module Obj = {
25
+ external magic: 'a => 'b = "%identity"
26
+ }
27
+
28
+ module Promise = {
29
+ type t<+'a> = promise<'a>
30
+
31
+ @send
32
+ external thenResolve: (t<'a>, 'a => 'b) => t<'b> = "then"
33
+ }
34
+
35
+ module Option = {
36
+ let unsafeSome: 'a => option<'a> = Obj.magic
37
+ }
38
+
39
+ module Dict = {
40
+ @inline
41
+ let has = (dict, key) => {
42
+ dict->Js.Dict.unsafeGet(key)->(Obj.magic: 'a => bool)
43
+ }
44
+ }
45
+
46
+ @inline
47
+ let panic = message => Exn.raiseError(Exn.makeError(`[rescript-rest] ${message}`))
48
+
49
+ @val
50
+ external encodeURIComponent: string => string = "encodeURIComponent"
51
+
52
+ module ApiFetcher = {
53
+ type args = {body: option<unknown>, headers: option<dict<unknown>>, method: string, path: string}
54
+ type response = {data: unknown, status: int, headers: dict<unknown>}
55
+ type t = args => promise<response>
56
+
57
+ %%private(external fetch: (string, args) => promise<{..}> = "fetch")
58
+
59
+ // Inspired by https://github.com/ts-rest/ts-rest/blob/7792ef7bdc352e84a4f5766c53f984a9d630c60e/libs/ts-rest/core/src/lib/client.ts#L102
60
+ /**
61
+ * Default fetch api implementation:
62
+ *
63
+ * Can be used as a reference for implementing your own fetcher,
64
+ * or used in the "api" field of ClientArgs to allow you to hook
65
+ * into the request to run custom logic
66
+ */
67
+ let default: t = async (args): response => {
68
+ let result = await fetch(args.path, args)
69
+ let contentType = result["headers"]["get"]("content-type")
70
+
71
+ // Note: contentType might be null
72
+ if (
73
+ contentType->Obj.magic &&
74
+ contentType->Js.String2.includes("application/") &&
75
+ contentType->Js.String2.includes("json")
76
+ ) {
77
+ {
78
+ status: result["status"],
79
+ data: await result["json"](),
80
+ headers: result["headers"],
81
+ }
82
+ } else if contentType->Obj.magic && contentType->Js.String2.includes("text/") {
83
+ {
84
+ status: result["status"],
85
+ data: await result["text"](),
86
+ headers: result["headers"],
87
+ }
88
+ } else {
89
+ {
90
+ status: result["status"],
91
+ data: await result["blob"](),
92
+ headers: result["headers"],
93
+ }
94
+ }
95
+ }
96
+ }
97
+
98
+ module Response = {
99
+ type numiricStatus = [
100
+ | #100
101
+ | #101
102
+ | #102
103
+ | #200
104
+ | #201
105
+ | #202
106
+ | #203
107
+ | #204
108
+ | #205
109
+ | #206
110
+ | #207
111
+ | #300
112
+ | #301
113
+ | #302
114
+ | #303
115
+ | #304
116
+ | #305
117
+ | #307
118
+ | #308
119
+ | #400
120
+ | #401
121
+ | #402
122
+ | #403
123
+ | #404
124
+ | #405
125
+ | #406
126
+ | #407
127
+ | #408
128
+ | #409
129
+ | #410
130
+ | #411
131
+ | #412
132
+ | #413
133
+ | #414
134
+ | #415
135
+ | #416
136
+ | #417
137
+ | #418
138
+ | #419
139
+ | #420
140
+ | #421
141
+ | #422
142
+ | #423
143
+ | #424
144
+ | #428
145
+ | #429
146
+ | #431
147
+ | #451
148
+ | #500
149
+ | #501
150
+ | #502
151
+ | #503
152
+ | #504
153
+ | #505
154
+ | #507
155
+ | #511
156
+ ]
157
+ type status = [
158
+ | #"1XX"
159
+ | #"2XX"
160
+ | #"3XX"
161
+ | #"4XX"
162
+ | #"5XX"
163
+ | numiricStatus
164
+ ]
165
+
166
+ type s = {
167
+ status: int => unit,
168
+ description: string => unit,
169
+ data: 'value. S.t<'value> => 'value,
170
+ field: 'value. (string, S.t<'value>) => 'value,
171
+ header: 'value. (string, S.t<'value>) => 'value,
172
+ }
173
+
174
+ type t<'response> = {
175
+ // When it's empty, treat response as a default
176
+ status: option<int>,
177
+ description: option<string>,
178
+ dataSchema: S.t<unknown>,
179
+ emptyData: bool,
180
+ schema: S.t<'response>,
181
+ }
182
+
183
+ type builder<'response> = {
184
+ // When it's empty, treat response as a default
185
+ mutable status?: int,
186
+ mutable description?: string,
187
+ mutable dataSchema?: S.t<unknown>,
188
+ mutable emptyData: bool,
189
+ mutable schema?: S.t<'response>,
190
+ }
191
+
192
+ let register = (
193
+ map: dict<t<'response>>,
194
+ status: [< status | #default],
195
+ builder: builder<'response>,
196
+ ) => {
197
+ let key = status->(Obj.magic: [< status | #default] => string)
198
+ if map->Dict.has(key) {
199
+ panic(`Response for the "${key}" status registered multiple times`)
200
+ } else {
201
+ map->Js.Dict.set(key, builder->(Obj.magic: builder<'response> => t<'response>))
202
+ }
203
+ }
204
+
205
+ @inline
206
+ let find = (map: dict<t<'response>>, responseStatus: int): option<t<'response>> => {
207
+ (map
208
+ ->Js.Dict.unsafeGet(responseStatus->(Obj.magic: int => string))
209
+ ->(Obj.magic: t<'response> => bool) ||
210
+ map
211
+ ->Js.Dict.unsafeGet((responseStatus / 100)->(Obj.magic: int => string) ++ "XX")
212
+ ->(Obj.magic: t<'response> => bool) ||
213
+ map->Js.Dict.unsafeGet("default")->(Obj.magic: t<'response> => bool))
214
+ ->(Obj.magic: bool => option<t<'response>>)
215
+ }
216
+ }
217
+
218
+ type pathParam = {name: string}
219
+ @unboxed
220
+ type pathItem = Static(string) | Param(pathParam)
221
+
222
+ type auth = Bearer | Basic
223
+
224
+ type s = {
225
+ field: 'value. (string, S.t<'value>) => 'value,
226
+ body: 'value. S.t<'value> => 'value,
227
+ rawBody: 'value. S.t<'value> => 'value,
228
+ header: 'value. (string, S.t<'value>) => 'value,
229
+ query: 'value. (string, S.t<'value>) => 'value,
230
+ param: 'value. (string, S.t<'value>) => 'value,
231
+ auth: auth => string,
232
+ }
233
+
234
+ type method =
235
+ | @as("GET") Get
236
+ | @as("POST") Post
237
+ | @as("PUT") Put
238
+ | @as("PATCH") Patch
239
+ | @as("DELETE") Delete
240
+ | @as("HEAD") Head
241
+ | @as("OPTIONS") Options
242
+ | @as("TRACE") Trace
243
+
244
+ type definition<'variables, 'response> = {
245
+ method: method,
246
+ path: string,
247
+ variables: s => 'variables,
248
+ responses: array<Response.s => 'response>,
249
+ summary?: string,
250
+ description?: string,
251
+ deprecated?: bool,
252
+ }
253
+
254
+ type routeParams<'variables, 'response> = {
255
+ definition: definition<'variables, 'response>,
256
+ pathItems: array<pathItem>,
257
+ variablesSchema: S.t<'variables>,
258
+ responses: array<Response.t<'response>>,
259
+ responsesMap: dict<Response.t<'response>>,
260
+ isRawBody: bool,
261
+ }
262
+
263
+ type route<'variables, 'response> = unit => definition<'variables, 'response>
264
+
265
+ let rec parsePath = (path: string, ~pathItems, ~pathParams) => {
266
+ if path !== "" {
267
+ switch path->Js.String2.indexOf("{") {
268
+ | -1 => pathItems->Js.Array2.push(Static(path))->ignore
269
+ | paramStartIdx =>
270
+ switch path->Js.String2.indexOf("}") {
271
+ | -1 => panic("Path contains an unclosed parameter")
272
+ | paramEndIdx =>
273
+ if paramStartIdx > paramEndIdx {
274
+ panic("Path parameter is not enclosed in curly braces")
275
+ }
276
+ let paramName = Js.String2.slice(path, ~from=paramStartIdx + 1, ~to_=paramEndIdx)
277
+ if paramName === "" {
278
+ panic("Path parameter name cannot be empty")
279
+ }
280
+ let param = {name: paramName}
281
+
282
+ pathItems
283
+ ->Js.Array2.push(Static(Js.String2.slice(path, ~from=0, ~to_=paramStartIdx)))
284
+ ->ignore
285
+ pathItems->Js.Array2.push(Param(param))->ignore
286
+ pathParams->Js.Dict.set(paramName, param)->ignore
287
+
288
+ parsePath(Js.String2.sliceToEnd(path, ~from=paramEndIdx + 1), ~pathItems, ~pathParams)
289
+ }
290
+ }
291
+ }
292
+ }
293
+
294
+ let coerceSchema = schema => {
295
+ schema->S.preprocess(s => {
296
+ let tagged = switch s.schema->S.classify {
297
+ | Option(optionalSchema) => optionalSchema->S.classify
298
+ | tagged => tagged
299
+ }
300
+ switch tagged {
301
+ | Literal(Boolean(_))
302
+ | Bool => {
303
+ parser: unknown =>
304
+ switch unknown->Obj.magic {
305
+ | "true" => true
306
+ | "false" => false
307
+ | _ => unknown->Obj.magic
308
+ }->Obj.magic,
309
+ }
310
+ | Literal(Number(_))
311
+ | Int
312
+ | Float => {
313
+ parser: unknown => {
314
+ let float = %raw(`+unknown`)
315
+ if Js.Float.isNaN(float) {
316
+ unknown
317
+ } else {
318
+ float->Obj.magic
319
+ }
320
+ },
321
+ }
322
+ | String
323
+ | Literal(String(_))
324
+ | Union(_)
325
+ | Never => {}
326
+ | _ => {}
327
+ }
328
+ })
329
+ }
330
+
331
+ let bearerAuthSchema = S.string->S.transform(s => {
332
+ serializer: token => {
333
+ `Bearer ${token}`
334
+ },
335
+ parser: string => {
336
+ switch string->Js.String2.split(" ") {
337
+ | ["Bearer", token] => token
338
+ | _ => s.fail("Invalid Bearer token")
339
+ }
340
+ },
341
+ })
342
+
343
+ let basicAuthSchema = S.string->S.transform(s => {
344
+ serializer: token => {
345
+ `Basic ${token}`
346
+ },
347
+ parser: string => {
348
+ switch string->Js.String2.split(" ") {
349
+ | ["Basic", token] => token
350
+ | _ => s.fail("Invalid Basic token")
351
+ }
352
+ },
353
+ })
354
+
355
+ let params = route => {
356
+ switch (route->Obj.magic)["_rest"]->(
357
+ Obj.magic: unknown => option<routeParams<'variables, 'response>>
358
+ ) {
359
+ | Some(params) => params
360
+ | None => {
361
+ let routeDefinition = (
362
+ route->(Obj.magic: route<'variables, 'response> => route<unknown, unknown>)
363
+ )()
364
+
365
+ let pathItems = []
366
+ let pathParams = Js.Dict.empty()
367
+ parsePath(routeDefinition.path, ~pathItems, ~pathParams)
368
+
369
+ // Don't use ref, since it creates an unnecessary object
370
+ let isRawBody = %raw(`false`)
371
+
372
+ let variablesSchema = S.object(s => {
373
+ routeDefinition.variables({
374
+ field: (fieldName, schema) => {
375
+ s.nestedField("body", fieldName, schema)
376
+ },
377
+ body: schema => {
378
+ s.field("body", schema)
379
+ },
380
+ rawBody: schema => {
381
+ let isNonStringBased = switch schema->S.classify {
382
+ | Literal(String(_))
383
+ | String => false
384
+ | _ => true
385
+ }
386
+ if isNonStringBased {
387
+ panic("Only string-based schemas are allowed in rawBody")
388
+ }
389
+ let _ = %raw(`isRawBody = true`)
390
+ s.field("body", schema)
391
+ },
392
+ header: (fieldName, schema) => {
393
+ s.nestedField("headers", fieldName->Js.String2.toLowerCase, coerceSchema(schema))
394
+ },
395
+ query: (fieldName, schema) => {
396
+ s.nestedField("query", fieldName, coerceSchema(schema))
397
+ },
398
+ param: (fieldName, schema) => {
399
+ if !Dict.has(pathParams, fieldName) {
400
+ panic(`Path parameter "${fieldName}" is not defined in the path`)
401
+ }
402
+ s.nestedField("params", fieldName, coerceSchema(schema))
403
+ },
404
+ auth: auth => {
405
+ s.nestedField(
406
+ "headers",
407
+ "authorization",
408
+ switch auth {
409
+ | Bearer => bearerAuthSchema
410
+ | Basic => basicAuthSchema
411
+ },
412
+ )
413
+ },
414
+ })
415
+ })
416
+
417
+ {
418
+ // The variables input is guaranteed to be an object, so we reset the rescript-schema type filter here
419
+ (variablesSchema->Obj.magic)["f"] = ()
420
+ (variablesSchema->S.classify->Obj.magic)["unknownKeys"] = S.Strip
421
+ let items: array<S.item> = (variablesSchema->S.classify->Obj.magic)["items"]
422
+ items->Js.Array2.forEach(item => {
423
+ let schema = item.schema
424
+ // Remove ${inputVar}.constructor!==Object check
425
+ (schema->Obj.magic)["f"] = (_b, ~inputVar) => `!${inputVar}`
426
+ })
427
+ }
428
+
429
+ let responsesMap = Js.Dict.empty()
430
+ let responses = []
431
+ routeDefinition.responses->Js.Array2.forEach(r => {
432
+ let builder: Response.builder<unknown> = {
433
+ emptyData: true,
434
+ }
435
+ let schema = S.object(s => {
436
+ let definition = r({
437
+ status: status => {
438
+ builder.status = Some(status)
439
+ let status = status->(Obj.magic: int => Response.status)
440
+ responsesMap->Response.register(status, builder)
441
+ s.tag("status", status)
442
+ },
443
+ description: d => builder.description = Some(d),
444
+ field: (fieldName, schema) => {
445
+ builder.emptyData = false
446
+ s.nestedField("data", fieldName, schema)
447
+ },
448
+ data: schema => {
449
+ builder.emptyData = false
450
+ s.field("data", schema)
451
+ },
452
+ header: (fieldName, schema) => {
453
+ s.nestedField("headers", fieldName->Js.String2.toLowerCase, coerceSchema(schema))
454
+ },
455
+ })
456
+ if builder.emptyData {
457
+ s.tag("data", %raw(`null`))
458
+ }
459
+ definition
460
+ })
461
+ if builder.status === None {
462
+ responsesMap->Response.register(#default, builder)
463
+ }
464
+ (schema->S.classify->Obj.magic)["unknownKeys"] = S.Strip
465
+ builder.dataSchema = (schema->S.classify->Obj.magic)["fields"]["data"]["t"]
466
+ builder.schema = Option.unsafeSome(schema)
467
+ responses
468
+ ->Js.Array2.push(builder->(Obj.magic: Response.builder<unknown> => Response.t<unknown>))
469
+ ->ignore
470
+ })
471
+
472
+ if responses->Js.Array2.length === 0 {
473
+ panic("At least single response should be registered")
474
+ }
475
+
476
+ let params = {
477
+ definition: routeDefinition,
478
+ variablesSchema,
479
+ responses,
480
+ pathItems,
481
+ responsesMap,
482
+ isRawBody,
483
+ }
484
+ (route->Obj.magic)["_rest"] = params
485
+ params->(Obj.magic: routeParams<unknown, unknown> => routeParams<'variables, 'response>)
486
+ }
487
+ }
488
+ }
489
+
490
+ external route: (unit => definition<'variables, 'response>) => route<'variables, 'response> =
491
+ "%identity"
492
+
493
+ type client = {
494
+ call: 'variables 'response. (route<'variables, 'response>, 'variables) => promise<'response>,
495
+ baseUrl: string,
496
+ fetcher: ApiFetcher.t,
497
+ // By default, all query parameters are encoded as strings, however, you can use the jsonQuery option to encode query parameters as typed JSON values.
498
+ jsonQuery: bool,
499
+ }
500
+
501
+ /**
502
+ * A recursive function to convert an object/string/number/whatever into an array of key=value pairs
503
+ *
504
+ * This should be fully compatible with the "qs" library, but more optimised and without the need to add a dependency
505
+ */
506
+ let rec tokeniseValue = (key, value, ~append) => {
507
+ if Js.Array2.isArray(value) {
508
+ value
509
+ ->(Obj.magic: unknown => array<unknown>)
510
+ ->Js.Array2.forEachi((v, idx) => {
511
+ tokeniseValue(`${key}[${idx->Js.Int.toString}]`, v, ~append)
512
+ })
513
+ } else if value === %raw(`null`) {
514
+ append(key, "")
515
+ } else if value === %raw(`void 0`) {
516
+ ()
517
+ } else if Js.typeof(value) === "object" {
518
+ let dict = value->(Obj.magic: unknown => dict<unknown>)
519
+ dict
520
+ ->Js.Dict.keys
521
+ ->Js.Array2.forEach(k => {
522
+ tokeniseValue(`${key}[${encodeURIComponent(k)}]`, dict->Js.Dict.unsafeGet(k), ~append)
523
+ })
524
+ } else {
525
+ append(key, value->(Obj.magic: unknown => string))
526
+ }
527
+ }
528
+
529
+ // Inspired by https://github.com/ts-rest/ts-rest/blob/7792ef7bdc352e84a4f5766c53f984a9d630c60e/libs/ts-rest/core/src/lib/client.ts#L347
530
+ let getCompletePath = (~baseUrl, ~pathItems, ~maybeQuery, ~maybeParams, ~jsonQuery) => {
531
+ let path = ref(baseUrl)
532
+
533
+ for idx in 0 to pathItems->Js.Array2.length - 1 {
534
+ let pathItem = pathItems->Js.Array2.unsafe_get(idx)
535
+ switch pathItem {
536
+ | Static(static) => path := path.contents ++ static
537
+ | Param({name}) =>
538
+ switch (maybeParams->Obj.magic && maybeParams->Js.Dict.unsafeGet(name)->Obj.magic)
539
+ ->(Obj.magic: bool => option<string>) {
540
+ | Some(param) => path := path.contents ++ param
541
+ | None => panic(`Path parameter "${name}" is not defined in variables`)
542
+ }
543
+ }
544
+ }
545
+
546
+ switch maybeQuery {
547
+ | None => ()
548
+ | Some(query) => {
549
+ let queryItems = []
550
+
551
+ let append = (key, value) => {
552
+ let _ = queryItems->Js.Array2.push(key ++ "=" ++ encodeURIComponent(value))
553
+ }
554
+
555
+ let queryNames = query->Js.Dict.keys
556
+ for idx in 0 to queryNames->Js.Array2.length - 1 {
557
+ let queryName = queryNames->Js.Array2.unsafe_get(idx)
558
+ let value = query->Js.Dict.unsafeGet(queryName)
559
+ let key = encodeURIComponent(queryName)
560
+ if value !== %raw(`void 0`) {
561
+ switch jsonQuery {
562
+ // if value is a string and is not a reserved JSON value or a number, pass it without encoding
563
+ // this makes strings look nicer in the URL (e.g. ?name=John instead of ?name=%22John%22)
564
+ // this is also how OpenAPI will pass strings even if they are marked as application/json types
565
+ | true =>
566
+ append(
567
+ key,
568
+ if (
569
+ Js.typeof(value) === "string" && {
570
+ let value = value->(Obj.magic: unknown => string)
571
+ value !== "true" &&
572
+ value !== "false" &&
573
+ value !== "null" &&
574
+ Js.Float.isNaN(Js.Float.fromString(value))
575
+ }
576
+ ) {
577
+ value->(Obj.magic: unknown => string)
578
+ } else {
579
+ value->(Obj.magic: unknown => Js.Json.t)->Js.Json.stringify
580
+ },
581
+ )
582
+ | false => tokeniseValue(key, value, ~append)
583
+ }
584
+ }
585
+ }
586
+
587
+ if queryItems->Js.Array2.length > 0 {
588
+ path := path.contents ++ "?" ++ queryItems->Js.Array2.joinWith("&")
589
+ }
590
+ }
591
+ }
592
+
593
+ path.contents
594
+ }
595
+
596
+ let fetch = (
597
+ type variables response,
598
+ route: route<variables, response>,
599
+ baseUrl,
600
+ variables,
601
+ ~fetcher=ApiFetcher.default,
602
+ ~jsonQuery=false,
603
+ ) => {
604
+ let route = route->(Obj.magic: route<variables, response> => route<unknown, unknown>)
605
+ let variables = variables->(Obj.magic: variables => unknown)
606
+
607
+ let {definition, variablesSchema, responsesMap, pathItems, isRawBody} = route->params
608
+
609
+ let data = variables->S.serializeToUnknownOrRaiseWith(variablesSchema)->Obj.magic
610
+
611
+ if data["body"] !== %raw(`void 0`) {
612
+ if !isRawBody {
613
+ data["body"] = %raw(`JSON.stringify(data["body"])`)
614
+ }
615
+ if data["headers"] === %raw(`void 0`) {
616
+ data["headers"] = %raw(`{}`)
617
+ }
618
+ data["headers"]["content-type"] = "application/json"
619
+ }
620
+
621
+ fetcher({
622
+ body: data["body"],
623
+ headers: data["headers"],
624
+ path: getCompletePath(
625
+ ~baseUrl,
626
+ ~pathItems,
627
+ ~maybeQuery=data["query"],
628
+ ~maybeParams=data["params"],
629
+ ~jsonQuery,
630
+ ),
631
+ method: (definition.method :> string),
632
+ })->Promise.thenResolve(fetcherResponse => {
633
+ switch responsesMap->Response.find(fetcherResponse.status) {
634
+ | None =>
635
+ let error = ref(`Unexpected response status "${fetcherResponse.status->Js.Int.toString}"`)
636
+ if (
637
+ fetcherResponse.data->Obj.magic &&
638
+ Js.typeof((fetcherResponse.data->Obj.magic)["message"]) === "string"
639
+ ) {
640
+ error :=
641
+ error.contents ++ ". Message: " ++ (fetcherResponse.data->Obj.magic)["message"]->Obj.magic
642
+ }
643
+
644
+ panic(error.contents)
645
+ | Some(response) =>
646
+ fetcherResponse
647
+ ->S.parseAnyOrRaiseWith(response.schema)
648
+ ->(Obj.magic: unknown => response)
649
+ }
650
+ })
651
+ }
652
+
653
+ let client = (~baseUrl, ~fetcher=ApiFetcher.default, ~jsonQuery=false) => {
654
+ let call = (route, variables) => route->fetch(baseUrl, variables, ~fetcher, ~jsonQuery)
655
+ {
656
+ baseUrl,
657
+ fetcher,
658
+ call,
659
+ jsonQuery,
660
+ }
661
+ }