composable_validations 0.0.9

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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 859b71a70691f26e28d171883bd5297af8897692
4
+ data.tar.gz: a2e612c3c41de11b37a828a4cf8466c01a6682b3
5
+ SHA512:
6
+ metadata.gz: 35890438972b2e2898fe20aea2a8e25184b19ff863df5a69208ae4da10d66996cdc50c2b6dff42efb5cd6bb517c02a3b62f2b3cf928baf00564add5b1463ad67
7
+ data.tar.gz: 1613d3040d41f1fdbbefbfe4098bf4c901711e1a4eeee76a5d4a651da0f5846e57e8266228c5f04480d6a3c07e9f49dd7967409e2eb78a7dfaa64e334c665b15
data/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2016 Shutl Ltd
2
+
3
+ MIT License
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,735 @@
1
+ # Composable Validations
2
+
3
+ Gem for validating complex JSON payloads.
4
+
5
+ ## Features
6
+ * allows composition of generic validators into readable and reusable
7
+ validation rules ([Composability](#composability))
8
+ * returned errors always contain exact path of the invalid payload element
9
+ ([Path to invalid element](#path-to-an-invalid-element))
10
+ * easy to extend with new validators ([Custom validators](#custom-validators))
11
+ * overridable error messages ([Overriding error messages](#overriding-error-messages))
12
+
13
+ ## Requirements
14
+
15
+ * Ruby 2+
16
+ * A tolerance to parantheses... the validator code has rather "lispy" functional look and feel
17
+
18
+ ## Install
19
+
20
+ ```
21
+ gem install composable_validations
22
+ ```
23
+
24
+ ## Quick guide
25
+
26
+ This gem allows you to build a validator - how/when you call this validation is up to you.
27
+
28
+ ### Basic example
29
+
30
+ Say we want to validate a payload that specifies a person with name and age. E.g. `{"person" => {"name"
31
+ => "Bob", "age" => 28}}`
32
+
33
+ ```ruby
34
+ require 'composable_validations'
35
+
36
+ include ComposableValidations
37
+
38
+ # building validator function
39
+ validator = a_hash(
40
+ allowed_keys("person"),
41
+ key("person", a_hash(
42
+ allowed_keys("name", "age"),
43
+ key("name", non_empty_string),
44
+ key("age", non_negative_integer))))
45
+
46
+ # invalid payload with non-integer age
47
+ payload = {
48
+ "person" => {
49
+ "name" => 123,
50
+ "age" => "mistake!"
51
+ }
52
+ }
53
+
54
+ # container for error messages
55
+ errors = {}
56
+
57
+ # application of the validator to the payload with default error messages
58
+ valid = default_errors(validator).call(payload, errors)
59
+
60
+ if valid
61
+ puts "payload is valid"
62
+ else
63
+ # examine error messages collected by validator
64
+ puts errors.inspect
65
+ end
66
+ ```
67
+ In the example above the payload is invalid and as a result `valid` has value
68
+ `false` and `errors` contains:
69
+ ```
70
+ {"person/name"=>["must be a string"], "person/age"=>["must be an integer"]}
71
+ ```
72
+ Note that invalid elements of the payload are identified by exact path within
73
+ the payload.
74
+
75
+ ### Sinatra app
76
+
77
+ When using this gem in your application code you would only include
78
+ `ComposableValidations` module in classes responsible for validation.
79
+
80
+ Extending previous example into Sinatra app:
81
+
82
+ ```ruby
83
+ require 'sinatra'
84
+ require 'json'
85
+ require 'composable_validations'
86
+
87
+ post '/' do
88
+ payload = JSON.parse(request.body.read)
89
+ validator = PersonValidator.new(payload)
90
+
91
+ if validator.valid?
92
+ status(204)
93
+ else
94
+ status(422)
95
+ validator.errors.to_json
96
+ end
97
+ end
98
+
99
+ class PersonValidator
100
+ include ComposableValidations
101
+ attr_reader :errors
102
+
103
+ def initialize(payload)
104
+ @payload = payload
105
+ @errors = {}
106
+
107
+ @validator = a_hash(
108
+ allowed_keys("person"),
109
+ key("person", a_hash(
110
+ allowed_keys("name", "age"),
111
+ key("name", non_empty_string),
112
+ key("age", non_negative_integer))))
113
+ end
114
+
115
+ def valid?
116
+ default_errors(@validator).call(@payload, @errors)
117
+ end
118
+ end
119
+ ```
120
+
121
+ ### Arrays
122
+
123
+ The previous examples showed validation of a JSON object. We can also
124
+ validate JSON arrays. Let's add list of hobbies to our person object from
125
+ the previous examples:
126
+ ```ruby
127
+ {
128
+ "person" => {
129
+ "name" => "Bob",
130
+ "age" => 28,
131
+ "hobbies" => ["knitting", "horse riding"]
132
+ }
133
+ }
134
+ ```
135
+ We will also not accept people with fewer than two hobbies. Validator for this
136
+ payload:
137
+ ```ruby
138
+ a_hash(
139
+ allowed_keys("person"),
140
+ key("person", a_hash(
141
+ allowed_keys("name", "age", "hobbies"),
142
+ key("name", non_empty_string),
143
+ key("age", non_negative_integer),
144
+ key("hobbies", array(
145
+ min_size(2),
146
+ each(non_empty_string))))))
147
+ ```
148
+ Try to apply this validator to the payload containing invalid list of hobbies
149
+ ```
150
+ ...
151
+ "hobbies" => ["knitting", {"not" => "allowed"}, "horse riding"]
152
+ ...
153
+ ```
154
+ and you'll get errors specifying exactly where the invalid element is:
155
+ ```
156
+ {"person/hobbies/1"=>["must be a string"]}
157
+ ```
158
+
159
+ ### Dependent validations
160
+
161
+ Sometimes we need to ensure that elements of the payload are in certain
162
+ relation.
163
+
164
+ We can ensure simple relations between keys using validators
165
+ `key_greater_than_key`, `key_less_than_key` etc. Check out
166
+ [Composability](#composability) for example of simple relation between keys.
167
+
168
+ ### Uniqueness
169
+
170
+ For uniqueness validation follow the example in [Custom
171
+ validators](#custom-validators).
172
+
173
+ ## Key concepts
174
+
175
+ ### Validators
176
+
177
+ Validator is a function returning boolean value and having following signature:
178
+ ```ruby
179
+ lambda { |validated_object, errors_hash, path| ... }
180
+ ```
181
+ * `errors_hash` is mutated while errors are collected by validators.
182
+ * `path` represents a path to the invalid element within the JSON object. It is
183
+ an array of strings (keys in hash map) and integers (indexes of an array).
184
+ E.g. if validated payload is `{"numbers" => [1, 2, "abc", 4]}`, path to
185
+ invalid element "abc" is `["numbers", 2]`.
186
+
187
+ This gem comes with basic validators like `a_hash`, `array`, `string`,
188
+ `integer`, `float`, `date_string`, etc. You can find complete list of
189
+ validators [below](#api). Adding new validators is explained in ([Custom
190
+ validators](#custom-validators)).
191
+
192
+ ### Combinators
193
+
194
+ Validators can be composed using two combinators:
195
+
196
+ * `run_all(*validators)` - applies all validators collecting errors from all of
197
+ them and returning false if any of the validators returns false. Useful when
198
+ collecting errors of independent validators e.g. fields of the hash.
199
+
200
+ * `fail_fast(*validators)` - applies validators returning false on first failing
201
+ validator. Useful when using validators depending on some preconditions. For
202
+ example when checking that a value is non negative, you want to ensure first
203
+ that it is a number: `fail_fast(float, non_negative)`.
204
+
205
+ Return values of above combinators are themselves validators. This way they can
206
+ be further composed into more powerful validation rules.
207
+
208
+ ### Composability
209
+
210
+ We want to validate object representing opening hours of a store. E.g. store
211
+ opened from 9am to 5pm would be represented by
212
+ ```ruby
213
+ {"from" => 9, "to" => 17}
214
+ ```
215
+ Let's start by building validator ensuring that payload is a hash where both
216
+ `from` and `to` are integers:
217
+ ```ruby
218
+ a_hash(
219
+ key("from", integer),
220
+ key("to", integer))
221
+ ```
222
+ We also want to make sure that extra keys like
223
+ ```ruby
224
+ {"from" => 9, "to" => 17, "something" => "wrong"}
225
+ ```
226
+ are not allowed. Let's fix it by using `allowed_keys` validator:
227
+ ```ruby
228
+ a_hash(
229
+ allowed_keys("from", "to"),
230
+ key("from", integer),
231
+ key("to", integer))
232
+ ```
233
+ Better, but we don't want to allow negative hours like this:
234
+ ```ruby
235
+ {"from" => -1, "to" => 17}
236
+ ```
237
+ We can fix it by using more specific integer validator:
238
+ ```ruby
239
+ a_hash(
240
+ allowed_keys("from", "to"),
241
+ key("from", non_negative_integer),
242
+ key("to", non_negative_integer))
243
+ ```
244
+ Let's assume here that we represent store opened all day as
245
+ ```ruby
246
+ {"from" => 0, "to" => 24}
247
+ ```
248
+ so hours greater than 24 should also be invalid. We can validate hour by
249
+ composing `non_negative_integer` validator with `less_or_equal` using
250
+ `fail_fast` combinator:
251
+ ```ruby
252
+ hour = fail_fast(non_negative_integer, less_or_equal(24))
253
+
254
+ a_hash(
255
+ allowed_keys("from", "to"),
256
+ key("from", hour),
257
+ key("to", hour))
258
+ ```
259
+ This validator still has a little problem. Opening hours like this are not
260
+ rejected:
261
+ ```ruby
262
+ {"from" => 21, "to" => 1}
263
+ ```
264
+ We have to make sure that closing is not before opening. We can do it by using
265
+ `key_greater_than_key` validator:
266
+ ```ruby
267
+ key_greater_than_key("to", "from")
268
+ ```
269
+ and our validator will look like this:
270
+ ```ruby
271
+ a_hash(
272
+ allowed_keys("from", "to"),
273
+ key("from", hour),
274
+ key("to", hour),
275
+ key_greater_than_key("to", "from"))
276
+ ```
277
+ That looks good, but it's not complete yet. `a_hash` validator applies all
278
+ validators to the provided payload by using `run_all` combinator. This
279
+ behaviour is problematic if our `from` or `to` keys are missing or are not
280
+ valid integers. Payload
281
+ ```ruby
282
+ {"from" => "abc", "to" => 17}
283
+ ```
284
+ will cause an exception as `key_greater_than_key` can not compare string to
285
+ integer. Let's fix it by using `fail_fast` and `run_all` combinators:
286
+ ```ruby
287
+ a_hash(
288
+ allowed_keys("from", "to"),
289
+ fail_fast(
290
+ run_all(
291
+ key("from", hour),
292
+ key("to", hour)),
293
+ key_greater_than_key("to", "from")))
294
+ ```
295
+ This way if `from` and `to` are not both valid hours we will not be comparing
296
+ them.
297
+
298
+ You can see this validator reused in a bigger example
299
+ [below](#path-to-an-invalid-element).
300
+
301
+ ## Path to an invalid element
302
+
303
+ Validation errors on deeply nested JSON structure will always contain exact
304
+ path to the invalid element.
305
+
306
+ ### Example
307
+
308
+ Let's say we validate stores. Example of store object:
309
+
310
+ ```ruby
311
+ store = {
312
+ "store" => {
313
+ "name" => "Scrutton Street",
314
+ "description" => "large store",
315
+ "opening_hours" => {
316
+ "monday" => {"from" => 9, "to" => 17},
317
+ "tuesday" => {"from" => 9, "to" => 17},
318
+ "wednesday"=> {"from" => 9, "to" => 17},
319
+ "thursday" => {"from" => 9, "to" => 17},
320
+ "friday" => {"from" => 9, "to" => 17},
321
+ "saturday" => {"from" => 10, "to" => 16}
322
+ },
323
+ "employees"=> ["bob", "alice"]
324
+ }
325
+ }
326
+ ```
327
+
328
+ Definition of the store validator (using `from_to` built in the [previous section](#composability)):
329
+ ```ruby
330
+ hour = fail_fast(non_negative_integer, less_or_equal(24))
331
+
332
+ from_to = a_hash(
333
+ allowed_keys("from", "to"),
334
+ fail_fast(
335
+ run_all(
336
+ key("from", hour),
337
+ key("to", hour)),
338
+ key_greater_than_key("to", "from")))
339
+
340
+ store_validator = a_hash(
341
+ allowed_keys("store"),
342
+ key("store",
343
+ a_hash(
344
+ allowed_keys("name", "description", "opening_hours", "employees"),
345
+ key("name", non_empty_string),
346
+ optional_key("description"),
347
+ key("opening_hours",
348
+ a_hash(
349
+ allowed_keys("monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"),
350
+ optional_key("monday", from_to),
351
+ optional_key("tuesday", from_to),
352
+ optional_key("wednesday", from_to),
353
+ optional_key("thursday", from_to),
354
+ optional_key("friday", from_to),
355
+ optional_key("saturday", from_to),
356
+ optional_key("sunday", from_to))),
357
+ key("employees", array(each(non_empty_string))))))
358
+ ```
359
+
360
+ Let's say we try to validate store that has Wednesday opening hours invalid
361
+ (closing time before opening time) like this:
362
+ ```ruby
363
+ ...
364
+ "wednesday"=> {"from" => 9, "to" => 7},
365
+ ...
366
+ ```
367
+ Now we use store validator to fill in the collection of errors using default
368
+ error messages:
369
+ ```ruby
370
+ errors = {}
371
+ result = default_errors(store_validator).call(store, errors)
372
+ ```
373
+ Result is `false` and we get validation error in the `errors` hash:
374
+ ```ruby
375
+ {"store/opening_hours/wednesday/to" => ["must be greater than from"]}
376
+ ```
377
+ You can find this example in
378
+ [functional spec](https://github.com/shutl/composable_validations/blob/master/spec/functional/composable_validations_spec.rb)
379
+ ready for experiments.
380
+
381
+ ## Overriding error messages
382
+
383
+ This gem comes with set of [default error
384
+ messages](https://github.com/shutl/composable_validations/blob/master/lib/composable_validations/default_error_messages.rb).
385
+ There are few ways to provide your own error messages.
386
+
387
+ ### Local override
388
+
389
+ You can override error message when building your validator:
390
+ ```ruby
391
+ a_hash(
392
+ key("from", integer("custom error message")),
393
+ key("to", integer("another custom error message")))
394
+ ```
395
+ This approach is good if you need just few specialized error messages for
396
+ different parts of your payload.
397
+
398
+ ### Global override
399
+
400
+ If you need to change some of the error messages across all your validators you
401
+ can provide map of error messages. Keys in the map are symbols matching names
402
+ of basic validators:
403
+ ```ruby
404
+ error_overrides = {
405
+ string: "not a string",
406
+ integer: "not an integer"
407
+ }
408
+
409
+ errors = {}
410
+ errors_container = ComposableValidations::Errors.new(errors, error_overrides)
411
+ result = validator.call(valid_data, errors_container, nil)
412
+ ```
413
+ Note that your error messages don't need to be strings. You could for example
414
+ use rendering function that returns combination of error code, error context
415
+ and human readable message:
416
+ ```ruby
417
+ error_overrides = {
418
+ key_greater_than_key: lambda do |validated_object, path, key1, key2|
419
+ {
420
+ code: 123,
421
+ context: [key1, key2],
422
+ message: "#{key1}=#{object[key1]} is not less than or equal to #{key2}=#{object[key2]}"
423
+ }
424
+ end
425
+ }
426
+
427
+ errors = {}
428
+ errors_container = ComposableValidations::Errors.new(errors, error_overrides)
429
+ result = validator.call(valid_data, errors_container, nil)
430
+ ```
431
+ And when applied to invalid payload your validator will return an error:
432
+ ```ruby
433
+ {
434
+ "store/opening_hours/wednesday/to"=>
435
+ [
436
+ {
437
+ :code=>123,
438
+ :context=>["to", "from"],
439
+ :message=>"to=17 is not less than or equal to from=24"
440
+ }
441
+ ]
442
+ }
443
+ ```
444
+ You can experiment with this example in the [specs](https://github.com/shutl/composable_validations/blob/master/spec/functional/error_overrides_spec.rb#L19).
445
+
446
+ ### Override error container
447
+
448
+ You can override error container class and provide any error collecting
449
+ behaviour you need. The only method error container must provide is:
450
+ ```ruby
451
+ def add(msg, path, object)
452
+ ```
453
+ where
454
+ * `msg` is a symbol of an error or an array where first element is a symbol of
455
+ error and remaining elements are context needed to render the error message.
456
+ * `path` represents a path to the invalid element within the JSON object. It is
457
+ an array of strings (keys in hash map) and integers (indexes in array).
458
+ * `object` is a validated object.
459
+
460
+ Example of error container that just collects error paths:
461
+ ```ruby
462
+ class CollectPaths
463
+ attr_reader :paths
464
+
465
+ def initialize
466
+ @paths = []
467
+ end
468
+
469
+ def add(msg, path, object)
470
+ @paths << path
471
+ end
472
+ end
473
+
474
+ validator = ...
475
+ errors_container = CollectPaths.new
476
+ result = validator.call(valid_data, errors_container, nil)
477
+ ```
478
+ and example of the value of `errors_container.paths` after getting an error:
479
+ ```ruby
480
+ [["store", "opening_hours", "wednesday", "to"]]
481
+ ```
482
+ You can experiment with this example in the [spec](https://github.com/shutl/composable_validations/blob/master/spec/functional/error_overrides_spec.rb#L80).
483
+
484
+ ## Custom validators
485
+
486
+ You can create your own validators as functions returning lambdas with signature
487
+ ```ruby
488
+ lambda { |validated_object, errors_hash, path| ... }
489
+ ```
490
+ Use `error` helper function to add errors to the error container and
491
+ functions `validate`, `precheck` and `nil_or` to avoid boilerplate.
492
+
493
+ ### Example
494
+
495
+ Let's say we have an ActiveRecord model Store and API allowing update of the
496
+ store name. We will be receiving payload:
497
+ ```ruby
498
+ { name: 'new store name' }
499
+ ```
500
+ We can build validator ensuring uniqueness of the store name:
501
+ ```ruby
502
+ a_hash(
503
+ allowed_keys('name'),
504
+ key('name',
505
+ non_empty_string,
506
+ unique_store_name))
507
+ ```
508
+ where `unique_store_name` is defined as:
509
+ ```ruby
510
+ def unique_store_name
511
+ lambda do |store_name, errors, path|
512
+ if !Store.exists?(name: store_name)
513
+ true
514
+ else
515
+ error(errors, "has already been taken", store_name, path)
516
+ end
517
+ end
518
+ end
519
+ ```
520
+ Note that we could simplify this code by using `validate` helper method:
521
+ ```ruby
522
+ def unique_store_name
523
+ validate("has already been taken") do |store_name|
524
+ !Store.exists?(name: store_name)
525
+ end
526
+ end
527
+ ```
528
+ We could also generalize this function and end up with generic ActiveModel
529
+ attribute uniqueness validator ready to be reused:
530
+ ```ruby
531
+ def unique(klass, attr_name)
532
+ validate("has already been taken") do |attr_value|
533
+ !klass.exists?(attr_name => attr_value)
534
+ end
535
+ end
536
+
537
+ a_hash(
538
+ allowed_keys('name'),
539
+ key('name',
540
+ non_empty_string,
541
+ unique(Store, :name)))
542
+ ```
543
+
544
+ ## API
545
+
546
+ * `a_hash(*validators)` - ensures that the validated object is a hash and then
547
+ applies all `validators` in sequence using `run_all` combinator.
548
+
549
+ * `allowed_keys(*allowed_keys)` - ensures that validated hash has only keys
550
+ provided as arguments.
551
+
552
+ * `array(*validators)` - ensures that the validated object is an array and then
553
+ applies all `validators` in sequence using `run_all` combinator.
554
+
555
+ * `at_least_one_of(*keys)` - ensures that the validated hash has at least one
556
+ of the keys provided as arguments.
557
+
558
+ * `boolean` - ensures that validated object is `true` or `false`.
559
+
560
+ * `date_string(format = /\A\d\d\d\d-\d\d-\d\d\Z/, msg = [:date_string,
561
+ 'YYYY-MM-DD'])` - ensures that validated object is a string in a given
562
+ format and is parsable by `Date#parse`.
563
+
564
+ * `default_errors(validator)` - helper function binding validator to the
565
+ default implementation of the error collection object. Returned function is
566
+ not a composable validator so it should only be applied to the top level
567
+ validator right before applying it to the object. Example:
568
+ ```ruby
569
+ errors = {}
570
+ default_errors(validator).call(validated_object, errors)
571
+ ```
572
+
573
+ * `each_in_slice(range, validator)` - applies `validator` to each slice of the array. Example:
574
+ ```ruby
575
+ array(
576
+ each_in_slice(0..-2, normal_element_validator),
577
+ each_in_slice(-1..-1, special_last_element_validator))
578
+ ```
579
+
580
+ * `each(validator)` - applies `validator` to each element of the array.
581
+
582
+ * `equal(val, msg = [:equal, val])` - ensures that validated object is equal
583
+ `val`.
584
+
585
+ * `error(errors, msg, object, *segments)` - adds error message `msg` to the
586
+ error collection `errors` under path `segments`. Use it in your custom
587
+ validators.
588
+
589
+ * `exact_size(n, msg = [:exact_size, n])` - ensures that validated object has
590
+ size of exactly `n`. Can be applied only to objects responding to the method
591
+ `#size`.
592
+
593
+ * `fail_fast(*validators)` - executes `validators` in sequence until one
594
+ of the validators returns `false` or all of them were executed.
595
+
596
+ * `float(msg = :float)` - ensures that validated object is a number (parsable
597
+ as `Float` or `Fixnum`).
598
+
599
+ * `format(regex, msg = :format)` - ensures that validated string conforms to
600
+ the regular expression provided.
601
+
602
+ * `greater_or_equal(val, msg = [:greater_or_equal, val])` - ensures that
603
+ validated object is greater or equal than `val`.
604
+
605
+ * `greater(val, msg = [:greater, val])` - ensures that validated object is
606
+ greater than `val`.
607
+
608
+ * `guarded_parsing(format, msg, &blk)` - ensures that validated object is a
609
+ string of a given format and that it can be parsed by provided block (block
610
+ does not raise `ArgumentError` or `TypeError`).
611
+
612
+ * `inclusion(options, msg = [:inclusion, options])` - ensures that validated
613
+ object is one of the provided `options`.
614
+
615
+ * `in_range(range, msg = [:in_range, range])` - ensures that validated object is
616
+ in given `range`.
617
+
618
+ * `integer(msg = :integer)` - ensures that validated object is an integer
619
+ (parsable as `Fixnum`).
620
+
621
+ * `just_array(msg = :just_array)` - ensures that validated object is of type
622
+ `Array`.
623
+
624
+ * `just_hash(msg = :just_hash)` - ensures that validated object is of type
625
+ `Hash`.
626
+
627
+ * `key_equal_to_key(key1, key2, msg = [:key_equal_to_key, key1, key2])` -
628
+ ensures that validated hash has equal values under keys `key1` and `key2`. If
629
+ any of the values are nil validator returns true.
630
+
631
+ * `key_greater_or_equal_to_key(key1, key2, msg = [:key_greater_or_equal_to_key,
632
+ key1, key2])` - ensures that validated hash has values under keys `key1` and
633
+ `key2` in relation `h[key1] >= h[key2]`. If any of the values are nil
634
+ validator returns true.
635
+
636
+ * `key_greater_than_key(key1, key2, msg = [:key_greater_than_key, key1, key2])`-
637
+ ensures that validated hash has values under keys `key1` and `key2` in
638
+ relation `h[key1] > h[key2]`. If any of the values are nil validator
639
+ returns true.
640
+
641
+ * `key(key, *validators)` - ensures presence of the key in the validated hash
642
+ and applies validators to the value under the `key` using `run_all`
643
+ combinator.
644
+
645
+ * `key_less_or_equal_to_key(key1, key2, msg = [:key_less_or_equal_to_key, key1, key2])`-
646
+ ensures that validated hash has values under keys `key1` and `key2` in
647
+ relation `h[key1] <= h[key2]`. If any of the values are nil validator
648
+ returns true.
649
+
650
+ * `key_less_than_key(key1, key2, msg = [:key_less_than_key, key1, key2])`-
651
+ ensures that validated hash has values under keys `key1` and `key2` in
652
+ relation `h[key1] < h[key2]`. If any of the values are nil validator
653
+ returns true.
654
+
655
+ * `less_or_equal(val, msg = [:less_or_equal, val])` - ensures that
656
+ validated object is less or equal than `val`.
657
+
658
+ * `less(val, msg = [:less, val])` - ensures that validated object is less than
659
+ `val`.
660
+
661
+ * `max_size(n, msg = [:max_size, n])` - ensures that validated object has size
662
+ not greater than `n`. Can be applied only to objects responding to the method
663
+ `#size`.
664
+
665
+ * `min_size(n, msg = [:min_size, n])` - ensures that validated object has size
666
+ not less than `n`. Can be applied only to objects responding to the method
667
+ `#size`.
668
+
669
+ * `nil_or(*validators)` - helper function returning validator that returns true
670
+ if validated object is `nil` or applies all `validators` using `run_all`
671
+ combinator if validated object is not `nil`.
672
+
673
+ * `non_empty(msg = :non_empty)` - ensures that validated object is not empty.
674
+
675
+ * `non_empty_string(msg = :non_empty_string)` - ensures that validated object
676
+ is a non-empty string.
677
+
678
+ * `non_negative_float` - ensures that validated object is a non-negative number.
679
+
680
+ * `non_negative_integer` - ensures that validated object is a non-negative
681
+ integer.
682
+
683
+ * `non_negative(msg = :non_negative)` - ensures that validated object is not
684
+ negative.
685
+
686
+ * `non_negative_stringy_float` - ensures that validated object is a
687
+ non-negative number or string that can be parsed into non-negative number.
688
+ Example: both 0.1 and "0.1" are valid.
689
+
690
+ * `non_negative_stringy_integer` - ensures that validated object is a
691
+ non-negative integer or string that can be parsed into non-negative integer.
692
+ Example: both 1 and "1" are valid.
693
+
694
+ * `optional_key(key, *validators)` - applies `validators` to the value under
695
+ the `key` using `run_all` combinator. Returns `true` if `key` does not exist
696
+ in the validated hash.
697
+
698
+ * `precheck(*validators, &blk)` - helper function returning validator that
699
+ returns `true` if `&blk` returns `true` or applies all `validators` using
700
+ `run_all` combinator if `&blk` returns `false`. Example - validate that value
701
+ is a number but also allow value "infinity":
702
+ ```ruby
703
+ precheck(float) { |v| v == 'infinity' }
704
+ ```
705
+
706
+ * `presence_of_key(key, msg = :presence_of_key)` - ensures that validated hash
707
+ has `key`.
708
+
709
+ * `run_all(*validators)` - executes all `validators` in sequence collecting all
710
+ error messages.
711
+
712
+ * `size_range(range, msg = [:size_range, range])` - ensures that validated
713
+ object has size `n` in range `range`. Can be applied only to objects
714
+ responding to the method `#size`.
715
+
716
+ * `string(msg = :string)` - ensures that validated object is of class `String`.
717
+
718
+ * `stringy_float(msg = :stringy_float)` - ensures that validated object is a
719
+ number or string that can be parsed into number. Example: both 0.1 and "0.1"
720
+ are valid.
721
+
722
+ * `stringy_integer(msg = :stringy_integer)` - ensures that validated object is
723
+ an integer or string that can be parsed into integer. Example: both 1 and
724
+ "1" are valid.
725
+
726
+ * `time_string(format = //, msg = :time_string)` - ensures that validated
727
+ object is a string in a given format and is parsable by `Time#parse`.
728
+
729
+ * `validate(msg, key = nil, &blk)` - helper method returning validator that
730
+ returns `true` if `&blk` returns `true` and `false` otherwise. `msg` is an
731
+ error message added to the error container when validation returns `false`.
732
+ Example - ensure that validated object is equal "hello":
733
+ ```ruby
734
+ validate('must be "hello"') { |v| v == 'hello' }
735
+ ```
@@ -0,0 +1,32 @@
1
+ module ComposableValidations; end
2
+
3
+ module ComposableValidations::Combinators
4
+ def run_all(*validators)
5
+ lambda do |object, errors, prefix|
6
+ validators.inject(true) do |acc, validator|
7
+ r = validator.call(object, errors, prefix)
8
+ acc && r
9
+ end
10
+ end
11
+ end
12
+
13
+ def fail_fast(*validators)
14
+ lambda do |object, errors, prefix|
15
+ validators.each do |validator|
16
+ return false if !validator.call(object, errors, prefix)
17
+ end
18
+ true
19
+ end
20
+ end
21
+
22
+ def nil_or(*validators)
23
+ precheck(*validators, &:nil?)
24
+ end
25
+
26
+ def precheck(*validators, &blk)
27
+ lambda do |object, errors, prefix|
28
+ return true if yield(object)
29
+ run_all(*validators).call(object, errors, prefix)
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,65 @@
1
+ module ComposableValidations::Comparison
2
+ def key_to_key_comparison(key1, key2, msg, &compfun)
3
+ precheck(
4
+ validate(msg, key1) do |h|
5
+ v1 = h[key1]
6
+ v2 = h[key2]
7
+ compfun.call(v1, v2)
8
+ end
9
+ ) do |h|
10
+ h[key1].nil? || h[key2].nil?
11
+ end
12
+ end
13
+
14
+ def comparison(val, msg, &compfun)
15
+ validate(msg) do |v|
16
+ compfun.call(v, val)
17
+ end
18
+ end
19
+
20
+ def key_greater_or_equal_to_key(key1, key2, msg = [:key_greater_or_equal_to_key, key1, key2])
21
+ key_to_key_comparison(key1, key2, msg, &:>=)
22
+ end
23
+
24
+ def key_greater_than_key(key1, key2, msg = [:key_greater_than_key, key1, key2])
25
+ key_to_key_comparison(key1, key2, msg, &:>)
26
+ end
27
+
28
+ def key_less_or_equal_to_key(key1, key2, msg = [:key_less_or_equal_to_key, key1, key2])
29
+ key_to_key_comparison(key1, key2, msg, &:<=)
30
+ end
31
+
32
+ def key_less_than_key(key1, key2, msg = [:key_less_than_key, key1, key2])
33
+ key_to_key_comparison(key1, key2, msg, &:<)
34
+ end
35
+
36
+ def key_equal_to_key(key1, key2, msg = [:key_equal_to_key, key1, key2])
37
+ key_to_key_comparison(key1, key2, msg, &:==)
38
+ end
39
+
40
+ def greater_or_equal(val, msg = [:greater_or_equal, val])
41
+ comparison(val, msg, &:>=)
42
+ end
43
+
44
+ def greater(val, msg = [:greater, val])
45
+ comparison(val, msg, &:>)
46
+ end
47
+
48
+ def less_or_equal(val, msg = [:less_or_equal, val])
49
+ comparison(val, msg, &:<=)
50
+ end
51
+
52
+ def less(val, msg = [:less, val])
53
+ comparison(val, msg, &:<)
54
+ end
55
+
56
+ def equal(val, msg = [:equal, val])
57
+ comparison(val, msg, &:==)
58
+ end
59
+
60
+ def in_range(range, msg = [:in_range, range])
61
+ validate(msg) do |val|
62
+ range.include?(val)
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,40 @@
1
+ ComposableValidations::Errors::DEFAULT_MESSAGE_MAP = {
2
+ non_empty_string: "can't be blank",
3
+ non_empty: "can't be empty",
4
+ allowed_keys: "is not allowed",
5
+ presence_of_key: "can't be blank",
6
+ just_hash: "must be a hash",
7
+ just_array: "must be an array",
8
+ string: "must be a string",
9
+ integer: "must be an integer",
10
+ stringy_integer: "must be an integer",
11
+ float: "must be a number",
12
+ stringy_float: "must be a number",
13
+ non_negative: "must be greater than or equal to 0",
14
+ format: "is invalid",
15
+ time_string: "must be a time",
16
+ date_string: lambda { |object, path, format| "must be a date in format #{format}" },
17
+ at_least_one_of: lambda { |object, path, keys| "at least one of #{keys.join(', ')} is required" },
18
+ key_greater_or_equal_to_key: lambda { |object, path, key1, key2| "must be greater than or equal to #{key2}" },
19
+ key_greater_than_key: lambda { |object, path, key1, key2| "must be greater than #{key2}" },
20
+ key_less_or_equal_to_key: lambda { |object, path, key1, key2| "must be less than or equal to #{key2}" },
21
+ key_less_than_key: lambda { |object, path, key1, key2| "must be less than #{key2}" },
22
+ key_equal_to_key: lambda { |object, path, key1, key2| "must be equal to #{key2}" },
23
+ greater_or_equal: lambda { |object, path, val| "must be greater than or equal to #{val}" },
24
+ greater: lambda { |object, path, val| "must be greater than #{val}" },
25
+ less_or_equal: lambda { |object, path, val| "must be less than or equal to #{val}" },
26
+ less: lambda { |object, path, val| "must be less than #{val}" },
27
+ equal: lambda { |object, path, val| "must be equal to #{val}" },
28
+ min_size: lambda { |object, path, n| "is too short (minium size is #{n})" },
29
+ max_size: lambda { |object, path, n| "is too long (maximum size is #{n})" },
30
+ exact_size: lambda { |object, path, n| "is the wrong size (should be #{n})" },
31
+ size_range: lambda { |object, path, range| "is the wrong size (minimum is #{range.min} and maximum is #{range.max})" },
32
+ in_range: lambda { |object, path, range| "must be in range #{range.min}..#{range.max}" },
33
+ inclusion: lambda do |object, path, options|
34
+ if options.length > 10
35
+ "is not allowed"
36
+ else
37
+ "must be one of: #{options.join(', ')}"
38
+ end
39
+ end
40
+ }
@@ -0,0 +1,48 @@
1
+ class ComposableValidations::Errors
2
+ def initialize(errors, message_map = nil)
3
+ @errors = errors
4
+ @message_map = DEFAULT_MESSAGE_MAP.merge(message_map || {})
5
+ end
6
+
7
+ def add(msg, path, object)
8
+ merge(@errors, {join(path) => [render_message(msg, path, object)]})
9
+ end
10
+
11
+ def to_hash
12
+ @errors
13
+ end
14
+
15
+ private
16
+
17
+ def render_message(msg, path, object)
18
+ message = Array(msg)
19
+ message_builder = @message_map.fetch(message.first, msg)
20
+ if message_builder.is_a?(Proc)
21
+ message_builder.call(object, path, *msg[1..-1])
22
+ else
23
+ message_builder
24
+ end
25
+ end
26
+
27
+ def merge(e1, e2)
28
+ e2.keys.each do |k, v|
29
+ e1[k] = ((e1[k] || []) + (e2[k] || [])).uniq
30
+ end
31
+ end
32
+
33
+ def join(segments)
34
+ if segments.empty?
35
+ segments[0] = base_key
36
+ end
37
+
38
+ segments.compact.join(separator)
39
+ end
40
+
41
+ def base_key
42
+ 'base'
43
+ end
44
+
45
+ def separator
46
+ '/'
47
+ end
48
+ end
@@ -0,0 +1,29 @@
1
+ module ComposableValidations::Utils
2
+ def default_errors(validator)
3
+ lambda do |object, errors_hash|
4
+ errors = ComposableValidations::Errors.new(errors_hash)
5
+ validator.call(object, errors, nil)
6
+ end
7
+ end
8
+
9
+ def join(*segments)
10
+ segments.inject([]) do |acc, seg|
11
+ acc + Array(seg)
12
+ end
13
+ end
14
+
15
+ def validate(msg, key = nil, &blk)
16
+ lambda do |o, errors, prefix|
17
+ if yield(o)
18
+ true
19
+ else
20
+ error(errors, msg, o, prefix, key)
21
+ end
22
+ end
23
+ end
24
+
25
+ def error(errors, msg, object, *segments)
26
+ errors.add(msg, join(*segments), object)
27
+ false
28
+ end
29
+ end
@@ -0,0 +1,3 @@
1
+ module ComposableValidations
2
+ VERSION = '0.0.9'
3
+ end
@@ -0,0 +1,218 @@
1
+ require 'date'
2
+ require 'time'
3
+
4
+ require_relative 'composable_validations/version'
5
+ require_relative 'composable_validations/combinators'
6
+ require_relative 'composable_validations/utils'
7
+ require_relative 'composable_validations/comparison'
8
+ require_relative 'composable_validations/errors'
9
+ require_relative 'composable_validations/default_error_messages'
10
+
11
+ module ComposableValidations
12
+ include ComposableValidations::Combinators
13
+ include ComposableValidations::Utils
14
+ include ComposableValidations::Comparison
15
+
16
+ # it is named with prefix 'a_' to avoid conflict with built in hash method
17
+ def a_hash(*validators)
18
+ fail_fast(just_hash, run_all(*validators))
19
+ end
20
+
21
+ def array(*validators)
22
+ fail_fast(
23
+ just_array,
24
+ run_all(*validators))
25
+ end
26
+
27
+ def each(validator)
28
+ each_in_slice(0..-1, validator)
29
+ end
30
+
31
+ def each_in_slice(range, validator)
32
+ lambda do |a, errors, prefix|
33
+ slice = a.slice(range) || []
34
+ slice.inject([true, 0]) do |(acc, index), elem|
35
+ suffix = to_index(a, range.begin) + index
36
+ b = validator.call(elem, errors, join(prefix, suffix))
37
+ [acc && b, index + 1]
38
+ end.first
39
+ end
40
+ end
41
+
42
+ def to_index(array, range_index)
43
+ indexes = array.map.with_index { |_, i| i }
44
+ indexes[range_index]
45
+ end
46
+
47
+ def allowed_keys(*allowed_keys)
48
+ lambda do |h, errors, prefix|
49
+ h.keys.inject(true) do |acc, key|
50
+ if !allowed_keys.include?(key)
51
+ error(errors, :allowed_keys, h, prefix, key)
52
+ else
53
+ acc && true
54
+ end
55
+ end
56
+ end
57
+ end
58
+
59
+ def key(key, *validators)
60
+ fail_fast(
61
+ presence_of_key(key),
62
+ lambda do |h, errors, prefix|
63
+ run_all(*validators).call(h[key], errors, join(prefix, key))
64
+ end
65
+ )
66
+ end
67
+
68
+ def optional_key(key, *validators)
69
+ lambda do |h, errors, prefix|
70
+ if h.has_key?(key)
71
+ key(key, *validators).call(h, errors, prefix)
72
+ else
73
+ true
74
+ end
75
+ end
76
+ end
77
+
78
+ def just_hash(msg = :just_hash)
79
+ just_type(Hash, msg)
80
+ end
81
+
82
+ def just_array(msg = :just_array)
83
+ just_type(Array, msg)
84
+ end
85
+
86
+ def string(msg = :string)
87
+ just_type(String, msg)
88
+ end
89
+
90
+ def non_empty_string(msg = :non_empty_string)
91
+ fail_fast(
92
+ string,
93
+ validate(msg) { |s| s.strip != '' })
94
+ end
95
+
96
+ def integer(msg = :integer)
97
+ just_type(Fixnum, msg)
98
+ end
99
+
100
+ def stringy_integer(msg = :stringy_integer)
101
+ parsing(msg) { |v| Integer(v.to_s) }
102
+ end
103
+
104
+ def float(msg = :float)
105
+ just_types(msg, Float, Fixnum)
106
+ end
107
+
108
+ def stringy_float(msg = :stringy_float)
109
+ parsing(msg) { |v| Float(v.to_s) }
110
+ end
111
+
112
+ def non_negative_float
113
+ fail_fast(float, non_negative)
114
+ end
115
+
116
+ def non_negative_integer
117
+ fail_fast(integer, non_negative)
118
+ end
119
+
120
+ def non_negative_stringy_float
121
+ fail_fast(stringy_float, non_negative)
122
+ end
123
+
124
+ def non_negative_stringy_integer
125
+ fail_fast(stringy_integer, non_negative)
126
+ end
127
+
128
+ def non_negative(msg = :non_negative)
129
+ validate(msg) { |v| Float(v.to_s) >= 0 }
130
+ end
131
+
132
+ def date_string(format = /\A\d\d\d\d-\d\d-\d\d\Z/, msg = [:date_string, 'YYYY-MM-DD'])
133
+ guarded_parsing(format, msg) { |v| Date.parse(v) }
134
+ end
135
+
136
+ def time_string(format = //, msg = :time_string)
137
+ guarded_parsing(format, msg) { |v| Time.parse(v) }
138
+ end
139
+
140
+ def format(regex, msg = :format)
141
+ validate(msg) { |val| regex.match(val) }
142
+ end
143
+
144
+ def guarded_parsing(format, msg, &blk)
145
+ fail_fast(
146
+ string(msg),
147
+ format(format, msg),
148
+ parsing(msg, &blk))
149
+ end
150
+
151
+ def parsing(msg, &blk)
152
+ validate(msg) do |val|
153
+ begin
154
+ yield(val)
155
+ true
156
+ rescue ArgumentError, TypeError
157
+ false
158
+ end
159
+ end
160
+ end
161
+
162
+ def just_type(type, msg)
163
+ just_types(msg, type)
164
+ end
165
+
166
+ def just_types(msg, *types)
167
+ validate(msg) do |v|
168
+ types.inject(false) do |acc, type|
169
+ acc || v.is_a?(type)
170
+ end
171
+ end
172
+ end
173
+
174
+ def boolean
175
+ inclusion([true, false])
176
+ end
177
+
178
+ def inclusion(options, msg = [:inclusion, options])
179
+ validate(msg) { |v| options.include?(v) }
180
+ end
181
+
182
+ def presence_of_key(key, msg = :presence_of_key)
183
+ validate(:presence_of_key, key) { |h| h.has_key?(key) }
184
+ end
185
+
186
+ def non_empty(msg = :non_empty)
187
+ validate(msg) { |v| !v.empty? }
188
+ end
189
+
190
+ def at_least_one_of(*keys)
191
+ validate([:at_least_one_of, keys]) do |h|
192
+ count = h.keys.count { |k| keys.include?(k) }
193
+ count > 0
194
+ end
195
+ end
196
+
197
+ def size(validator)
198
+ lambda do |object, errors, path|
199
+ validator.call(object.size, errors, path)
200
+ end
201
+ end
202
+
203
+ def min_size(n, msg = [:min_size, n])
204
+ size(greater_or_equal(n, msg))
205
+ end
206
+
207
+ def max_size(n, msg = [:max_size, n])
208
+ size(less_or_equal(n, msg))
209
+ end
210
+
211
+ def exact_size(n, msg = [:exact_size, n])
212
+ size(equal(n, msg))
213
+ end
214
+
215
+ def size_range(range, msg = [:size_range, range])
216
+ size(in_range(range, msg))
217
+ end
218
+ end
metadata ADDED
@@ -0,0 +1,67 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: composable_validations
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.9
5
+ platform: ruby
6
+ authors:
7
+ - Kajetan Bojko
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2016-02-09 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rspec
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ~>
18
+ - !ruby/object:Gem::Version
19
+ version: '3'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ~>
25
+ - !ruby/object:Gem::Version
26
+ version: '3'
27
+ description: Gem for validating complex JSON payloads in a functional way
28
+ email: kai@shutl.com
29
+ executables: []
30
+ extensions: []
31
+ extra_rdoc_files: []
32
+ files:
33
+ - LICENSE
34
+ - README.md
35
+ - lib/composable_validations.rb
36
+ - lib/composable_validations/combinators.rb
37
+ - lib/composable_validations/comparison.rb
38
+ - lib/composable_validations/default_error_messages.rb
39
+ - lib/composable_validations/errors.rb
40
+ - lib/composable_validations/utils.rb
41
+ - lib/composable_validations/version.rb
42
+ homepage: https://github.com/shutl/composable_validations
43
+ licenses:
44
+ - MIT
45
+ metadata: {}
46
+ post_install_message:
47
+ rdoc_options: []
48
+ require_paths:
49
+ - .
50
+ - lib
51
+ required_ruby_version: !ruby/object:Gem::Requirement
52
+ requirements:
53
+ - - '>='
54
+ - !ruby/object:Gem::Version
55
+ version: '2'
56
+ required_rubygems_version: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - '>='
59
+ - !ruby/object:Gem::Version
60
+ version: '0'
61
+ requirements: []
62
+ rubyforge_project:
63
+ rubygems_version: 2.4.6
64
+ signing_key:
65
+ specification_version: 4
66
+ summary: Gem for validating complex JSON payloads
67
+ test_files: []