validate-rb 0.1.0.alpha.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: ec9996734d51898a4f003a56c2cb45748c14d58b138ddb03d1291d9cf6a4ed28
4
+ data.tar.gz: 144941aa76f0db9224309d7b3614bc41e5e5fd59b37b7419f4785dd77498abf6
5
+ SHA512:
6
+ metadata.gz: 3145f4f69e4eb2969d4eafc9c4f92faa4c3a63c2b15061bef8abea109fab30648ace5104c8e8c81314c7b63a1ee0452899d0074cb1ee8fef15ec000443a95f87
7
+ data.tar.gz: 7c2f705b8a0da5e05652491060462a783554429a5ea32ecc08e25364ec675e31aac769f23a4f6ec9fbca07e5317cfa9224e99a06820c051b60a8e761890beaea
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2020 Gusto
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 all
13
+ 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 THE
21
+ SOFTWARE.
@@ -0,0 +1,124 @@
1
+ # Validate.rb ![CI](https://github.com/Gusto/validate-rb/workflows/CI/badge.svg)
2
+
3
+ Yummy constraint validations for Ruby
4
+
5
+ ## Installation
6
+
7
+ Add this line to your application's Gemfile:
8
+
9
+ ```ruby
10
+ gem 'validate-rb'
11
+ ```
12
+
13
+ And then execute:
14
+
15
+ $ bundle install
16
+
17
+ Or install it yourself as:
18
+
19
+ $ gem install validate-rb
20
+
21
+ ## Usage
22
+
23
+ ### Defining a validator
24
+
25
+ Validators are a collection of constraints
26
+
27
+ ```ruby
28
+ require 'validate'
29
+
30
+ # validators can be named
31
+ Validate::Validators.define(:create_user_request) do
32
+ # attr is a type of constraint that defines constraints on an attribute
33
+ attr(:username) { not_blank }
34
+ attr(:address) do
35
+ not_nil
36
+ # 'valid' constraint continues validation further down the object graph
37
+ valid
38
+ end
39
+ end
40
+
41
+ # validators can also be defined for a specific class
42
+ Validate::Validators.define(Address) do
43
+ attr(:street) do
44
+ not_blank
45
+ is_a(String)
46
+ attr(:length) { max(255) }
47
+ end
48
+
49
+ attr(:zip) do
50
+ # constraints have default error messages, which can be changed
51
+ match(/[0-9]{5}\-[0-9]{4}/, message: '%{value.inspect} must be a zip')
52
+ end
53
+ end
54
+
55
+ address = Address.new(street: '123 Any Road', zip: '11223-3445')
56
+ request = CreateUser.new(username: 'janedoe',
57
+ address: address)
58
+
59
+ violations = Validate.validate(request, as: :create_user_request)
60
+
61
+ violations.group_by(&:path).each do |path, violations|
62
+ puts "#{path} is invalid:"
63
+ violations.each do |v|
64
+ puts " #{v.message}"
65
+ end
66
+ end
67
+ ```
68
+
69
+ ### Creating constraints
70
+
71
+ Constraints have properties and can be evaluated
72
+
73
+ ```ruby
74
+ # constraints must have a name
75
+ Validate::Constraints.define(:not_blank) do
76
+ # evaluation can 'fail' or 'pass'
77
+ evaluate { |value| fail if value.nil? || value.empty? }
78
+ end
79
+
80
+ # 'attr' is just another constraint, like 'not_blank'
81
+ Validate::Constraints.define(:attr) do
82
+ # constraints can have options
83
+ # every constraint at least has a :message option
84
+ # constraint options can have validation constraints
85
+ option(:name) { not_blank & is_a(Symbol) }
86
+ option(:validator) { is_a(Validate::Validators::Validator) }
87
+
88
+ # by default, constraints expect **kwargs for options
89
+ # initializer can be defined to translates from arbitrary args to options map
90
+ initialize do |name, &validation_block|
91
+ {
92
+ name: name,
93
+ # validators can be created anonymously
94
+ validator: Validate::Validators.create(&validation_block)
95
+ }
96
+ end
97
+
98
+ evaluate do |value, ctx|
99
+ # pass constraints on non-values to support optional validation
100
+ pass if value.nil?
101
+
102
+ # fetch an option
103
+ name = options[:name]
104
+ fail unless value.respond_to?(name)
105
+
106
+ # validation context can be used to traverse object graph
107
+ # `ValidationContext#attr(attribute)` creates a `ValidationContext` for object's `attribute`
108
+ # there is also `ValidationContext#key` to validate keys in a hash, useful for ENV validation
109
+ attr_ctx = ctx.attr(name)
110
+ options[:validator]&.validate(attr_ctx)
111
+ end
112
+ end
113
+ ```
114
+
115
+ ## Development
116
+
117
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
118
+
119
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
120
+
121
+ ## Contributing
122
+
123
+ Bug reports and pull requests are welcome on GitHub at https://github.com/gusto/validate-rb.
124
+
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'forwardable'
4
+ require 'monitor'
5
+
6
+ require_relative 'validate/version'
7
+ require_relative 'validate/errors'
8
+ require_relative 'validate/constraint'
9
+ require_relative 'validate/assertions'
10
+ require_relative 'validate/arguments'
11
+ require_relative 'validate/ast'
12
+ require_relative 'validate/scope'
13
+ require_relative 'validate/helpers'
14
+ require_relative 'validate/constraints/validation_context'
15
+ require_relative 'validate/constraints'
16
+ require_relative 'validate/validators/dsl'
17
+ require_relative 'validate/validators'
18
+ require_relative 'validate/compare'
19
+
20
+ # Validate.rb can be used independently by calling {Validate.validate}
21
+ # or included in classes and modules.
22
+ #
23
+ # @example Validating an object using externally defined metadata
24
+ # Address = Struct.new(:street, :city, :state, :zip)
25
+ # Validate::Validators.define(Address) do
26
+ # attr(:street) { not_blank }
27
+ # attr(:city) { not_blank }
28
+ # attr(:state) { not_blank & length(2) }
29
+ # attr(:zip) { not_blank & match(/[0-9]{5}(\-[0-9]{4})?/) }
30
+ # end
31
+ # puts Validate.validate(Address.new)
32
+ #
33
+ # @example Validating an object using metdata defined in class
34
+ # class Address < Struct.new(:street, :city, :state, :zip)
35
+ # include Validate
36
+ # validate do
37
+ # attr(:street) { not_blank }
38
+ # attr(:city) { not_blank }
39
+ # attr(:state) { not_blank & length(2) }
40
+ # attr(:zip) { not_blank & match(/[0-9]{5}(\-[0-9]{4})?/) }
41
+ # end
42
+ # end
43
+ # puts Validate.validate(Address.new)
44
+ module Validate
45
+ # Validate an object and get constraint violations list back
46
+ #
47
+ # @param object [Object] object to validate
48
+ # @param as [Symbol, Class] (object.class) validator to use, defaults to
49
+ # object's class
50
+ #
51
+ # @return [Array<Constraint::Violation>] list of constraint violations
52
+ def self.validate(object, as: object.class)
53
+ violations = []
54
+ Scope.current
55
+ .validator(as)
56
+ .validate(Constraints::ValidationContext.root(object, violations))
57
+ violations.freeze
58
+ end
59
+
60
+ # Check if a given validator exists
61
+ #
62
+ # @param name [Symbol, Class] validator to check
63
+ #
64
+ # @return [Boolean] `true` if validator is present, `else` otherwise
65
+ def self.validator?(name)
66
+ Scope.current.validator?(name)
67
+ end
68
+
69
+ # Hook to allow for inclusion in class or module
70
+ def self.included(base)
71
+ base.extend(ClassMethods)
72
+ end
73
+
74
+ # @private
75
+ module ClassMethods
76
+ def validator(&body)
77
+ @validator ||= Validators.create(&body)
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,93 @@
1
+ module Validate
2
+ module Arguments
3
+ module ClassMethods
4
+ def method_added(method_name)
5
+ super
6
+ return if @args.empty?
7
+
8
+ method = instance_method(method_name)
9
+ guard = ArgumentsGuard.new(method, @args.dup)
10
+
11
+ @methods_guard.__send__(:define_method, method_name) do |*args, &block|
12
+ guard.send(method_name, *args, &block)
13
+ super(*args, &block)
14
+ end
15
+ ensure
16
+ @args.clear
17
+ end
18
+
19
+ def singleton_method_added(method_name)
20
+ super
21
+ return if @args.empty?
22
+
23
+ method = singleton_method(method_name)
24
+ guard = ArgumentsGuard.new(method, @args.dup)
25
+
26
+ @methods_guard.__send__(:define_singleton_method, method_name) do |*args, &block|
27
+ guard.send(method_name, *args, &block)
28
+ super(*args, &block)
29
+ end
30
+ ensure
31
+ @args.clear
32
+ end
33
+
34
+ def arg(name, &body)
35
+ if @args.include?(name)
36
+ raise Error::ArgumentError, "duplicate argument :#{name}"
37
+ end
38
+
39
+ @args[name] = Assertions.create(&body)
40
+ self
41
+ end
42
+ end
43
+
44
+ def self.included(base)
45
+ base.extend(ClassMethods)
46
+ base.instance_exec do
47
+ @args = {}
48
+ prepend(@methods_guard = Module.new)
49
+ end
50
+ end
51
+
52
+ class ArgumentsGuard
53
+ DEFAULT_VALUE = BasicObject.new
54
+
55
+ def initialize(method, rules)
56
+ signature = []
57
+ assertions = []
58
+
59
+ method.parameters.each do |(kind, name)|
60
+ case kind
61
+ when :req
62
+ signature << name.to_s
63
+ when :opt
64
+ signature << "#{name} = DEFAULT_VALUE"
65
+ when :rest
66
+ signature << "*#{name}"
67
+ when :keyreq
68
+ signature << "#{name}:"
69
+ when :key
70
+ signature << "#{name}: DEFAULT_VALUE"
71
+ when :keyrest
72
+ signature << "**#{name}"
73
+ when :block
74
+ signature << "&#{name}"
75
+ else
76
+ raise Error::ArgumentError, "unsupported parameter type #{kind}"
77
+ end
78
+ next unless rules.include?(name)
79
+
80
+ assertions << "@rules[:#{name}].assert(#{name}, message: 'invalid argument #{name}')"
81
+ end
82
+
83
+ singleton_class.class_eval(<<~RUBY, __FILE__, __LINE__ + 1)
84
+ def #{method.name}(#{signature.join(', ')})
85
+ #{assertions.join("\n ")}
86
+ end
87
+ RUBY
88
+
89
+ @rules = rules
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,27 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Validate
5
+ module Assertions
6
+ module_function
7
+
8
+ def create(*args, &block)
9
+ Assertion.new(AST::DefinitionContext.create(*args, &block))
10
+ end
11
+
12
+ class Assertion
13
+ def initialize(validation_context)
14
+ @constraints = validation_context
15
+ end
16
+
17
+ def assert(value, error_class: Error::ArgumentError, message: 'invalid value')
18
+ ctx = Constraints::ValidationContext.root(value)
19
+ @constraints.evaluate(ctx)
20
+ return value unless ctx.has_violations?
21
+
22
+ raise error_class, message,
23
+ cause: ctx.to_err
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,381 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Validate
5
+ module AST
6
+ def self.build(*args, &block)
7
+ Generator.new
8
+ .generate(*args, &block)
9
+ .freeze
10
+ end
11
+
12
+ class DefinitionContext
13
+ module Builder
14
+ module_function
15
+
16
+ def all_constraints(*constraints)
17
+ Rules::Unanimous.new(constraints.map { |node| send(*node) })
18
+ end
19
+
20
+ def at_least_one_constraint(*constraints)
21
+ Rules::Affirmative.new(constraints.map { |node| send(*node) })
22
+ end
23
+
24
+ def no_constraints(*constraints)
25
+ Rules::Negative.new(constraints.map { |node| send(*node) })
26
+ end
27
+
28
+ def constraint(name, args, block, trace)
29
+ if defined?(Constraints) && Constraints.respond_to?(name)
30
+ begin
31
+ return Constraints.send(name, *(args.map { |node| send(*node) }), &block)
32
+ rescue => e
33
+ ::Kernel.raise Error::ValidationRuleError, e.message, trace
34
+ end
35
+ end
36
+
37
+ Rules::Pending.new(name, args.map { |node| send(*node) }, block, trace)
38
+ end
39
+
40
+ def value(value)
41
+ value
42
+ end
43
+ end
44
+
45
+ def self.create(*args, &block)
46
+ ast = AST.build(*args, &block)
47
+ context = new
48
+ ast.each { |node| context.add_constraint(Builder.send(*node)) }
49
+ context
50
+ end
51
+
52
+ def initialize
53
+ @constraints = {}
54
+ end
55
+
56
+ def add_constraint(constraint)
57
+ if @constraints.include?(constraint.name)
58
+ raise Error::ValidationRuleError,
59
+ "duplicate constraint #{constraint.name}"
60
+ end
61
+
62
+ @constraints[constraint.name] = constraint
63
+ self
64
+ end
65
+
66
+ def evaluate(ctx)
67
+ @constraints.each_value
68
+ .reject { |c| catch(:pending) { c.valid?(ctx.value, ctx) } }
69
+ .each { |c| ctx.add_violation(c) }
70
+ ctx
71
+ end
72
+ end
73
+
74
+ CORE_CONSTRAINTS = %i[
75
+ not_nil not_blank not_empty
76
+ is_a one_of validate
77
+ min max equal match
78
+ valid each_value unique
79
+ length
80
+ ].freeze
81
+
82
+ class Generator < ::BasicObject
83
+ def initialize
84
+ @stack = []
85
+ end
86
+
87
+ def generate(*args, &block)
88
+ instance_exec(*args, &block)
89
+
90
+ if @stack.one? && @stack.first[0] == :all_constraints
91
+ return @stack.first[1..-1]
92
+ end
93
+
94
+ @stack
95
+ end
96
+
97
+ def &(other)
98
+ unless other == self
99
+ ::Kernel.raise(
100
+ Error::ValidationRuleError,
101
+ 'bad rule, only constraints and &, |, and ! operators allowed'
102
+ )
103
+ end
104
+
105
+ right = @stack.pop
106
+ left = @stack.pop
107
+ if right[0] == :all_constraints
108
+ right.insert(1, left)
109
+ @stack << right
110
+ else
111
+ @stack << [:all_constraints, left, right]
112
+ end
113
+ self
114
+ end
115
+
116
+ def |(other)
117
+ unless other == self
118
+ ::Kernel.raise(
119
+ Error::ValidationRuleError,
120
+ 'bad rule, only constraints and &, |, and ! operators allowed'
121
+ )
122
+ end
123
+
124
+ right = @stack.pop
125
+ left = @stack.pop
126
+ if right[0] == :at_least_one_constraint
127
+ right.insert(1, left)
128
+ @stack << right
129
+ else
130
+ @stack << [:at_least_one_constraint, left, right]
131
+ end
132
+ self
133
+ end
134
+
135
+ def !
136
+ prev = @stack.pop
137
+ if prev[0] == :no_constraints
138
+ @stack << prev[1]
139
+ elsif prev[0] == :all_constraints
140
+ prev[0] = :no_constraints
141
+ @stack << prev
142
+ else
143
+ @stack << [:no_constraints, prev]
144
+ end
145
+ self
146
+ end
147
+
148
+ private
149
+
150
+ def method_missing(method, *args, &block)
151
+ return super unless respond_to_missing?(method)
152
+
153
+ @stack << [
154
+ :constraint,
155
+ method,
156
+ args.map { |arg| [:value, arg] },
157
+ block,
158
+ ::Kernel.caller
159
+ .reject { |line| line.include?(__FILE__) }
160
+ ]
161
+ self
162
+ end
163
+
164
+ def respond_to_missing?(method, _ = false)
165
+ (defined?(Constraints) && Constraints.respond_to?(method)) || CORE_CONSTRAINTS.include?(method)
166
+ end
167
+ end
168
+
169
+ module Combinator
170
+ extend Forwardable
171
+ def_delegators :@constraints, :[]
172
+
173
+ def respond_to_missing?(_, _ = false)
174
+ false
175
+ end
176
+
177
+ private
178
+
179
+ def constraint_message(index)
180
+ @constraints[index].message % Hash.new do |_, key|
181
+ if key.to_s.start_with?('constraint')
182
+ "%{#{key.to_s.gsub('constraint', "constraint[#{index}]")}}"
183
+ else
184
+ "%{#{key}}"
185
+ end
186
+ end
187
+ end
188
+ end
189
+
190
+ module Rules
191
+ class Pending < Constraint
192
+ include MonitorMixin
193
+
194
+ def initialize(name, args, block, caller)
195
+ @name = name
196
+ @args = args
197
+ @block = block
198
+ @caller = caller
199
+ @constraint = nil
200
+
201
+ extend SingleForwardable
202
+ mon_initialize
203
+ end
204
+
205
+ def name
206
+ load_constraint { return @name }.name
207
+ end
208
+
209
+ def valid?(value, ctx = Constraints::ValidationContext.none)
210
+ load_constraint { throw(:pending, true) }.valid?(value, ctx)
211
+ end
212
+
213
+ def to_s
214
+ load_constraint { return "[pending #{@name}]" }.to_s
215
+ end
216
+
217
+ def inspect
218
+ load_constraint { return "[pending #{@name}]" }.inspect
219
+ end
220
+
221
+ def ==(other)
222
+ load_constraint { return false } == other
223
+ end
224
+
225
+ def method_missing(method, *args)
226
+ load_constraint { return NameError }.__send__(method, *args)
227
+ end
228
+
229
+ def respond_to_missing?(method, pvt = false)
230
+ load_constraint { return false }.__send__(:respond_to_missing?, method, pvt)
231
+ end
232
+
233
+ private
234
+
235
+ def load_constraint
236
+ yield unless defined?(Constraints) && Constraints.respond_to?(@name)
237
+
238
+ synchronize do
239
+ return @constraint if @constraint
240
+
241
+ begin
242
+ @constraint = Constraints.send(@name, *@args, &@block)
243
+ rescue => e
244
+ ::Kernel.raise Error::ValidationRuleError, e.message, @caller
245
+ end
246
+
247
+ def_delegators(:@constraint, :name, :valid?, :to_s,
248
+ :inspect, :==, :message)
249
+
250
+ @name = @args = @block = @caller = nil
251
+ @constraint
252
+ end
253
+ end
254
+ end
255
+
256
+ class Unanimous < Constraint
257
+ include Combinator
258
+ include Arguments
259
+
260
+ arg(:constraints) do
261
+ not_nil
262
+ length(min: 2)
263
+ each_value { is_a(Constraint) }
264
+ unique(:name)
265
+ end
266
+ def initialize(constraints)
267
+ @constraints = constraints.freeze
268
+ end
269
+
270
+ def valid?(value, _ = Constraints::ValidationContext.none)
271
+ ctx = Constraints::ValidationContext.root(value)
272
+ @constraints.all? do |c|
273
+ c.valid?(value, ctx) && !ctx.has_violations?
274
+ end
275
+ end
276
+
277
+ def name
278
+ 'both_' + @constraints.map(&:name).sort.join('_and_')
279
+ end
280
+
281
+ def inspect
282
+ return @constraints.first.inspect if @constraints.one?
283
+
284
+ "(#{@constraints.map(&:inspect).join(' & ')})"
285
+ end
286
+
287
+ def message
288
+ 'both ' + @constraints
289
+ .size
290
+ .times
291
+ .map { |i| "[#{constraint_message(i)}]" }
292
+ .join(', and ')
293
+ end
294
+ end
295
+
296
+ class Affirmative < Constraint
297
+ include Combinator
298
+ include Arguments
299
+
300
+ arg(:constraints) do
301
+ not_nil
302
+ length(min: 2)
303
+ each_value { is_a(Constraint) }
304
+ unique(:name)
305
+ end
306
+ def initialize(constraints)
307
+ @constraints = constraints.freeze
308
+ end
309
+
310
+ def valid?(value, _ = Constraints::ValidationContext.none)
311
+ ctx = Constraints::ValidationContext.root(value)
312
+ @constraints.any? do |c|
313
+ ctx.clear_violations
314
+ c.valid?(value, ctx) && !ctx.has_violations?
315
+ end
316
+ end
317
+
318
+ def name
319
+ 'either_' + @constraints.map(&:name).sort.join('_or_')
320
+ end
321
+
322
+ def inspect
323
+ return @constraints.first.inspect if @constraints.one?
324
+
325
+ "(#{@constraints.map(&:inspect).join(' | ')})"
326
+ end
327
+
328
+ def message
329
+ 'either ' + @constraints
330
+ .size
331
+ .times
332
+ .map { |i| "[#{constraint_message(i)}]" }
333
+ .join(', or ')
334
+ end
335
+ end
336
+
337
+ class Negative < Constraint
338
+ include Combinator
339
+ include Arguments
340
+
341
+ arg(:constraints) do
342
+ not_nil
343
+ not_empty
344
+ each_value { is_a(Constraint) }
345
+ unique(:name)
346
+ end
347
+ def initialize(constraints)
348
+ @constraints = constraints.freeze
349
+ end
350
+
351
+ def valid?(value, _ = Constraints::ValidationContext.none)
352
+ ctx = Constraints::ValidationContext.root(value)
353
+ @constraints.none? do |c|
354
+ ctx.clear_violations
355
+ c.valid?(value, ctx) && !ctx.has_violations?
356
+ end
357
+ end
358
+
359
+ def name
360
+ 'neither_' + @constraints.map(&:name).sort.join('_nor_')
361
+ end
362
+
363
+ def message
364
+ return "not [#{constraint_message(0)}]" if @constraints.one?
365
+
366
+ 'neither ' + @constraints
367
+ .size
368
+ .times
369
+ .map { |i| "[#{constraint_message(i)}]" }
370
+ .join(', nor ')
371
+ end
372
+
373
+ def inspect
374
+ return "!#{@constraints.first.inspect}" if @constraints.one?
375
+
376
+ "!(#{@constraints.map(&:inspect).join(' & ')})"
377
+ end
378
+ end
379
+ end
380
+ end
381
+ end