fend 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ require File.expand_path("../lib/fend/version", __FILE__)
2
+
3
+ Gem::Specification.new do |gem|
4
+ gem.name = "fend"
5
+ gem.version = Fend.version
6
+ gem.authors = ["Aleksandar Radunovic"]
7
+ gem.email = ["aleksandar@radunovic.io"]
8
+
9
+ gem.summary = "Small and extensible data validation toolkit"
10
+ gem.description = gem.summary
11
+ gem.homepage = "https://fend.radunovic.io"
12
+ gem.license = "MIT"
13
+
14
+ gem.files = Dir["README.md", "LICENSE.txt", "lib/**/*.rb", "fend.gemspec"]
15
+ gem.require_path = "lib"
16
+
17
+ gem.required_ruby_version = ">= 2.0"
18
+
19
+ gem.add_development_dependency "rake"
20
+ gem.add_development_dependency "rspec"
21
+ end
@@ -0,0 +1,296 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Fend
4
+ # Generic error class
5
+ class Error < StandardError; end
6
+
7
+ # Core class that represents validation param. Class methods are added
8
+ # by Fend::Plugins::Core::ParamClassMethods module.
9
+ # Instance methods are added by Fend::Plugins::Core::ParamMethods module.
10
+ class Param
11
+ @fend_class = ::Fend
12
+ end
13
+
14
+ # Core class that represents validation result.
15
+ # Class methods are added by Fend::Plugins::Core::ResultClassMethods.
16
+ # Instance methods are added by Fend::Plugins::Core::ResultMethods.
17
+ class Result
18
+ @fend_class = ::Fend
19
+ end
20
+
21
+ @opts = {}
22
+ @validation_block = nil
23
+
24
+ # Module in which all Fend plugins should be defined.
25
+ module Plugins
26
+ @plugins = {}
27
+
28
+ # Use plugin if already loaded. If not, load and return it.
29
+ def self.load_plugin(name)
30
+ unless plugin = @plugins[name]
31
+ require "fend/plugins/#{name}"
32
+
33
+ raise Error, "plugin #{name} did not register itself correctly in Fend::Plugins" unless plugin = @plugins[name]
34
+ end
35
+ plugin
36
+ end
37
+
38
+ # Register plugin so that it can loaded.
39
+ def self.register_plugin(name, mod)
40
+ @plugins[name] = mod
41
+ end
42
+
43
+ # Core plugin. Provides core functionality.
44
+ module Core
45
+ module ClassMethods
46
+ attr_reader :opts
47
+
48
+ attr_reader :validation_block
49
+
50
+ def inherited(subclass)
51
+ subclass.instance_variable_set(:@opts, opts.dup)
52
+ subclass.opts.each do |key, value|
53
+ if (value.is_a?(Array) || value.is_a?(Hash)) && !value.frozen?
54
+ subclass.opts[key] = value.dup
55
+ end
56
+ end
57
+
58
+ param_class = Class.new(self::Param)
59
+ param_class.fend_class = subclass
60
+ subclass.const_set(:Param, param_class)
61
+
62
+ result_class = Class.new(self::Result)
63
+ result_class.fend_class = subclass
64
+ subclass.const_set(:Result, result_class)
65
+ end
66
+
67
+ def plugin(plugin, *args, &block)
68
+ plugin = Plugins.load_plugin(plugin) if plugin.is_a?(Symbol)
69
+ plugin.load_dependencies(self, *args, &block) if plugin.respond_to?(:load_dependencies)
70
+
71
+ include(plugin::InstanceMethods) if defined?(plugin::InstanceMethods)
72
+ extend(plugin::ClassMethods) if defined?(plugin::ClassMethods)
73
+
74
+ self::Param.send(:include, plugin::ParamMethods) if defined?(plugin::ParamMethods)
75
+ self::Param.extend(plugin::ParamClassMethods) if defined?(plugin::ParamClassMethods)
76
+
77
+ self::Result.send(:include, plugin::ResultMethods) if defined?(plugin::ResultMethods)
78
+ self::Result.extend(plugin::ResultClassMethods) if defined?(plugin::ResultClassMethods)
79
+
80
+ plugin.configure(self, *args, &block) if plugin.respond_to?(:configure)
81
+
82
+ plugin
83
+ end
84
+
85
+ # Store validation block for later execution:
86
+ #
87
+ # validate do |i|
88
+ # i.param(:foo) do |foo|
89
+ # # foo validation logic
90
+ # end
91
+ # end
92
+ def validate(&block)
93
+ @validation_block = block
94
+ end
95
+
96
+ def call(input)
97
+ new.call(input)
98
+ end
99
+ end
100
+
101
+ module InstanceMethods
102
+ # Trigger data validation and return Result
103
+ def call(raw_data)
104
+ set_data(raw_data)
105
+ validate(&validation_block)
106
+
107
+ result(input: @_input_data, output: @_output_data, errors: @_input_param.errors)
108
+ end
109
+
110
+ # Set:
111
+ # * raw input data
112
+ # * validation input data
113
+ # * result output data
114
+ # * input param
115
+ def set_data(raw_data)
116
+ @_raw_data = raw_data
117
+ @_input_data = process_input(raw_data) || raw_data
118
+ @_output_data = process_output(@_input_data) || @_input_data
119
+ @_input_param = param_class.new(@_input_data)
120
+ end
121
+
122
+ # Returns validation block set on class level
123
+ def validation_block
124
+ self.class.validation_block
125
+ end
126
+
127
+ # Get validation param class
128
+ def param_class
129
+ self.class::Param
130
+ end
131
+
132
+ # Get validation result class
133
+ def result_class
134
+ self.class::Result
135
+ end
136
+
137
+ # Process input data
138
+ def process_input(input); end
139
+
140
+ # Process output data
141
+ def process_output(output); end
142
+
143
+ # Start validation
144
+ def validate(&block)
145
+ yield(@_input_param) if block_given?
146
+ end
147
+
148
+ # Instantiate and return result
149
+ def result(args)
150
+ result_class.new(args)
151
+ end
152
+ end
153
+
154
+ module ParamClassMethods
155
+ # References Fend class under which the param class is namespaced
156
+ attr_accessor :fend_class
157
+ end
158
+
159
+ module ParamMethods
160
+ # Get param value
161
+ attr_reader :value
162
+
163
+ # Get param validation errors
164
+ attr_reader :errors
165
+
166
+ def initialize(value)
167
+ @value = value
168
+ @errors = []
169
+ end
170
+
171
+ # Fetch nested value
172
+ def [](name)
173
+ @value.fetch(name, nil) if @value.respond_to?(:fetch)
174
+ end
175
+
176
+ # Define child param and execute validation block
177
+ def param(name, &block)
178
+ return if flat? && invalid?
179
+
180
+ value = self[name]
181
+ param = _build_param(value)
182
+
183
+ yield(param)
184
+
185
+ _nest_errors(name, param.errors) if param.invalid?
186
+ end
187
+
188
+ # Define array member param and execute validation block
189
+ def each(&block)
190
+ return if (flat? && invalid?) || !@value.is_a?(Array)
191
+
192
+ @value.each_with_index do |value, index|
193
+ param = _build_param(value)
194
+
195
+ yield(param, index)
196
+
197
+ _nest_errors(index, param.errors) if param.invalid?
198
+ end
199
+ end
200
+
201
+ # Returns true if param is valid (no errors)
202
+ def valid?
203
+ errors.empty?
204
+ end
205
+
206
+ # Returns true if param is invalid/errors are present
207
+ def invalid?
208
+ !valid?
209
+ end
210
+
211
+ # Append param error message
212
+ def add_error(message)
213
+ @errors << message
214
+ end
215
+
216
+ def inspect
217
+ "#{fend_class.inspect}::Param #{super}"
218
+ end
219
+
220
+ def to_s
221
+ "#{fend_class.inspect}::Param"
222
+ end
223
+
224
+ # Return Fend class under which Param class is namespaced
225
+ def fend_class
226
+ self.class::fend_class
227
+ end
228
+
229
+ private
230
+
231
+ def flat?
232
+ errors.is_a?(Array)
233
+ end
234
+
235
+ def _nest_errors(name, messages)
236
+ @errors = {} unless @errors.is_a?(Hash)
237
+ @errors[name] = messages
238
+ end
239
+
240
+ def _build_param(*args)
241
+ self.class.new(*args)
242
+ end
243
+ end
244
+
245
+ module ResultClassMethods
246
+ attr_accessor :fend_class
247
+ end
248
+
249
+ module ResultMethods
250
+ # Get raw input data
251
+ attr_reader :input
252
+
253
+ # Get output data
254
+ attr_reader :output
255
+
256
+ def initialize(args = {})
257
+ @input = args.fetch(:input)
258
+ @output = args.fetch(:output)
259
+ @errors = args.fetch(:errors)
260
+ end
261
+
262
+ # Get error messages
263
+ def messages
264
+ return {} if success?
265
+
266
+ @errors
267
+ end
268
+
269
+ # Check if if validation failed
270
+ def failure?
271
+ !success?
272
+ end
273
+
274
+ # Check if if validation succeeded
275
+ def success?
276
+ @errors.empty?
277
+ end
278
+
279
+ def fend_class
280
+ self.class.fend_class
281
+ end
282
+
283
+ def inspect
284
+ "#{fend_class.inspect}::Result"
285
+ end
286
+
287
+ def to_s
288
+ "#{fend_class.inspect}::Result"
289
+ end
290
+ end
291
+ end
292
+ end
293
+
294
+ extend Fend::Plugins::Core::ClassMethods
295
+ plugin Fend::Plugins::Core
296
+ end
@@ -0,0 +1,442 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+ require "bigdecimal/util"
5
+
6
+ require "date"
7
+ require "time"
8
+
9
+ class Fend
10
+ module Plugins
11
+ # `coercions` plugin provides a way to coerce validaiton input.
12
+ # First, the plugin needs to be loaded
13
+ #
14
+ # plugin :coercions
15
+ #
16
+ # Because of Fend's dynamic nature, coercion is separated from validation.
17
+ # As such, coercion needs to be done before the actual validation. In order
18
+ # to make this work, type schema must be passed to `coerce` method.
19
+ #
20
+ # coerce username: :string, age: :integer, admin: :boolean
21
+ #
22
+ # As you can see, type schema is just a hash containing param names and
23
+ # types to which the values need to be converted. Here are some examples:
24
+ #
25
+ # # coerce username value to string
26
+ # coerce(username: :string)
27
+ #
28
+ # # coerce address value to hash
29
+ # coerce(address: :hash)
30
+ #
31
+ # # coerce address value to hash
32
+ # # coerce address[:city] value to string
33
+ # # coerce address[:street] value to string
34
+ # coerce(address: { city: :string, street: :string })
35
+ #
36
+ # # coerce tags to an array
37
+ # coerce(tags: :array)
38
+ #
39
+ # # coerce tags to an array of strings
40
+ # coerce(tags: [:string])
41
+ #
42
+ # # coerce tags to an array of hashes, each containing `id` and `name` of the tag
43
+ # coerce(tags: [{ id: :integer, name: :string }])
44
+ #
45
+ # Coerced data will also serve as result output:
46
+ #
47
+ # result = UserValidation.call(username: 1234, age: "18", admin: 0)
48
+ # result.output #=> { username: "1234", age: 18, admin: false }
49
+ #
50
+ # ## Built-in coercions
51
+ #
52
+ # General rules:
53
+ #
54
+ # * If input value **cannot** be coerced to specified type, it is returned
55
+ # unmodified.
56
+ #
57
+ # * `nil` is returned if input value is an empty string, except for `:hash`
58
+ # and `:array` coercions.
59
+ #
60
+ # :any
61
+ # : Returns input
62
+ #
63
+ # :string
64
+ # : Returns `input.to_s` if input is `Numeric` or `Symbol`
65
+ #
66
+ # :symbol
67
+ # : Returns `input.to_sym` if `input.respond_to?(:to_sym)`
68
+ #
69
+ # :integer
70
+ # : Uses `Kernel.Integer(input)`
71
+ #
72
+ # :float
73
+ # : Uses `Kernel.Float(input)`
74
+ #
75
+ # :decimal
76
+ # : Uses `Kernel.Float(input).to_d`
77
+ #
78
+ # :date
79
+ # : Uses `Date.parse(input)`
80
+ #
81
+ # :date_time
82
+ # : Uses `DateTime.parse(input)`
83
+ #
84
+ # :time
85
+ # : Uses `Time.parse(input)`
86
+ #
87
+ # :boolean
88
+ # : Returns `true` if input is one of:
89
+ # `1, "1", "t", "true", :true "y","yes", "on"` (case insensitive)
90
+ #
91
+ # : Returns `false` if input is one of:
92
+ # `0, "0", "f", "false", :false, "n", "no", "off"` (case insensitive)
93
+ #
94
+ # :array
95
+ # : Returns `[]` if input is an empty string.
96
+ #
97
+ # : Returns input if input is an array
98
+ #
99
+ # :hash
100
+ # : Returns `{}` if input is an empty string.
101
+ #
102
+ # : Returns input if input is a hash
103
+ #
104
+ # ## Strict coercions
105
+ #
106
+ # Adding `strict_` prefix to type name will cause error to be raised
107
+ # when input is incoercible:
108
+ #
109
+ # coerce username: :strict_string
110
+ #
111
+ # UserValidation.call(username: Hash.new)
112
+ # #=> Fend::Plugins::Coercions::CoercionError: cannot coerce {} to string
113
+ #
114
+ # Custom error message can be defined by setting `:strict_error_message`
115
+ # option when loading the plugin:
116
+ #
117
+ # plugin :coercions, strict_error_message: "Incoercible input encountered"
118
+ #
119
+ # # or
120
+ #
121
+ # plugin :coercions, strict_error_message: ->(value, type) { "#{value.inspect} cannot become #{type}" }
122
+ #
123
+ # ## Defining custom coercions and overriding built-in ones
124
+ #
125
+ # You can define your own coercion method or override the built-in one by
126
+ # passing a block and using `coerce_to` method, when loading the plugin:
127
+ #
128
+ # plugin :coercions do
129
+ # # add new
130
+ # coerce_to(:positive_integer) do |input|
131
+ # Kernel.Integer(input).abs
132
+ # end
133
+ #
134
+ # # override existing
135
+ # coerce_to(:integer) do |input|
136
+ # # ...
137
+ # end
138
+ # end
139
+ #
140
+ # ### Handling incoercible input
141
+ #
142
+ # If input value cannot be coerced, either `ArgumentError` or `TypeError`
143
+ # should be raised.
144
+ #
145
+ # class PostValidation < Fend
146
+ # plugin :coercions do
147
+ # coerce_to(:user) do |input|
148
+ # raise ArgumentError unless input.is_a?(Integer)
149
+ #
150
+ # User.find(input)
151
+ # end
152
+ # end
153
+ #
154
+ # # ...
155
+ #
156
+ # end
157
+ #
158
+ # `ArgumentError` and `TypeError` are rescued on a higher level and
159
+ # input is returned as is.
160
+ #
161
+ # `coerce(modified_by: :user)`
162
+ #
163
+ # result = PostValidation.call(modified_by: "invalid_id")
164
+ #
165
+ # result.input #=> { modified_by: "invalid_id" }
166
+ # result.output #=> { modified_by: "invalid_id" }
167
+ #
168
+ # If **strict** coercion is specified, errors are re-raised as `CoercionError`.
169
+ #
170
+ # `coerce(modified_by: :strict_user)`
171
+ #
172
+ # result = PostValidation.call(modified_by: "invalid_id")
173
+ # #=> Fend::Plugins::Coercions::CoercionError: cannot coerce invalid_id to user
174
+ #
175
+ # ### Handling empty strings
176
+ #
177
+ # In order to check if input is an empty string, you can take advantange of
178
+ # `empty_string?` helper method. It takes input as an argument:
179
+ #
180
+ # coerce_to(:user) do |input|
181
+ # return if empty_string?(input)
182
+ #
183
+ # # ...
184
+ # end
185
+ module Coercions
186
+ class CoercionError < Error; end
187
+
188
+ def self.configure(validation, opts = {}, &block)
189
+ validation.const_set(:Coerce, Class.new(Fend::Plugins::Coercions::Coerce)) unless validation.const_defined?(:Coerce)
190
+ validation::Coerce.class_eval(&block) if block_given?
191
+ validation.opts[:coercions_strict_error_message] = opts.fetch(:strict_error_message, validation.opts[:coercions_strict_error_message])
192
+ validation::Coerce.fend_class = validation
193
+
194
+ validation.const_set(:Coercer, Coercer) unless validation.const_defined?(:Coercer)
195
+ end
196
+
197
+
198
+ module ClassMethods
199
+ attr_accessor :type_schema
200
+
201
+ def inherited(subclass)
202
+ super
203
+ coerce_class = Class.new(self::Coerce)
204
+ coerce_class.fend_class = subclass
205
+ subclass.const_set(:Coerce, coerce_class)
206
+ end
207
+
208
+ def coerce(type_schema_hash)
209
+ @type_schema = type_schema_hash
210
+ end
211
+ end
212
+
213
+
214
+ module InstanceMethods
215
+ def type_schema
216
+ schema = self.class.type_schema
217
+
218
+ return {} if schema.nil?
219
+
220
+ raise Error, "type schema must be hash" unless schema.is_a?(Hash)
221
+
222
+ schema
223
+ end
224
+
225
+ def process_input(data)
226
+ data = super || data
227
+ coerce(data)
228
+ end
229
+
230
+ private
231
+
232
+ def coerce(data)
233
+ coercer.call(data, type_schema)
234
+ end
235
+
236
+ def coercer
237
+ @_coercer ||= Coercer.new(self.class::Coerce.new)
238
+ end
239
+ end
240
+
241
+ class Coercer
242
+ attr_reader :coerce
243
+
244
+ def initialize(coerce)
245
+ @coerce = coerce
246
+ end
247
+
248
+ def call(data, schema)
249
+ data.each_with_object({}) do |(name, value), result|
250
+ type = schema[name]
251
+
252
+ result[name] = coerce_value(type, value)
253
+ end
254
+ end
255
+
256
+ private
257
+
258
+ def coerce_value(type, value)
259
+ case type
260
+ when NilClass then value
261
+ when Hash then process_hash(value, type)
262
+ when Array then process_array(value, type.first)
263
+ else
264
+ coerce.to(type, value)
265
+ end
266
+ end
267
+
268
+ def process_hash(input, schema)
269
+ coerced_value = coerce_value(:hash, input)
270
+
271
+ return coerced_value unless coerced_value.is_a?(Hash)
272
+
273
+ call(coerced_value, schema)
274
+ end
275
+
276
+ def process_array(input, member_schema)
277
+ coerced_value = coerce_value(:array, input)
278
+
279
+ return coerced_value unless coerced_value.is_a?(Array)
280
+
281
+ coerced_value.each_with_object([]) do |member, result|
282
+ value = member
283
+ type = member_schema.is_a?(Array) ? member_schema.first : member_schema
284
+
285
+ coerced_member_value = coerce_value(type, value)
286
+
287
+ next if coerced_member_value.nil?
288
+
289
+ result << coerced_member_value
290
+ end
291
+ end
292
+ end
293
+
294
+ class Coerce
295
+ STRICT_PREFIX = "strict_".freeze
296
+
297
+ @fend_class = Fend
298
+
299
+ class << self
300
+ attr_accessor :fend_class
301
+ end
302
+
303
+ def self.coerce_to(type, &block)
304
+ method_name = "to_#{type}"
305
+
306
+ define_method(method_name, &block)
307
+
308
+ private method_name
309
+ end
310
+
311
+ def self.to(type, value)
312
+ new.to(type, value)
313
+ end
314
+
315
+ def to(type, value, opts = {})
316
+ type = type.to_s.sub(STRICT_PREFIX, "") if is_strict = type.to_s.start_with?(STRICT_PREFIX)
317
+
318
+ begin
319
+ method("to_#{type}").call(value)
320
+ rescue ArgumentError, TypeError
321
+ is_strict ? raise_error(value, type) : value
322
+ end
323
+ end
324
+
325
+ private
326
+
327
+ def to_any(input)
328
+ return if empty_string?(input)
329
+
330
+ input
331
+ end
332
+
333
+ def to_string(input)
334
+ return if empty_string?(input) || input.nil?
335
+
336
+ case input
337
+ when String then input
338
+ when Numeric, Symbol then input.to_s
339
+ else
340
+ raise ArgumentError
341
+ end
342
+ end
343
+
344
+ def to_symbol(input)
345
+ return if empty_string?(input) || input.nil?
346
+
347
+ return input.to_sym if input.respond_to?(:to_sym)
348
+
349
+ raise ArgumentError
350
+ end
351
+
352
+ def to_integer(input)
353
+ return if empty_string?(input)
354
+
355
+ ::Kernel.Integer(input)
356
+ end
357
+
358
+ def to_float(input)
359
+ return if empty_string?(input)
360
+
361
+ ::Kernel.Float(input)
362
+ end
363
+
364
+ def to_decimal(input)
365
+ return if empty_string?(input)
366
+
367
+ to_float(input).to_d
368
+ end
369
+
370
+ def to_date(input)
371
+ return if empty_string?(input)
372
+
373
+ raise ArgumentError unless input.respond_to?(:to_str)
374
+
375
+ ::Date.parse(input)
376
+ end
377
+
378
+ def to_date_time(input)
379
+ return if empty_string?(input)
380
+
381
+ raise ArgumentError unless input.respond_to?(:to_str)
382
+
383
+ ::DateTime.parse(input)
384
+ end
385
+
386
+ def to_time(input)
387
+ return if empty_string?(input)
388
+
389
+ raise ArgumentError unless input.respond_to?(:to_str)
390
+
391
+ ::Time.parse(input)
392
+ end
393
+
394
+ def to_boolean(input)
395
+ return if empty_string?(input)
396
+
397
+ case input
398
+ when true, 1, /\A(?:1|t(?:rue)?|y(?:es)?|on)\z/i then true
399
+ when false, 0, /\A(?:0|f(?:alse)?|no?|off)\z/i then false
400
+ else
401
+ raise ArgumentError
402
+ end
403
+ end
404
+
405
+ def to_array(input)
406
+ return [] if empty_string?(input)
407
+ return input if input.is_a?(Array)
408
+
409
+ raise ArgumentError
410
+ end
411
+
412
+ def to_hash(input)
413
+ return {} if empty_string?(input)
414
+ return input if input.is_a?(Hash)
415
+
416
+ raise ArgumentError
417
+ end
418
+
419
+ private
420
+
421
+ def raise_error(input, type)
422
+ message = fend_class.opts[:coercions_strict_error_message] || "cannot coerce #{input.inspect} to #{type}"
423
+ message = message.is_a?(String) ? message : message.call(input, type)
424
+
425
+ raise CoercionError, message
426
+ end
427
+
428
+ def empty_string?(input)
429
+ return false unless input.is_a?(String) || input.is_a?(Symbol)
430
+
431
+ !(/\A[[:space:]]*\z/.match(input).nil?)
432
+ end
433
+
434
+ def fend_class
435
+ self.class.fend_class
436
+ end
437
+ end
438
+ end
439
+
440
+ register_plugin(:coercions, Coercions)
441
+ end
442
+ end