datacaster 0.9.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: '0945e72cd8cba6c89e6eed3b202e8241eb25ecc12e4a1c44bfeca2be78206306'
4
+ data.tar.gz: 9c02c5a237e1d2853a804fa1e31fd2fc13b4e46c5fc207b16461fb5ce9f30570
5
+ SHA512:
6
+ metadata.gz: f0a9b9b2aca6f58001550631cd31c381f38b95d95ea652e2a3d1184698fe82cc24bdf8a79074a606a4b3d5437d0cf1f31e8fee997765b83cac6cb4d4ce6670c5
7
+ data.tar.gz: eb99b873a43d7c73eeebade0d9a25111c47d3dcd32686f718c2ba1aaa738aca69aece76c096f0c48e21c4f55cd1d754683d02d0c8618c270a4515d039f29c5f4
@@ -0,0 +1,12 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /_yardoc/
4
+ /coverage/
5
+ /doc/
6
+ /pkg/
7
+ /spec/reports/
8
+ /tmp/
9
+
10
+ # rspec failure tracking
11
+ .rspec_status
12
+ *.gem
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
@@ -0,0 +1,6 @@
1
+ ---
2
+ language: ruby
3
+ cache: bundler
4
+ rvm:
5
+ - 2.6.3
6
+ before_install: gem install bundler -v 2.1.3
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source "https://rubygems.org"
2
+
3
+ # Specify your gem's dependencies in datacaster.gemspec
4
+ gemspec
@@ -0,0 +1,59 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ datacaster (0.9.0)
5
+ dry-monads (>= 1.3, < 1.4)
6
+
7
+ GEM
8
+ remote: https://rubygems.org/
9
+ specs:
10
+ activemodel (6.0.3.2)
11
+ activesupport (= 6.0.3.2)
12
+ activesupport (6.0.3.2)
13
+ concurrent-ruby (~> 1.0, >= 1.0.2)
14
+ i18n (>= 0.7, < 2)
15
+ minitest (~> 5.1)
16
+ tzinfo (~> 1.1)
17
+ zeitwerk (~> 2.2, >= 2.2.2)
18
+ concurrent-ruby (1.1.6)
19
+ diff-lcs (1.3)
20
+ dry-core (0.4.9)
21
+ concurrent-ruby (~> 1.0)
22
+ dry-equalizer (0.3.0)
23
+ dry-monads (1.3.5)
24
+ concurrent-ruby (~> 1.0)
25
+ dry-core (~> 0.4, >= 0.4.4)
26
+ dry-equalizer
27
+ i18n (1.8.3)
28
+ concurrent-ruby (~> 1.0)
29
+ minitest (5.14.1)
30
+ rake (12.3.3)
31
+ rspec (3.9.0)
32
+ rspec-core (~> 3.9.0)
33
+ rspec-expectations (~> 3.9.0)
34
+ rspec-mocks (~> 3.9.0)
35
+ rspec-core (3.9.2)
36
+ rspec-support (~> 3.9.3)
37
+ rspec-expectations (3.9.2)
38
+ diff-lcs (>= 1.2.0, < 2.0)
39
+ rspec-support (~> 3.9.0)
40
+ rspec-mocks (3.9.1)
41
+ diff-lcs (>= 1.2.0, < 2.0)
42
+ rspec-support (~> 3.9.0)
43
+ rspec-support (3.9.3)
44
+ thread_safe (0.3.6)
45
+ tzinfo (1.2.7)
46
+ thread_safe (~> 0.1)
47
+ zeitwerk (2.3.0)
48
+
49
+ PLATFORMS
50
+ ruby
51
+
52
+ DEPENDENCIES
53
+ activemodel (>= 5.2)
54
+ datacaster!
55
+ rake (>= 12.0)
56
+ rspec (~> 3.0)
57
+
58
+ BUNDLED WITH
59
+ 2.1.4
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2020 Eugene Zolotarev
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
@@ -0,0 +1,981 @@
1
+ # Datacaster
2
+
3
+ This gem provides run-time type checking and mapping of composite data structures (i.e. hashes/arrays of hashes/arrays of ... of literals).
4
+
5
+ Its main use is in the validation and preliminary transformation of API params requests.
6
+
7
+ ## Installing
8
+
9
+ Add to your Gemfile:
10
+
11
+ ```
12
+ gem 'datacaster'
13
+ ```
14
+
15
+ ## Why not ...
16
+
17
+ **Why not Rails strong params**?
18
+
19
+ Strong params don't provide easy composition of validations and are restricted in error (failure) reporting.
20
+
21
+ **Why not ActiveModel validations**?
22
+
23
+ ActiveModel requires a substantial amount of boilerplate (e.g. separate class for each of nested objects/hashes) and is limited in composition.
24
+
25
+ **Why not [Dry Types](https://dry-rb.org/gems/dry-types)?**
26
+
27
+ Poor validation error reporting, a substantial amount of boilerplate, arguably complex/inconsistent DSL.
28
+
29
+ ## Basics
30
+
31
+ ### Conveyor belt
32
+
33
+ Datacaster could be thought of as a conveyor belt, where each step of the conveyor either performs some validation of a value or some transformation of it.
34
+
35
+ For example, the following code validates that value is a string:
36
+
37
+ ```ruby
38
+ require 'datacaster'
39
+
40
+ validator = Datacaster.schema { string }
41
+
42
+ validator.("test") # Datacaster::ValidResult("test")
43
+ validator.("test").valid? # true
44
+ validator.("test").value # "test"
45
+ validator.("test").errors # nil
46
+
47
+ validator.(1) # Datacaster::ErrorResult(["must be string"])
48
+ validator.(1).valid? # false
49
+ validator.(1).value # nil
50
+ validator.(1).errors # ["must be string"]
51
+ ```
52
+
53
+ Datacaster instances are created with a call to `Datacaster.schema { ... }` or `Datacaster.partial_schema { ... }` (described later in this file).
54
+
55
+ Datacaster validators' results could be converted to [dry result monad](https://dry-rb.org/gems/dry-monads/1.0/result/):
56
+
57
+ ```ruby
58
+ require 'datacaster'
59
+
60
+ validator = Datacaster.schema { string }
61
+
62
+ validator.("test").to_dry_result # Success("test")
63
+ validator.(1).to_dry_result # Failure(["must be string"])
64
+ ```
65
+
66
+ `string` method call inside of the block in the examples above returns (with the help of some basic meta-programming magic) 'chainable' datacaster instance. To 'chain' datacaster instances 'logical AND' (`&`) operator is used:
67
+
68
+ ```ruby
69
+ require 'datacaster'
70
+
71
+ validator = Datacaster.schema { string & check { |x| x.length > 5 } }
72
+
73
+ validator.("test1") # Datacaster::ValidResult("test12")
74
+ validator.(1) # Datacaster::ErrorResult(["must be string"])
75
+ validator.("test") # Datacaster::ErrorResult(["is invalid"])
76
+ ```
77
+
78
+ In the code above we ensure that validated value is:
79
+
80
+ a) a string,
81
+ b) has length > 5.
82
+
83
+ If first condition is not met, second one is not evaluated at all (i.e. evaluation is always "short-circuit", just as one might expect).
84
+
85
+ Later in this file `string` and other such validations are referred to as "basic types", and `check { ... }` and other custom validations are referred to as "custom types".
86
+
87
+ It is worth noting that in `a & b` validation composition as above, if `a` in some way transforms the value and passes, then `b` receives the transformed value (though `string` validation in particular guarantees to not change the initial value).
88
+
89
+ ### Result value
90
+
91
+ All datacaster validations, when called, return an instance of `Datacaster::Result` value, i.e. `Datacaster::ValidResult` or `Datacaster::ErrorResult`.
92
+
93
+ You can call `#valid?`, `#value`, `#errors` methods directly, or, if preferred, call `#to_dry_result` method to convert `Datacaster::Result` to the corresponding `Dry::Monads::Result` (with all the included "batteries" of the latter, e.g. pattern matching, 'binding', etc.).
94
+
95
+ `#value` and `#errors` would return `#nil` if the result is, correspondingly, `ErrorResult` and `ValidResult`. No methods would raise an error.
96
+
97
+ Errors are returned as array or hash (or hash of arrays, or array of hashes, etc., for complex data structures). Each element of the returned array shows a separate error (as a string), and each key of the returned hash corresponds to the key of the validated hash. More or less errors are similar to what you expect from `ActiveModel::Errors#to_hash`.
98
+
99
+ ### Hash schema
100
+
101
+ Validating hashes is the main case scenario for datacaster. Several specific conventions are used here, which are listed below in this file.
102
+
103
+ Let's assume we want to validate that a hash (which represents data about a person):
104
+
105
+ a) is, in fact, a Hash;
106
+ a) has exactly 2 keys, `name` and `salary`,
107
+ b) key 'name' is a string,
108
+ c) key 'salary' is an integer:
109
+
110
+ ```ruby
111
+ person_validator =
112
+ Datacaster.schema do
113
+ hash_schema(
114
+ name: string,
115
+ salary: integer
116
+ )
117
+ end
118
+
119
+ person_validator.(name: "Jack Simon", salary: 50_000)
120
+ # => Datacaster::ValidResult({:name=>"Jack Simon", :salary=>50000})
121
+
122
+ person_validator.(name: "Jack Simon")
123
+ # => Datacaster::ErrorResult({:salary=>["must be integer"]})
124
+
125
+ person_validator.("test")
126
+ # => Datacaster::ErrorResult(["must be hash"])
127
+
128
+ person_validator.(name: "John Smith", salary: "1000")
129
+ # => Datacaster::ErrorResult({:salary=>["must be integer"]})
130
+
131
+ person_validator.(name: :john, salary: "1000")
132
+ # => Datacaster::ErrorResult({:name=>["must be string"], :salary=>["must be integer"]})
133
+
134
+ person_validator.(name: "John Smith", salary: 100_000, title: "developer")
135
+ # => Datacaster::ErrorResult({:title=>["must be absent"]})
136
+ ```
137
+
138
+ `Datacaster.schema` definitions don't permit, as you likely noticed from the example above, extra fields in the hash. In fact, `Datacaster.schema` automatically adds special built-in validator, called `Datacaster::Terminator`, at the end of your validation chain, which function is to ensure that all hash keys had been validated.
139
+
140
+ If you want to permit your hashes to contain extra fields, use `Datacaster.partial_schema` (it's the only difference between `.schema` and `.partial_schema`):
141
+
142
+ ```ruby
143
+ person_with_extra_keys_validator =
144
+ Datacaster.partial_schema do
145
+ hash_schema(
146
+ name: string,
147
+ salary: integer
148
+ )
149
+ end
150
+
151
+ person_with_extra_keys_validator.(name: "John Smith", salary: 100_000, title: "developer")
152
+ # => Datacaster::ValidResult({:name=>"John Smith", :salary=>100000, :title=>"developer"})
153
+ ```
154
+
155
+ Datacaster 'hash schema' makes strict difference between absent and nil values, allows to use shortcuts for defining nested schemas (with no limitation on the level of nesting), and has convinient 'AND with error aggregation' (`*`, same symbol as in numbers multiplication) for joining validation errors of multiple failures. See below in the corresponding sections.
156
+
157
+ ### Logical operators
158
+
159
+ There are 3 regular 'logical operators':
160
+
161
+ * AND (`&`)
162
+ * OR (`|`)
163
+ * IF... THEN... ELSE
164
+
165
+ And one special: AND with error aggregation (`*`).
166
+
167
+ The former 3 is described immediately below, and the latter is described in the section on hash schemas further in this file.
168
+
169
+ #### *AND operator*:
170
+
171
+ ```ruby
172
+ even_number = Datacaster.schema { integer & check { |x| x.even? } }
173
+
174
+ even_number.(2)
175
+ # => Datacaster::ValidResult(2)
176
+
177
+ even_number.(3)
178
+ # => Datacaster::ErrorResult(["is invalid"])
179
+ even_number.("test")
180
+ # => #<Datacaster::ErrorResult(["must be integer"])>
181
+ ```
182
+
183
+ If left-hand validation of AND operator passes, *its result* (not the original value) is passed to the right-hand validation. See below in this file section on transformations where this might be relevant.
184
+
185
+ #### *OR operator*:
186
+
187
+ ```ruby
188
+ # 'compare' custom type returns ValidResult if and only if validated value == compare's argument
189
+ person_or_entity = Datacaster.schema { compare(:person) | compare(:entity) }
190
+
191
+ person_or_entity.(:person) # => Datacaster::ValidResult(:person)
192
+ person_or_entity.(:entity) # => Datacaster::ValidResult(:entity)
193
+
194
+ person_or_entity.(:ngo) # => Datacaster::ErrorResult(["must be equal to :entity"])
195
+ ```
196
+
197
+ Notice that OR operator, if left-hand validation fails, passes the original value to the right-hand validation. As you see in the example above resultant error messages are not always convenient (i.e. to show something like "value must be :person or :entity" is preferable to showing somewhat misleading "must be equal to :entity"). See the next section on "IF... THEN... ELSE" for closer to the real world example.
198
+
199
+ #### *IF... THEN... ELSE operator*:
200
+
201
+ Let's suppose we want to validate that incoming hash is either 'person' or 'entity', where
202
+
203
+ - 'person' is a hash with 3 keys (kind: `:person`, name: string, salary: integer),
204
+ - 'entity' is a hash with 4 keys (kind: `:entity`, title: string, form: string, revenue: integer).
205
+
206
+ ```ruby
207
+ person_or_entity =
208
+ Datacaster.schema do
209
+ # separate 'kind' validator, ensures that 'kind' is either :person or :entity
210
+ kind_is_valid = hash_schema(
211
+ kind: check { |x| %i[person entity].include?(x) }
212
+ )
213
+
214
+ # separate person validator (excluding validation of 'kind' field)
215
+ person = hash_schema(name: string, salary: integer)
216
+
217
+ # separate entity validator (excluding validation of 'kind' field)
218
+ entity = hash_schema(title: string, form: string, revenue: integer)
219
+
220
+ kind_is_valid & hash_schema(kind: compare(:person)).then(person).else(entity)
221
+ end
222
+
223
+ person_or_entity.(
224
+ kind: :person,
225
+ name: "John Smith",
226
+ salary: 100_000
227
+ )
228
+ # => Datacaster::ValidResult({:kind=>:person, :name=>"John Smith", :salary=>100000})
229
+
230
+ person_or_entity.(
231
+ kind: :entity,
232
+ title: "Hooves and Hornes",
233
+ form: "LLC",
234
+ revenue: 5_000_000
235
+ )
236
+ # => Datacaster::ValidResult({:kind=>:entity, :title=>"Hooves and Hornes", :form=>"LLC", :revenue=>5000000})
237
+
238
+ person_or_entity.(
239
+ title: "?"
240
+ )
241
+ # => Datacaster::ErrorResult({:kind=>["is invalid"]})
242
+ ```
243
+
244
+ See below documentation on 'check' custom type to know how to provide custom error message instead of 'is invalid'.
245
+
246
+ Schema, defined above, behaves in all aspects (shown in the example and in other practical applications which might come to your mind) just as you might expect it to, after reading previous examples and the code above.
247
+
248
+ In our opinion the above example shows most laconic way to express underlying 'business-logic' (including elaborate error reporting on all kinds of failures) among all available competitor approaches/gems.
249
+
250
+ Formally, with `a.then(b).else(c)`:
251
+
252
+ * if `a` returns `ValidResult`, then `b` is called *with the result of `a`* (not the original value) and whatever `b` returns is returned;
253
+ * otherwise, `c` is called with the original value, and whatever `c` returns is returned.
254
+
255
+ `else`-part is required and could not be omitted.
256
+
257
+ Note: this construct is *not* an equivalent of `a & b | c`.
258
+
259
+ With `a.then(b).else(c)` if `a` and `b` fails, then `b`'s error is returned. With `a & b | c`, instead, `c`'s result would be returned.
260
+
261
+ ## Built-in types
262
+
263
+ Full description of all built-in types follows.
264
+
265
+ ### Basic types
266
+
267
+ #### `string`
268
+
269
+ Returns ValidResult if and only if provided value is a string. Doesn't transform the value.
270
+
271
+ #### `integer`
272
+
273
+ Returns ValidResult if and only if provided value is an integer. Doesn't transform the value.
274
+
275
+ #### `float`
276
+
277
+ Returns ValidResult if and only if provided value is a float (checked with Ruby's `#is_a?(Float)`, i.e. integers are not considered valid floats). Doesn't transform the value.
278
+
279
+ #### `decimal([digits = 8])`
280
+
281
+ Returns ValidResult if and only if provided value is either a float, integer or string representing float/integer.
282
+
283
+ Transforms the value to `BigDecimal` instance.
284
+
285
+ #### `array`
286
+
287
+ Returns ValidResult if and only if provided value is an `Array`. Doesn't transform the value.
288
+
289
+ #### `hash_value`
290
+
291
+ Returns ValidResult if and only if provided value is a `Hash`. Doesn't transform the value.
292
+
293
+ Note: this type is called `hash_value` instead of `hash`, because `hash` is reserved method name in Ruby.
294
+
295
+ ### Convenience types
296
+
297
+ #### `non_empty_string`
298
+
299
+ Returns ValidResult if and only if provided value is a string and is not empty. Doesn't transform the value.
300
+
301
+ #### `hash_with_symbolized_keys`
302
+
303
+ Returns ValidResult if and only if provided value is an instance of `Hash`. Transforms the value to `#hash_with_symbolized_keys` (requires `ActiveSupport`).
304
+
305
+ #### `integer32`
306
+
307
+ Returns ValidResult if and only if provided value is an integer and it's absolute value is <= 2_147_483_647. Doesn't transform the value.
308
+
309
+ ### Special types
310
+
311
+ #### `absent`
312
+
313
+ Returns ValidResult if and only if provided value is `Datacaster.absent` (this is singleton instance). Relevant only for hash schemas (see below). Doesn't transform the value.
314
+
315
+ #### `any`
316
+
317
+ Returns ValidResult if and only if provided value is not `Datacaster.absent` (this is singleton instance). Relevant only for hash schemas (see below). Doesn't transform the value.
318
+
319
+ #### `transform_to_value(value)`
320
+
321
+ Always returns ValidResult. The value is transformed to provided argument. Is used to provide default values, e.g.:
322
+
323
+ ```ruby
324
+ max_concurrent_connections = Datacaster.schema { compare(nil).then(transform_to_value(5)).else(integer) }
325
+
326
+ max_concurrent_connections.(9) # => Datacaster::ValidResult(9)
327
+ max_concurrent_connections.("9") # => Datacaster::ErrorResult(["must be integer"])
328
+ max_concurrent_connections.(nil) #=> #<Datacaster::ValidResult(5)>
329
+ ```
330
+
331
+ #### `remove`
332
+
333
+ Equivalent to `transform_to_value(Datacaster.absent)`. Always returns ValidResult. The value is transformed to `Datacaster.absent` (see section below on hash schemas, where this is useful).
334
+
335
+ #### `pass`
336
+
337
+ Equivalent to `transform_to_value { |x| x }`. Always returns ValidResult. Doesn't transform the value. Useful to "mark" the value as validated (see section below on hash schemas, where this could be applied).
338
+
339
+ #### `responds_to(method)`
340
+
341
+ Returns ValidResult if and only if value `#responds_to?(method)`. Doesn't transform the value.
342
+
343
+ #### `must_be(klass)`
344
+
345
+ Returns ValidResult if and only if value `#is_a?(klass)`. Doesn't transform the value.
346
+
347
+ #### `optional(base)`
348
+
349
+ Returns ValidResult if and only if value is either `Datacaster.absent` (singleton instance) or passes `base` validation. See below documentation on hash schemas for details on `Datacaster.absent`.
350
+
351
+ ```ruby
352
+ item_with_optional_price =
353
+ Datacaster.schema do
354
+ hash_schema(
355
+ name: string,
356
+ price: optional(float)
357
+ )
358
+ end
359
+
360
+ item_with_optional_price.(name: "Book", price: 1.23)
361
+ # => Datacaster::ValidResult({:name=>"Book", :price=>1.23})
362
+ item_with_optional_price.(name: "Book")
363
+ # => Datacaster::ValidResult({:name=>"Book"})
364
+
365
+ item_with_optional_price.(name: "Book", price: "wrong")
366
+ # => Datacaster::ErrorResult({:price=>["must be float"]})
367
+ ```
368
+
369
+ #### `pick(key)`
370
+
371
+ Returns ValidResult if and only if value `#is_a?(Enumerable)`.
372
+
373
+ Transforms the value to/returns:
374
+
375
+ * `value[key]` if key is set in the value
376
+ * `nil` if `value[key]` is set and is nil
377
+ * `Datacaster.absent` if key is not set
378
+
379
+ ```ruby
380
+ pick_name = Datacaster.schema { pick(:name) }
381
+
382
+ pick_name.(name: "George") # => Datacaster::ValidResult("George")
383
+ pick_name.(last_name: "Johnson") # => Datacaster::ValidResult(#<Datacaster.absent>)
384
+
385
+ pick_name.("test") # => Datacaster::ErrorResult(["must be Enumerable"])
386
+ ```
387
+
388
+ Alternative form could be used: `pick(*keys)`.
389
+
390
+ In this case, an array of results is returned, each element in which corresponds to the element in `keys` array (i.e. is an argument of the `pick`) and evaluated in accordance with the above rules.
391
+
392
+ ```ruby
393
+ pick_name_and_age = Datacaster.schema { pick(:name, :age) }
394
+
395
+ pick_name_and_age.(name: "George", age: 20) # => Datacaster::ValidResult(["George", 20])
396
+ pick_name_and_age.(last_name: "Johnson", age: 20) # => Datacaster::ValidResult([#<Datacaster.absent>, 20])
397
+
398
+ pick_name_and_age.("test") # => Datacaster::ErrorResult(["must be Enumerable"])
399
+ ```
400
+
401
+ ### "Web-form" types
402
+
403
+ These types are convenient to parse and validate POST forms and decode JSON requests.
404
+
405
+ #### `to_integer`
406
+
407
+ Returns ValidResult if and only if value is an integer, float or string representing integer/float. Transforms value to integer.
408
+
409
+ #### `to_float`
410
+
411
+ Returns ValidResult if and only if value is an integer, float or string representing integer/float. Transforms value to float.
412
+
413
+ #### `to_boolean`
414
+
415
+ Returns ValidResult if and only if value is `true`, `1`, `'true'` or `false`, `0`, `'false'`. Transforms value to `true` or `false` (using apparent convention).
416
+
417
+ #### `iso8601`
418
+
419
+ Returns ValidResult if and only if value is a string in [ISO8601](https://en.wikipedia.org/wiki/ISO_8601) date-time format.
420
+
421
+ ```ruby
422
+ dob = Datacaster.schema { iso8601 }
423
+
424
+ dob.("2011-02-03")
425
+ # => Datacaster::ValidResult(#<DateTime: 2011-02-03T00:00:00+00:00 ...>)
426
+ ```
427
+
428
+ Transforms value to `DateTime` instance.
429
+
430
+ #### `optional_param(base)`
431
+
432
+ Returns ValidResult if and only if value is absent, empty string or passes `base` validation.
433
+
434
+ If the value is empty string (`""`), transforms it to `Datacaster.absent` instance. It makes sense to use this type only in conjunction with hash schema validations (see below), where `Datacaster.absent` keys are removed from the resultant hash.
435
+
436
+ Otherwise, doesn't transform the value.
437
+
438
+ ### Custom and fundamental types
439
+
440
+ These custom types (or 'meta' types) are used to create 'hand-crafted' validators.
441
+
442
+ When `name` argument is available, that argument determines what would display with `#inspect` of that validator (and nothing else).
443
+
444
+ When `error` argument is available, that argument determines what error text (should be string, but actual error will automatically be displayed as array of strings, see examples in the previous sections of this file) will be used if validation fails.
445
+
446
+ #### `cast(name = 'Anonymous') { |value| ... }`
447
+
448
+ The most basic &mdash; "fully manual" &mdash; validator.
449
+
450
+ Calls block with the value. Returns whatever block returns.
451
+
452
+ Provided block must return either `Datacaster::Result` or `Dry::Result::Monad` (the latter will automatically be converted to the former), otherwise `cast` will raise runtime `TypeError`.
453
+
454
+ ```ruby
455
+ # Actually, better use 'check' here instead (see below)
456
+ user_id_exists =
457
+ Datacaster.schema do
458
+ cast('UserIdExists') do |user_id|
459
+ if User.exists?(user_id)
460
+ Success(user_id) # or Datacaster::ValidResult(user_id)
461
+ else
462
+ # Note: actual returned error will always be an array, despite what
463
+ # you manually set as return value of caster. E.g., ["user is not found"]
464
+ # in this example.
465
+ Failure("user is not found") # or Datacaster::ErrorResult("user is not found")
466
+ end
467
+ end
468
+ end
469
+ ```
470
+
471
+ Notice, that for this example (as is written in the comment) `check` type is better option (see below). It's actually so hard to come up with an example where explicit `cast` is the best option that we didn't manage to do that. Refrain from using `cast` unless absolutely no other type could be used.
472
+
473
+ `cast` will transform value, if such is the logic of provided block.
474
+
475
+ #### `check(name = 'Anonymous', error = 'is invalid') { |value| ... }`
476
+
477
+ Returns ValidResult if and only if provided block returns truthy value (i.e. anything except `false` and `nil`).
478
+
479
+ ```ruby
480
+ user_id_exists =
481
+ Datacaster.schema do
482
+ check('UserIdExists', 'user is not found') do |user_id|
483
+ User.exists?(user_id)
484
+ end
485
+ end
486
+ ```
487
+
488
+ Doesn't transform the value.
489
+
490
+ #### `try(name = 'Anonymous', error = 'is invalid', catched_exception:) { |value| ... }`
491
+
492
+ Returns ValidResult if and only if block finishes without exceptions. If block raises an exception:
493
+
494
+ * if exception class equals to `catched_exception`, then ErrorResult is returned;
495
+ * otherwise, exception is re-raised.
496
+
497
+ Note: instead of specific exception class an array of classes could be provided.
498
+
499
+ ```ruby
500
+ def dangerous_method!
501
+ raise RuntimeError
502
+ end
503
+
504
+ dangerous_validator =
505
+ Datacaster.schema do
506
+ try(catched_exception: RuntimeError) { |value| dangerous_method! }
507
+ end
508
+ ```
509
+
510
+ As you see from the example, that's another 'meta type', which direct use is hard to justify. Consider using `check` instead (returning boolean value from the block instead of raising error).
511
+
512
+ Doesn't transform the value.
513
+
514
+ #### `validate(active_model_validations, name = 'Anonymous')`
515
+
516
+ Requires ActiveModel.
517
+
518
+ Add `require 'datacaster/validator'` to your source code before using this.
519
+
520
+ Returns ValidResult if and only if provided ActiveModel validations passes. Otherwise, returns ActiveModel errors wrapped as ErrorResult.
521
+
522
+ ```ruby
523
+ require 'datacaster/validator'
524
+
525
+ nickname =
526
+ Datacaster.schema do
527
+ validate(format: {
528
+ with: /\A[a-zA-Z]+\z/,
529
+ message: "only allows letters"
530
+ })
531
+ end
532
+
533
+ nickname.("longshot") # Datacaster::ValidResult("longshot")
534
+ nickname.("user32") # Datacaster::ErrorResult(["only allows letters"])
535
+ ```
536
+
537
+ Doesn't transform the value.
538
+
539
+ #### `compare(reference_value, name = 'Anonymous', error = nil)`
540
+
541
+ This type is the way to ensure some value in your schema is some predefined "constant".
542
+
543
+ Returns ValidResult if and only if `reference_value` equals value.
544
+
545
+ ```ruby
546
+ agreed_with_tos =
547
+ Datacaster.partial_schema do
548
+ hash_schema(
549
+ agreed: compare(true)
550
+ )
551
+ end
552
+ ```
553
+
554
+ #### `transform(name = 'Anonymous') { |value| ... }`
555
+
556
+ Always returns ValidResult. Transforms the value: returns whatever block returned, automatically wrapping it into `ValidResult`.
557
+
558
+ ```ruby
559
+ city =
560
+ Datacaster.schema do
561
+ hash_schema(
562
+ name: string,
563
+ # convert miles to km
564
+ distance: to_float & transform { |v| v * 1.60934 }
565
+ )
566
+ end
567
+
568
+ city.(name: "Denver", distance: "2.5") # => Datacaster::ValidResult({:name=>"Denver", :distance=>4.02335})
569
+ ```
570
+
571
+ #### `transform_if_present(name = 'Anonymous') { |value| ... }`
572
+
573
+ Always returns ValidResult. If the value is `Datacaster.absent` (singleton instance, see below section on hash schemas), then `Datacaster.absent` is returned (block isn't called). Otherwise, works like `transform`.
574
+
575
+ ### Array schemas
576
+
577
+ To define compound data type, array of 'something', use `array_schema(something)` (or, synonymically, `array_of(something)`). There is no way to define array wherein each element is of different type.
578
+
579
+ ```ruby
580
+ salaries = Datacaster.schema { array_of(integer) }
581
+
582
+ salaries.([1000, 2000, 3000]) # Datacaster::ValidResult([1000, 2000, 3000])
583
+
584
+ salaries.(["one thousand"]) # Datacaster::ErrorResult({0=>["must be integer"]})
585
+ salaries.(:not_an_array) # Datacaster::ErrorResult(["must be array"])
586
+ salaries.([]) # Datacaster::ErrorResult(["must not be empty"])
587
+ ```
588
+
589
+ To allow empty array use the following construct: `compare([]) | array_of(...)`.
590
+
591
+ If you want to define array of hashes, shortcut definition could be used: instead of `array_of(hash_schema({...}))` use `array_of({...})`:
592
+
593
+ ```ruby
594
+ people =
595
+ Datacaster.schema do
596
+ array_of(
597
+ # hash_schema(
598
+ {
599
+ name: string,
600
+ salary: float
601
+ }
602
+ # )
603
+ )
604
+ end
605
+
606
+ person1 = {name: "John Smith", salary: 250_000.0}
607
+ person2 = {name: "George Johnson", salary: 50_000.0}
608
+ people.([person1, person2]) # => Datacaster::ValidResult([{...}, {...}])
609
+
610
+ people.([{salary: 250_000.0}, {salary: "50000"}])
611
+ # => Datacaster::ErrorResult({
612
+ # 0 => {:name => ["must be string"]},
613
+ # 1 => {:name => ["must be string"], :salary => ["must be float"]}
614
+ # })
615
+ ```
616
+
617
+ Notice, that extra keys of inner hashes could be validated only if each element is otherwise valid. In other words, if some of the elements have other validation errors, then "extra key must be absent" validation error won't appear on any element.
618
+
619
+ Formally, `array_of(x)` will return ValidResult if and only if:
620
+
621
+ a) provided value implements basic array methods (`#map`, `#zip`),
622
+ b) provided value is not `#empty?`,
623
+ c) each element of the provided value passes validation of `x`.
624
+
625
+ If a) fails, `ErrorResult(["must be array"])` is returned.
626
+ If b) fails, `ErrorResult(["must not be empty"])` is returned.
627
+ If c) fails, `ErrorResult({0 => ..., 1 => ...})` is returned. Wrapped hash contains keys which correspond to initial array's indices, and values correspond to failure returned from `x` validator, called for the corresponding element.
628
+
629
+ Array schema transforms array if inner type (`x`) transforms element (in this case `array_schema` works more or less like `map` function). Otherwise, it doesn't transform.
630
+
631
+ ### Hash schemas
632
+
633
+ Hash schemas are "bread and butter" of Datacaster.
634
+
635
+ To define compound data type, hash of 'something', use `hash_schema({key: type, ...})`:
636
+
637
+ ```ruby
638
+ person =
639
+ Datacaster.schema do
640
+ hash_schema(
641
+ name: string,
642
+ salary: integer
643
+ )
644
+ end
645
+
646
+ person.(name: "John Smith", salary: 100_000)
647
+ # => Datacaster::ValidResult({:name=>"John Smith", :salary=>100000})
648
+
649
+ person.(name: "John Smith", salary: "100_000")
650
+ # => Datacaster::ErrorResult({:salary=>["must be integer"]})
651
+ ```
652
+
653
+ Formally, hash schema returns ValidResult if and only if:
654
+
655
+ a) provided value `is_a?(Hash)`,
656
+ b) all values, fetched by keys mentioned in `hash_schema(...)` definition, pass corresponding validations,
657
+ c) after all checks (including logical operators), there are no unchecked keys in the hash.
658
+
659
+ If a) fails, `ErrorResult(["must be hash"])` is returned.
660
+ if b) fails, `ErrorResult(key1 => [errors...], key2 => [errors...])` is returned. Each key of wrapped "error hash" corresponds to the key of validated hash, and each value of "error hash" contains array of errors, returned by the corresponding validator.
661
+ If b) fulfilled, then and only then validated hash is checked for extra keys. If they are found, `ErrorResult(extra_key_1 => ["must be absent"], ...)` is returned.
662
+
663
+ Technically, last part is implemented with special singleton validator, called `#<Datacaster::Terminator>`, which is automatically added to the validation chain (with the use of `&` operator) by `Datacaster.schema` method. Don't be scared if you see it in the output of `#inspect` method of your validators (e.g. in `irb`).
664
+
665
+ #### Absent is not nil
666
+
667
+ In practical tasks it's important to distinguish between absent (i.e. not set or deleted) and `nil` values of a hash.
668
+
669
+ To check some value for `nil`, use ordinary `compare(nil)` validator, mentioned above.
670
+
671
+ To check some value for absence, use `absent` validator:
672
+
673
+ ```ruby
674
+ restricted_params =
675
+ Datacaster.schema do
676
+ hash_schema(
677
+ username: string,
678
+ is_admin: absent
679
+ )
680
+ end
681
+
682
+ restricted_params.(username: "test")
683
+ # => Datacaster::ValidResult({:username=>"test"})
684
+
685
+ restricted_params.(username: "test", is_admin: true)
686
+ # => Datacaster::ErrorResult({:is_admin=>["must be absent"]})
687
+ restricted_params.(username: "test", is_admin: nil)
688
+ # => Datacaster::ErrorResult({:is_admin=>["must be absent"]})
689
+ ```
690
+
691
+ More practical case is to include `absent` validator in logical expressions, e.g. `something: absent | string`. If `something` is set to `nil`, this validation will fail, which could be the desired (and hardly achieved by any other validation framework) behavior.
692
+
693
+ Also, see documentation for `optional(base)` and `optional_param(base)` above. If some value becomes `Datacaster.absent` in its chain of validations-transformations, it is removed from the resultant hash (on the same stage where the lack of extra/unchecked keys in the hash is validated):
694
+
695
+ ```ruby
696
+ person =
697
+ Datacaster.schema do
698
+ hash_schema(
699
+ name: string,
700
+ dob: optional(iso8601)
701
+ )
702
+ end
703
+
704
+ person.(name: "John Smith", dob: "1990-05-23")
705
+ # => Datacaster::ValidResult({:name=>"John Smith", :dob=>#<DateTime: 1990-05-23T00:00:00+00:00 ...>})
706
+ person.(name: "John Smith")
707
+ # => Datacaster::ValidResult({:name=>"John Smith"})
708
+
709
+ person.(name: "John Smith", dob: "invalid date")
710
+ # => Datacaster::ErrorResult({:dob=>["must be iso8601 string"]})
711
+ ```
712
+
713
+ Another use-case for `Datacaster.absent` is to directly set some key to that value. In that case, it will be removed from the resultant hash. The most convenient way to do that is to use `remove` type (described above in this file):
714
+
715
+ ```ruby
716
+ anonimized_person =
717
+ Datacaster.schema do
718
+ hash_schema(
719
+ name: remove,
720
+ dob: pass
721
+ )
722
+ end
723
+
724
+ anonimized_person.(name: "John Johnson", dob: "1990-05-23")
725
+ # => Datacaster::ValidResult({:dob=>"1990-05-23"})
726
+ ```
727
+
728
+ Note: we need to `pass` `dob` field to "mark" it as validated, otherwise `Datacaster.schema` will return ErrorResult, notifying that unchecked extra field was in the initial hash.
729
+
730
+ #### Schema vs Partial schema
731
+
732
+ As written in the beginning of this section on `hash_schema`, at the last stage of validation it is ensured that hash contains no extra keys.
733
+
734
+ Sometimes it is necessary to omit that requirement and allow for hash to contain any keys (in addition to the ones defined in `hash_schema`). One practical use-case for that is when datacaster definitions are spread among several files.
735
+
736
+ Let's say we have:
737
+
738
+ * 'people' (hashes with `name: string`, `description: string` and `kind: 'person'` fields),
739
+ * 'entities' (hash with `title: string`, `description: string` and `kind: 'entity'` fields).
740
+
741
+ In other words, we have some polymorphic resource, which type is defined by `kind` field, and which has common fields for all its "sub-kinds" (in this example: `description`), and also fields specific to each "kind" (in database we often model this as [STI](https://api.rubyonrails.org/v6.0.3.2/classes/ActiveRecord/Base.html#class-ActiveRecord::Base-label-Single+table+inheritance)).
742
+
743
+ Here's how we would model this type with Datacaster (filenames are given for the sake of explanation, use whatever convention your project dictates; also, use whatever codestyle is preferred, below is shown the one which we prefer):
744
+
745
+ ```ruby
746
+ # commmon_fields_validator.rb
747
+ CommonFieldsValidator =
748
+ Datacaster.partial_schema do
749
+ # validate common fields
750
+ hash_schema(
751
+ description: string
752
+ )
753
+ end
754
+
755
+ # person_validator.rb
756
+ PersonValidator =
757
+ Datacaster.partial_schema do
758
+ # validate fields specific to person
759
+ hash_schema(
760
+ name: string,
761
+ kind: compare('person')
762
+ )
763
+ end
764
+
765
+ # entity_validator.rb
766
+ EntityValidator =
767
+ Datacaster.partial_schema do
768
+ # validate fields specific to entity
769
+ hash_schema(
770
+ title: string,
771
+ kind: compare('entity')
772
+ )
773
+ end
774
+
775
+ # record_validator.rb
776
+ RecordValidator =
777
+ Datacaster.schema do
778
+ # separate validator for 'kind' field - to produce convenient error message
779
+ kind = check("Kind", "must be either 'person' or 'enity'") do |v|
780
+ %w(person entity).include?(v)
781
+ end
782
+
783
+ # check that 'kind' field is correct and then select validator
784
+ # in accordance with it
785
+ hash_schema(kind: kind) & CommonFieldsValidator &
786
+ hash_schema(kind: compare('person')).
787
+ then(PersonValidator).
788
+ else(EntityValidator)
789
+ end
790
+ ```
791
+
792
+ See "IF... THEN... ELSE" section above in this file for full description of how `a.then(b).else(c)` validator works.
793
+
794
+ Examples of how this validator would work:
795
+
796
+ ```ruby
797
+ # some_file.rb
798
+
799
+ RecordValidator.(
800
+ kind: 'person',
801
+ name: 'George Johnson',
802
+ description: 'CEO'
803
+ )
804
+ # => Datacaster::ValidResult({:kind=>"person", :name=>"George Johnson", :description=>"CEO"})
805
+
806
+ RecordValidator.(kind: 'unknown')
807
+ # => Datacaster::ErrorResult({:kind=>["must be either 'person' or 'enity'"]})
808
+ RecordValidator.(
809
+ kind: 'person',
810
+ name: 'George Johnson',
811
+ description: 'CEO',
812
+ extra: :key
813
+ )
814
+ # => Datacaster::ErrorResult({:extra=>["must be absent"]})
815
+ ```
816
+
817
+ Note that only the usage of `Datacaster.partial_schema` instead of `Datacaster.schema` allowed us to compose several `hash_schema`s from different files (from different calls to Datacaster API).
818
+
819
+ Had we used `schema` everywhere, `CommonFieldsValidator` would return failure for records which are supposed to be valid, because they would contain "extra" (i.e. not defined in `CommonFieldsValidator` itself) keys (e.g. `name` for person).
820
+
821
+ As a rule of thumb, use `partial_schema` in any "intermediary" validators (extracted for the sake of clarity of code and reusability) and use `schema` in any "end" validators (ones which receive full record as input and use intermediary validators behind the scenes).
822
+
823
+ #### AND with error aggregation (`*`)
824
+
825
+ Often it is useful to run validator which are "further down the conveyor" (i.e. placed at the right-hand side of AND operator `&`) even if current (i.e. left-hand side) validator has failed.
826
+
827
+ Let's say we have extracted some "common validations" and have some concrete validators, which utilize these reusable common validations (more or less repeating the motif of the previous example, shortening non-essential for this section parts for clarity):
828
+
829
+ ```ruby
830
+ CommonValidator =
831
+ Datacaster.partial_schema do
832
+ hash_schema(
833
+ description: string
834
+ )
835
+ end
836
+
837
+ PersonValidator =
838
+ Datacaster.schema do
839
+ hash_schema(
840
+ name: string
841
+ )
842
+ end
843
+
844
+ RecordValidator =
845
+ Datacaster.schema do
846
+ CommonValidator & PersonValidator
847
+ end
848
+ ```
849
+
850
+ This code will work as expected (i.e. `RecordValidator`, the "end" validator, will check that provided hash value both has `name` and `description` string fields), except for one specific case:
851
+
852
+ ```ruby
853
+ RecordValidator.(kind: 'person', name: 1)
854
+ # => Datacaster::ErrorResult({:description=>["must be string"]})
855
+ ```
856
+
857
+ It correctly returns `ErrorResult`, but it doesn't mention that in addition to `description` being wrongfully absent, `name` field is of wrong type (integer instead of string). That could be inconvenient where Datacaster is used, for example, as a params validator for an API service: end user of the API would need to repeatedly send requests, essentially "brute forcing" his way in through all the errors (fixing them one by one), instead of having the list of all errors in one iteration.
858
+
859
+ Specifically to resolve this, "AND with error aggregation" (`*`) operator should be used in place of regular AND (`&`):
860
+
861
+ ```ruby
862
+ RecordValidator =
863
+ Datacaster.schema do
864
+ CommonValidator * PersonValidator
865
+ end
866
+
867
+ RecordValidator.(kind: 'person', name: 1)
868
+ # => Datacaster::ErrorResult({:description=>["must be string"], :name=>["must be string"]})
869
+ ```
870
+
871
+ Note: "star" (`*`) has been chosen arbitrarily among available Ruby operators. It shouldn't be read as multiplication (and, in fact, in Ruby it is used not only as multiplication sign).
872
+
873
+ Described in this example is the only case where `*` and `&` differ: in all other aspects they are full equivalents.
874
+
875
+ Formally, "AND with error aggregation" (`*`):
876
+
877
+ a) if left-hand side fails, calls right-hand side anyway and then returns aggregated (merged) `ErrorResult`s,
878
+ b) in all other cases behaves as regular "AND" (`&`).
879
+
880
+ ### Shortcut nested definitions
881
+
882
+ Datacaster aimed at ease of use where multi-level embedded structures need to be validated, boilerplate reduced to inevitable minimum.
883
+
884
+ The words `hash_schema` and `array_schema`/`array_of` could be, therefore, omitted from the definition of nested structures (replaced with `{...}` and `[...]` correspondingly):
885
+
886
+ ```ruby
887
+ # full definition
888
+ person =
889
+ Datacaster.schema do
890
+ hash_schema(
891
+ name: string,
892
+ date_of_birth: hash_schema(
893
+ day: integer,
894
+ month: integer,
895
+ year: integer
896
+ ),
897
+ friends: array_of(
898
+ hash_schema(
899
+ id: integer,
900
+ login: string
901
+ )
902
+ )
903
+ )
904
+ end
905
+
906
+ # shortcut definition
907
+ person =
908
+ Datacaster.schema do
909
+ hash_schema(
910
+ name: string,
911
+ date_of_birth: {
912
+ day: integer,
913
+ month: integer,
914
+ year: integer
915
+ },
916
+ friends: [
917
+ {
918
+ id: integer,
919
+ login: string
920
+ }
921
+ ]
922
+ )
923
+ end
924
+ ```
925
+
926
+ Note: in "root" scope (immediately inside of `schema { ... }` block) words `hash_schema` and `array_of` are still required. We consider that allowing to omit them as well would hurt readability of code.
927
+
928
+ ### Mapping hashes: `transform_to_hash`
929
+
930
+ One common task in processing compound data structures is to map one set of hash keys to another set. That's where `transform_to_hash` type comes to play (see also `pluck` and `remove` description above in this file).
931
+
932
+ ```ruby
933
+ city_with_distance =
934
+ Datacaster.schema do
935
+ transform_to_hash(
936
+ distance_in_km: pick(:distance_in_meters) & transform { |x| x / 1000 },
937
+ distance_in_miles: pick(:distance_in_meters) & transform { |x| x / 1000 * 1.609 },
938
+ distance_in_meters: remove
939
+ )
940
+ end
941
+
942
+ city_with_distance.(distance_in_meters: 1200.0)
943
+ # => Datacaster::ValidResult({:distance_in_km=>1.2, :distance_in_miles=>1.9307999999999998})
944
+ ```
945
+
946
+ Of course, order of keys in the definition hash doesn't change anything.
947
+
948
+ Formally, `transform_to_hash`:
949
+
950
+ a) transforms (any) value to hash;
951
+ b) this hash will contain keys listed in `transform_to_hash` definition;
952
+ c) value of these keys will be: initial value (*not the corresponding key of it, the value altogether*) transformed with the corresponding validator/type;
953
+ d) if any of the values from c) happen to be `Datacaster.absent`, this value *with its key* is removed from the resultant hash;
954
+ e) if the initial value happens to also be a hash, all its keys, except those which had been transformed, are merged to the resultant hash.
955
+
956
+ `transform_to_hash` will return ValidResult if and only if all transformations return ValidResults.
957
+
958
+ `transform_to_hash` will always transform the initial value.
959
+
960
+ Here is what is happening when `city_with_distance` (from the example above) is called:
961
+
962
+ * Initial hash `{distance_in_meters: 1200}` is passed to `transform_to_hash`
963
+ * `transform_to_hash` reads through its definition and creates resultant hash with the keys `distance_in_km`, `distance_in_miles`, `distance_in_meters`
964
+ * The key `distance_in_km` of the resultant hash in the transformation of the initial hash: firstly, hash is transformed to the value of its key with `pluck`, then that value is divided by 1000
965
+ * Similarly, `distance_in_miles` value is built
966
+ * `distance_in_meters` value is created by transforming initial value to `Datacaster.absent` (that is how `remove` works)
967
+
968
+ Note: because of point e) above we need to explicitly delete `distance_in_meters` key, because otherwise `transform_to_hash` will copy it to the resultant hash without validation. And all non-validated keys at the end of `Datacaster.schema` block (as explained above in section on partial schemas) result in error.
969
+
970
+ ## Contributing
971
+
972
+ Fork, create issues and make PRs as usual.
973
+
974
+ ## Ideas/TODO
975
+
976
+ * Support pattern matching on Datacaster::Result
977
+ * Duplicate all standard ActiveModel validations as built-in datacaster counterparts
978
+
979
+ ## License
980
+
981
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).