composable_validations 0.0.9

Sign up to get free protection for your applications and to get access to all the features.
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: []