@kaiko.io/rescript-deser 2.0.0 → 3.0.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.
package/Makefile CHANGED
@@ -4,7 +4,11 @@ yarn.lock: bsconfig.json package.json
4
4
  yarn install
5
5
 
6
6
  compile: yarn.lock
7
- [ -n "$(INSIDE_EMACS)" ] && (rescript build -with-deps | sed "s@ $(shell pwd)/@@") || rescript build -with-deps
7
+ @if [ -n "$(INSIDE_EMACS)" ]; then \
8
+ NINJA_ANSI_FORCED=0 rescript build -with-deps; \
9
+ else \
10
+ rescript build -with-deps; \
11
+ fi
8
12
 
9
13
  format: yarn.lock
10
14
  rescript format -all
@@ -17,4 +21,12 @@ publish: compile
17
21
  RESCRIPT_FILES := $(shell find src -type f -name '*.res')
18
22
 
19
23
  compile-rescript: $(RESCRIPT_FILES) yarn.lock
20
- [ -n "$(INSIDE_EMACS)" ] && (rescript build -with-deps | sed "s@ $(shell pwd)/@@") || rescript build -with-deps
24
+ @if [ -n "$(INSIDE_EMACS)" ]; then \
25
+ NINJA_ANSI_FORCED=0 rescript build -with-deps; \
26
+ else \
27
+ rescript build -with-deps; \
28
+ fi
29
+
30
+
31
+ test: compile
32
+ esbuild lib/js/tests/index.js --bundle --outdir=www --servedir=www --serve=4369
package/bsconfig.json CHANGED
@@ -11,13 +11,10 @@
11
11
  }
12
12
  ],
13
13
  "suffix": ".js",
14
- "reason": {
15
- "react-jsx": 3
16
- },
17
14
  "bs-dependencies": [
18
15
  "@kaiko.io/rescript-prelude"
19
16
  ],
20
- "package-specs": {
21
- "module": "es6"
17
+ "warnings": {
18
+ "error": "+8+11+26+27+33+56"
22
19
  }
23
20
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kaiko.io/rescript-deser",
3
- "version": "2.0.0",
3
+ "version": "3.0.0",
4
4
  "keywords": [
5
5
  "json",
6
6
  "deserializer",
@@ -12,9 +12,11 @@
12
12
  "license": "MIT",
13
13
  "private": false,
14
14
  "dependencies": {
15
- "@kaiko.io/rescript-prelude": "^3.0.1"
15
+ "@kaiko.io/rescript-prelude": "^6.0.0"
16
16
  },
17
17
  "devDependencies": {
18
- "rescript": "^10.0.1"
18
+ "esbuild": "^0.15.7",
19
+ "qunit": "^2.16.0",
20
+ "rescript": "^10.1.4"
19
21
  }
20
22
  }
package/src/JSON.res CHANGED
@@ -27,6 +27,7 @@ module type Deserializer = {
27
27
  type t
28
28
  let name: string
29
29
  let fromJSON: Js.Json.t => result<t, string>
30
+ let checkFieldsSanity: unit => result<unit, string>
30
31
  }
31
32
 
32
33
  module Field = {
@@ -68,6 +69,8 @@ module Field = {
68
69
  // a Constraints for this in the future.
69
70
  | Morphism(t, FieldValue.t => FieldValue.t)
70
71
 
72
+ | Self
73
+
71
74
  let usingString = (f: string => 'a, value) => value->FieldValue.asString->f->FieldValue.any
72
75
  let usingInt = (f: int => 'a, value) => value->FieldValue.asInt->f->FieldValue.any
73
76
  let usingFloat = (f: float => 'a, value) => value->FieldValue.asFloat->f->FieldValue.any
@@ -104,6 +107,7 @@ module Field = {
104
107
  | Boolean => "Boolean"
105
108
  | Datetime
106
109
  | Date => "Date"
110
+ | Self => "Self (recursive)"
107
111
  | Collection(m) => {
108
112
  module M = unpack(m: Deserializer)
109
113
  "Collection of " ++ M.name
@@ -141,19 +145,24 @@ module Field = {
141
145
  }
142
146
  }
143
147
 
144
- let rec extractValue = (values: Dict.t<Js.Json.t>, field: string, shape: t): FieldValue.t => {
148
+ let rec extractValue = (
149
+ values: Dict.t<Js.Json.t>,
150
+ field: string,
151
+ shape: t,
152
+ self: t,
153
+ ): FieldValue.t => {
145
154
  switch values->Dict.get(field) {
146
- | Some(value) => value->fromUntagged(shape)
155
+ | Some(value) => value->fromUntagged(shape, self)
147
156
  | None =>
148
157
  switch shape {
149
- | DefaultWhenInvalid(_, _) => Js.Json.null->fromUntagged(shape)
150
- | Optional(_) => Js.Json.null->fromUntagged(shape)
158
+ | DefaultWhenInvalid(_, _) => Js.Json.null->fromUntagged(shape, self)
159
+ | Optional(_) => Js.Json.null->fromUntagged(shape, self)
151
160
  | OptionalWithDefault(_, default) => default
152
161
  | _ => raise(TypeError(`Missing non-optional field '${field}'`))
153
162
  }
154
163
  }
155
164
  }
156
- and fromUntagged = (untagged: Js.Json.t, shape: t): FieldValue.t => {
165
+ and fromUntagged = (untagged: Js.Json.t, shape: t, self: t): FieldValue.t => {
157
166
  switch (shape, untagged->Js.Json.classify) {
158
167
  | (Any, _) => untagged->FieldValue.any
159
168
  | (String, Js.Json.JSONString(text)) => FieldValue.string(text)
@@ -165,7 +174,7 @@ module Field = {
165
174
  let lenbases = bases->Array.length
166
175
  let lenitems = items->Array.length
167
176
  if lenbases == lenitems {
168
- let values = Array.zipBy(items, bases, fromUntagged)
177
+ let values = Array.zipBy(items, bases, (i, b) => fromUntagged(i, b, self))
169
178
  values->FieldValue.array
170
179
  } else {
171
180
  raise(
@@ -193,14 +202,14 @@ module Field = {
193
202
  }
194
203
 
195
204
  | (Array(shape), Js.Json.JSONArray(items)) =>
196
- FieldValue.array(items->Array.map(item => item->fromUntagged(shape)))
205
+ FieldValue.array(items->Array.map(item => item->fromUntagged(shape, self)))
197
206
  | (Mapping(f), Js.Json.JSONObject(values)) =>
198
- values->Dict.mapValues(v => v->fromUntagged(f))->FieldValue.mapping
207
+ values->Dict.mapValues(v => v->fromUntagged(f, self))->FieldValue.mapping
199
208
  | (Object(fields), Js.Json.JSONObject(values)) =>
200
209
  FieldValue.object(
201
210
  fields
202
211
  ->Array.map(((field, shape)) => {
203
- let value = switch extractValue(values, field, shape) {
212
+ let value = switch extractValue(values, field, shape, self) {
204
213
  | value => value
205
214
  | exception TypeError(msg) => raise(TypeError(`Field "${field}": ${msg}`))
206
215
  }
@@ -210,10 +219,10 @@ module Field = {
210
219
  )
211
220
 
212
221
  | (OptionalWithDefault(_, value), Js.Json.JSONNull) => value
213
- | (OptionalWithDefault(shape, _), _) => untagged->fromUntagged(shape)
222
+ | (OptionalWithDefault(shape, _), _) => untagged->fromUntagged(shape, self)
214
223
  | (Optional(_), Js.Json.JSONNull) => FieldValue.null
215
- | (Optional(shape), _) => untagged->fromUntagged(shape)
216
- | (Morphism(shape, f), _) => untagged->fromUntagged(shape)->f->FieldValue.any
224
+ | (Optional(shape), _) => untagged->fromUntagged(shape, self)
225
+ | (Morphism(shape, f), _) => untagged->fromUntagged(shape, self)->f->FieldValue.any
217
226
 
218
227
  | (Collection(m), Js.Json.JSONArray(items)) => {
219
228
  module M = unpack(m: Deserializer)
@@ -234,18 +243,63 @@ module Field = {
234
243
  }
235
244
 
236
245
  | (DefaultWhenInvalid(t, default), _) =>
237
- switch untagged->fromUntagged(t) {
246
+ switch untagged->fromUntagged(t, self) {
238
247
  | res => res
239
248
  | exception TypeError(msg) => {
240
249
  Js.Console.warn2("Detected and ignore (with default): ", msg)
241
250
  default
242
251
  }
243
252
  }
244
-
253
+ | (Self, _) => untagged->fromUntagged(self, self)
245
254
  | (expected, actual) =>
246
255
  raise(TypeError(`Expected ${expected->toString}, but got ${actual->_taggedToString} instead`))
247
256
  }
248
257
  }
258
+
259
+ let rec checkFieldsSanity = (name: string, fields: t, optional: bool): result<unit, string> =>
260
+ switch (fields, optional) {
261
+ | (Self, false) => Error(`${name}: Trivial infinite recursion 'let fields = Self'`)
262
+ | (Self, true) => Ok()
263
+
264
+ | (Any, _) => Ok()
265
+ | (String, _) | (Float, _) | (Int, _) => Ok()
266
+ | (Boolean, _) | (Date, _) | (Datetime, _) => Ok()
267
+ | (Morphism(_, _), _) => Ok()
268
+
269
+ | (Collection(mod), _)
270
+ | (Deserializer(mod), _) => {
271
+ module M = unpack(mod: Deserializer)
272
+ switch M.checkFieldsSanity() {
273
+ | Ok() => Ok()
274
+ | Error(msg) => Error(`${name}/ ${msg}`)
275
+ }
276
+ }
277
+
278
+ | (DefaultWhenInvalid(fields, _), _)
279
+ | (OptionalWithDefault(fields, _), _)
280
+ | (Optional(fields), _) =>
281
+ checkFieldsSanity(name, fields, true)
282
+
283
+ | (Object(fields), optional) =>
284
+ fields
285
+ ->Array.map(((fieldName, field), ()) =>
286
+ checkFieldsSanity(`${name}::${fieldName}`, field, optional)
287
+ )
288
+ ->ManyResults.bailU
289
+ ->ManyResults.map(_ => ())
290
+
291
+ /// Mappings and arrays can be empty, so their payloads are
292
+ /// automatically optional.
293
+ | (Mapping(field), _) | (Array(field), _) => checkFieldsSanity(name, field, true)
294
+
295
+ | (Tuple(fields), optional) =>
296
+ fields
297
+ ->Array.mapWithIndex((index, field, ()) =>
298
+ checkFieldsSanity(`${name}[${index->Int.toString}]`, field, optional)
299
+ )
300
+ ->ManyResults.bailU
301
+ ->ManyResults.map(_ => ())
302
+ }
249
303
  }
250
304
 
251
305
  module type Serializable = {
@@ -261,9 +315,20 @@ module MakeDeserializer = (S: Serializable): (Deserializer with type t = S.t) =>
261
315
 
262
316
  %%private(external _toNativeType: FieldValue.t => t = "%identity")
263
317
 
264
- /// Parse a `Js.Json.t` into `result<t, string>`
318
+ @doc("Checks for trivial infinite-recursion in the fields of the module.
319
+
320
+ Notice this algorithm is just an heuristic, and it might happen that are
321
+ cases of infinite-recursion not detected and cases where detection is a
322
+ false positive.
323
+
324
+ You should use this only while debugging/developing to verify your data.
325
+
326
+ ")
327
+ let checkFieldsSanity = () => Field.checkFieldsSanity(name, fields, false)
328
+
329
+ @doc("Parse a `Js.Json.t` into `result<t, string>`")
265
330
  let fromJSON = (json: Js.Json.t): result<t, _> => {
266
- switch json->Field.fromUntagged(fields) {
331
+ switch json->Field.fromUntagged(fields, fields) {
267
332
  | res => Ok(res->_toNativeType)
268
333
  | exception TypeError(e) => Error(e)
269
334
  }
@@ -0,0 +1,72 @@
1
+ type description = string
2
+ type message = string
3
+
4
+ type result<'a> = {
5
+ result: bool,
6
+ actual: 'a,
7
+ expected: 'a,
8
+ message: string,
9
+ }
10
+
11
+ type assertion
12
+ type block = assertion => unit
13
+ type matcher<'a> = 'a => bool
14
+
15
+ // Library stuff
16
+
17
+ @scope("QUnit") external start: unit => unit = "start"
18
+ @scope("QUnit") external done: (unit => unit) => unit = "done"
19
+
20
+ @send external expect: (assertion, int) => unit = "expect"
21
+ @send external pushResult: (assertion, result<'a>) => unit = "notOk"
22
+ @send external step: (assertion, description) => unit = "step"
23
+ @send external verifySteps: (assertion, array<description>) => unit = "verifySteps"
24
+
25
+ // Values
26
+
27
+ @send external equal: (assertion, 'a, 'a, description) => unit = "equal"
28
+ @send external notEqual: (assertion, 'a, 'a, description) => unit = "notEqual"
29
+
30
+ @send external isFalse: (assertion, 'a, description) => unit = "false"
31
+ @send external isTrue: (assertion, 'a, description) => unit = "true"
32
+
33
+ @send external deepEqual: (assertion, 'a, 'a, description) => unit = "deepEqual"
34
+ @send external notDeepEqual: (assertion, 'a, 'a, description) => unit = "notDeepEqual"
35
+
36
+ @send external strictEqual: (assertion, 'a, 'a, description) => unit = "strictEqual"
37
+ @send external notStrictEqual: (assertion, 'a, 'a, description) => unit = "notStrictEqual"
38
+
39
+ @send external ok: (assertion, 'a, 'a) => unit = "ok"
40
+ @send external notOk: (assertion, 'a, 'a) => unit = "notOk"
41
+
42
+ @send external propEqual: (assertion, 'a, 'a) => unit = "propEqual"
43
+ @send external notPropEqual: (assertion, 'a, 'a) => unit = "notPropEqual"
44
+
45
+ // Promises
46
+
47
+ type done = unit => unit
48
+
49
+ @send external async: assertion => done = "async"
50
+ @send external asyncMany: (assertion, int) => done = "async"
51
+ @send external rejects: (assertion, Js.Promise.t<'a>, message) => unit = "rejects"
52
+ @send external rejectsM: (assertion, Js.Promise.t<'a>, message) => unit = "rejects"
53
+ @send
54
+ external rejectMatches: (assertion, Js.Promise.t<'a>, matcher<'a>, message) => unit = "rejects"
55
+ @send
56
+ external rejectMatchesM: (assertion, Js.Promise.t<'a>, matcher<'a>, message) => unit = "rejects"
57
+ @send external timeout: (assertion, int) => unit = "timeout"
58
+
59
+ // Exceptions
60
+
61
+ @send external throws: (assertion, block, message) => unit = "throws"
62
+ @send
63
+ external throwMatches: (assertion, block, matcher<'a>, message) => unit = "throws"
64
+
65
+ type hooks
66
+ @send external before: (hooks, block) => unit = "before"
67
+ @send external beforeEach: (hooks, block) => unit = "beforeEach"
68
+ @send external afterEach: (hooks, block) => unit = "afterEach"
69
+ @send external after: (hooks, block) => unit = "after"
70
+
71
+ @module("qunit") @val external module_: (description, hooks => unit) => unit = "module"
72
+ @module("qunit") @val external test: (description, block) => unit = "test"
@@ -0,0 +1,192 @@
1
+ open JSON
2
+ open QUnit
3
+ open Prelude
4
+
5
+ %%private(external cast: 'a => 'b = "%identity")
6
+
7
+ module Appointment = {
8
+ type t = {
9
+ note: string,
10
+ date: Js.Date.t,
11
+ extra: option<string>,
12
+ }
13
+ module Deserializer = MakeDeserializer({
14
+ type t = t
15
+ open JSON.Field
16
+
17
+ let fields = Object([("note", String), ("date", Date), ("extra", Optional(String))])
18
+ })
19
+ }
20
+
21
+ module_("Basic deserializer", _ => {
22
+ let valid: array<(_, Appointment.t)> = [
23
+ (
24
+ %raw(`{"note": "Bicentennial doctor's appointment", "date": "2100-01-01"}`),
25
+ {
26
+ note: "Bicentennial doctor's appointment",
27
+ date: Js.Date.fromString("2100-01-01"),
28
+ extra: None,
29
+ },
30
+ ),
31
+ (
32
+ %raw(`{
33
+ "note": "Bicentennial doctor's appointment",
34
+ "date": "2100-01-01",
35
+ "extra": "Don't take your stop-aging pills the night before the appointment",
36
+ }`),
37
+ {
38
+ note: "Bicentennial doctor's appointment",
39
+ date: Js.Date.fromString("2100-01-01"),
40
+ extra: Some("Don't take your stop-aging pills the night before the appointment"),
41
+ },
42
+ ),
43
+ ]
44
+
45
+ test("Correctly deserializes data", qunit => {
46
+ qunit->expect(valid->Array.length)
47
+ valid->Array.forEach(
48
+ ((data, expected)) => {
49
+ Js.Console.log2("Running sample", data)
50
+ switch Appointment.Deserializer.fromJSON(data) {
51
+ | Ok(result) => qunit->deepEqual(result, expected, "result == expected")
52
+ | Error(msg) => Js.Console.error(msg)
53
+ }
54
+ },
55
+ )
56
+ })
57
+
58
+ let invalid = [
59
+ (
60
+ "Missing non-optional field",
61
+ %raw(`{"extra": "Bicentennial doctor's appointment", "date": "2100-01-01"}`),
62
+ ),
63
+ ]
64
+
65
+ test("Correctly catches invalid data", qunit => {
66
+ qunit->expect(invalid->Array.length)
67
+ invalid->Array.forEach(
68
+ ((message, data)) => {
69
+ Js.Console.log3("Running sample", message, data)
70
+ switch Appointment.Deserializer.fromJSON(data) {
71
+ | Ok(result) => Js.Console.error2("Invalid being accepted: ", result)
72
+ | Error(msg) => {
73
+ Js.Console.log2("Correctly detected:", msg)
74
+ qunit->ok(true, true)
75
+ }
76
+ }
77
+ },
78
+ )
79
+ })
80
+ })
81
+
82
+ module_("Recursive deserializer", _ => {
83
+ module TrivialTree = {
84
+ type rec t<'a> = {
85
+ data: 'a,
86
+ children: array<t<'a>>,
87
+ }
88
+
89
+ module DeserializerImpl = MakeDeserializer({
90
+ type payload
91
+ type rec t = {
92
+ data: payload,
93
+ children: array<t>,
94
+ }
95
+ open JSON.Field
96
+
97
+ let fields = Object([("data", Any), ("children", Array(Self))])
98
+ })
99
+
100
+ module Deserializer = {
101
+ include DeserializerImpl
102
+
103
+ let fromJSON = data => data->DeserializerImpl.fromJSON->Result.map(cast)
104
+ }
105
+ }
106
+
107
+ let valid: array<(_, TrivialTree.t<string>)> = [
108
+ (
109
+ %raw(`{"data": "A", "children": [{"data": "A1", "children": []}, {"data": "B", "children": [{"data": "C", "children": []}, {"data": "D", "children": []}]}]}`),
110
+ {
111
+ data: "A",
112
+ children: [
113
+ {data: "A1", children: []},
114
+ {data: "B", children: [{data: "C", children: []}, {data: "D", children: []}]},
115
+ ],
116
+ },
117
+ ),
118
+ ]
119
+
120
+ test("Trivial recursion detection: Ok", qunit => {
121
+ qunit->expect(1)
122
+ qunit->deepEqual(TrivialTree.Deserializer.checkFieldsSanity(), Ok(), "Ok")
123
+ })
124
+
125
+ test("Infinite list", qunit => {
126
+ module InfiniteList = MakeDeserializer({
127
+ open JSON.Field
128
+
129
+ type t
130
+ let fields = Object([("head", String), ("tail", Self)])
131
+ })
132
+
133
+ qunit->expect(1)
134
+ qunit->deepEqual(InfiniteList.checkFieldsSanity()->Result.isError, true, "Ok")
135
+ })
136
+
137
+ test("Finite list", qunit => {
138
+ module List = MakeDeserializer({
139
+ open JSON.Field
140
+
141
+ type t
142
+ let fields = Object([("head", String), ("tail", Optional(Self))])
143
+ })
144
+
145
+ qunit->expect(1)
146
+ qunit->deepEqual(List.checkFieldsSanity(), Ok(), "Ok")
147
+ })
148
+
149
+ test("Correctly deserializes recursive data", qunit => {
150
+ qunit->expect(valid->Array.length)
151
+ valid->Array.forEach(
152
+ ((data, expected)) => {
153
+ Js.Console.log2("Running sample", data)
154
+ switch TrivialTree.Deserializer.fromJSON(data) {
155
+ | Ok(result) => qunit->deepEqual(result, expected, "result == expected")
156
+ | Error(msg) => Js.Console.error(msg)
157
+ }
158
+ },
159
+ )
160
+ })
161
+
162
+ test("Recursion in sub-deserializer", qunit => {
163
+ module List = MakeDeserializer({
164
+ open JSON.Field
165
+
166
+ type t
167
+ let fields = Object([("head", String), ("tail", Optional(Self))])
168
+ })
169
+
170
+ module Ledger = MakeDeserializer({
171
+ open JSON.Field
172
+
173
+ type t
174
+ let fields = Object([("records", Deserializer(module(List))), ("next", Optional(Self))])
175
+ })
176
+
177
+ let data = %raw(`
178
+ {"records": {"head": "A", "tail": {"head": "B"}},
179
+ "next": {"records": {"head": "A", "tail": {"head": "B"}}}}
180
+ `)
181
+ let expected = {
182
+ "records": {"head": "A", "tail": {"head": "B", "tail": None}},
183
+ "next": {
184
+ "records": {"head": "A", "tail": {"head": "B", "tail": None}},
185
+ "next": None,
186
+ },
187
+ }
188
+
189
+ qunit->expect(1)
190
+ qunit->deepEqual(data->Ledger.fromJSON->cast, Ok(expected), "nice ledger")
191
+ })
192
+ })
package/www/index.html ADDED
@@ -0,0 +1,20 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <meta http-equiv="X-UA-Compatible" content="IE=edge">
7
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
8
+ <link rel="shortcut icon" href="#" />
9
+ <link rel="stylesheet" href="https://code.jquery.com/qunit/qunit-2.16.0.css" type="text/css" media="screen" />
10
+ <title>Document</title>
11
+ </head>
12
+
13
+ <script type="module" defer src="index.js"></script>
14
+
15
+ <body>
16
+ <div id="qunit"></div>
17
+ <div id="qunit-fixture"></div>
18
+ </body>
19
+
20
+ </html>
package/tests/compile.res DELETED
@@ -1,22 +0,0 @@
1
- open JSON
2
-
3
- module Test = {
4
- type appointment = {
5
- note: string,
6
- date: Js.Date.t,
7
- }
8
- module Appointment = MakeDeserializer({
9
- type t = appointment
10
- open JSON.Field
11
-
12
- let fields = Object([("note", String), ("date", Date)])
13
- })
14
-
15
- type foo = array<appointment>
16
- module Foo = MakeDeserializer({
17
- type t = foo
18
- open JSON.Field
19
-
20
- let fields = DefaultWhenInvalid(Array(Deserializer(module(Appointment))), []->FieldValue.array)
21
- })
22
- }