miscellany 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,442 @@
1
+ module Miscellany
2
+ class ParamValidator
3
+ attr_accessor :context, :options, :errors
4
+
5
+ delegate_missing_to :context
6
+
7
+ TIME_TYPES = [Date, DateTime, Time].freeze
8
+
9
+ CHECKS = %i[type specified present default transform in block items pattern].freeze
10
+ NON_PREFIXED = %i[default transform type message timezone].freeze
11
+ PREFIXES = %i[all onem onep one none].freeze
12
+ PREFIX_ALIASES = { any: :onep, not: :none }.freeze
13
+ ALL_PREFIXES = (PREFIXES + PREFIX_ALIASES.keys).freeze
14
+ VALID_FLAGS = %i[present specified].freeze
15
+
16
+ def self.record_type(model, key: :id)
17
+ Proc.new do |param, *args|
18
+ model.find_by!(key => param)
19
+ rescue ActiveRecord::RecordNotFound
20
+ raise ArgumentError
21
+ end
22
+ end
23
+
24
+ def initialize(block, context, parameters = nil, options = nil)
25
+ @block = block
26
+ @context = context
27
+ @params = parameters || context.params
28
+ @subkeys = []
29
+ @options = options || {}
30
+ @errors = {}
31
+ @explicit_parameters = []
32
+ end
33
+
34
+ def self.check(params, context: nil, &blk)
35
+ pv = new(blk, context, params)
36
+ pv.apply_checks
37
+ pv.errors
38
+ end
39
+
40
+ def self.assert(params, context: nil, handle:, &blk)
41
+ errors = check(params, context: context, &blk)
42
+ if errors.present?
43
+ handle.call(errors)
44
+ else
45
+ params
46
+ end
47
+ end
48
+
49
+ def apply_checks(&blk)
50
+ blk ||= @block
51
+ args = trim_arguments(blk, [params, @subkeys[-1]])
52
+ instance_exec(*args, &blk)
53
+ end
54
+
55
+ def parameter(param_keys, *args, **kwargs, &blk)
56
+ param_keys = Array(param_keys)
57
+ opts = normalize_opts(*args, **kwargs, &blk)
58
+
59
+ checks = {}
60
+ PREFIXES.each do |pfx|
61
+ pfx_keys = opts[pfx]&.keys&.select { |k| opts[pfx][k] } || []
62
+ pfx_keys.each do |k|
63
+ checks[k] = pfx # TODO: Support filters connected to multiple prefixes
64
+ end
65
+ # TODO: warn if pfx != :all && param_keys.length == 1
66
+ end
67
+ NON_PREFIXED.each do |k|
68
+ checks[k] = nil
69
+ end
70
+
71
+ all_results = {}
72
+ param_keys.each do |pk|
73
+ check_results = all_results[pk] = {}
74
+ run_check = ->(check, &blk) { exec_check(check_results, check, checks, options: opts, &blk) }
75
+
76
+ exec_check(check_results, :type) { coerce_type(params, pk, opts) } || next
77
+
78
+ run_check[:specified] { params.key?(pk) || 'must be specified' } || next
79
+ run_check[:present] { params[pk].present? || 'must be present' } || next
80
+
81
+ # Set Default
82
+ if params[pk].nil? && !opts[:default].nil?
83
+ params[pk] ||= opts[:default].respond_to?(:call) ? opts[:default].call : opts[:default]
84
+ next # We can assume that the default value is allowed
85
+ end
86
+
87
+ # Apply Transform
88
+ params[pk] = opts[:transform].to_proc.call(params[pk]) if params.include?(pk) && opts[:transform]
89
+
90
+ next if params[pk].nil?
91
+
92
+ run_check[:pattern] do |pattern|
93
+ return true if params[pk].to_s.match?(pattern)
94
+
95
+ "must match pattern: #{pattern.inspect}"
96
+ end
97
+
98
+ run_check[:in] do |one_of|
99
+ next true if one_of.include?(params[pk])
100
+
101
+ if one_of.is_a?(Range)
102
+ "must be between: #{one_of.begin}..#{one_of.end}"
103
+ else
104
+ "must be one of: #{one_of.to_a.join(', ')}"
105
+ end
106
+ end
107
+
108
+ # Nested check
109
+ run_check[:block] do |blk|
110
+ iterate_array = false # TODO
111
+ sub_parameter(pk) do
112
+ if params.is_a?(Array) && iterate_array
113
+ params.each_with_index do |v, i|
114
+ sub_parameter(i) { apply_checks(&blk) }
115
+ end
116
+ else
117
+ apply_checks(&blk)
118
+ end
119
+ end
120
+ end
121
+
122
+ # Nested check
123
+ run_check[:items] do |blk|
124
+ sub_parameter(pk) do
125
+ if params.is_a?(Array)
126
+ params.each_with_index do |v, i|
127
+ sub_parameter(i) { apply_checks(&blk) }
128
+ end
129
+ else
130
+ raise "items: validator can only be used with Arrays"
131
+ end
132
+ end
133
+ end
134
+ end
135
+
136
+ final_errors = {}
137
+ checks.each do |check, check_prefix|
138
+ if check_prefix == :all || check_prefix == nil
139
+ all_results.each do |field, err_map|
140
+ errs = err_map[check]
141
+ next unless errs.present?
142
+
143
+ final_errors[field] = merge_error_hashes(final_errors[field], errs)
144
+ end
145
+ elsif check_prefix == :none
146
+ all_results.each do |field, err_map|
147
+ errs = err_map[check]
148
+ final_errors[field] = merge_error_hashes(final_errors[field], "must NOT be #{check}") unless errs.present?
149
+ end
150
+ else
151
+ counts = check_pass_count(check, all_results)
152
+ field_key = param_keys.join(', ')
153
+ string_prefixes = {
154
+ onep: 'One or more of',
155
+ onem: 'At most one of',
156
+ one: 'Exactly one of',
157
+ }
158
+
159
+ if (counts[:passed] != 1 && check_prefix == :one) ||
160
+ (counts[:passed] > 1 && check_prefix == :onem) ||
161
+ (counts[:passed] < 1 && check_prefix == :onep)
162
+
163
+ final_errors = merge_error_hashes(final_errors, "#{string_prefixes[check_prefix]} #{field_key} #{check}")
164
+ end
165
+ end
166
+ end
167
+
168
+ @errors = merge_error_hashes(@errors, final_errors)
169
+ final_errors
170
+ end
171
+
172
+ alias p parameter
173
+
174
+ protected
175
+
176
+ def check_pass_count(check, all_results)
177
+ counts = { passed: 0, failed: 0, skipped: 0 }
178
+ all_results.each do |_field, field_results|
179
+ result = field_results[check]
180
+ key = if result.nil?
181
+ :skipped
182
+ elsif result.present?
183
+ :failed
184
+ else
185
+ :passed
186
+ end
187
+ counts[key] += 1
188
+ end
189
+ counts
190
+ end
191
+
192
+ def exec_check(state, check, checks_to_run = nil, options: nil, &blk)
193
+ return true if checks_to_run && !checks_to_run[check] && !NON_PREFIXED.include?(check)
194
+
195
+ # TODO: Support Running checks of the same type for different prefixes
196
+
197
+ check_prefixes = NON_PREFIXED.include?(check) ? [nil] : Array(checks_to_run&.[](check))
198
+ return true unless check_prefixes.present?
199
+
200
+ check_prefixes.each do |check_prefix|
201
+ initial_errors = @errors
202
+ @errors = []
203
+ prefix_options = (check_prefix.nil? ? options : options&.[](check_prefix)) || {}
204
+ args = trim_arguments(blk, [prefix_options[check]])
205
+
206
+ result = yield(*args)
207
+ result = "failed validation #{check}" if result == false
208
+
209
+ if result.present? && result != true
210
+ result = options[:message] if options&.[](:message).present?
211
+ Array(result).each do |e|
212
+ @errors << e
213
+ end
214
+ end
215
+
216
+ state[check] = merge_error_hashes(state[check], @errors)
217
+ @errors = initial_errors
218
+ end
219
+
220
+ !state[check].present?
221
+ end
222
+
223
+ def coerce_type(params, key, opts)
224
+ value = params[key]
225
+ return nil if value.nil? || !opts[:type].present?
226
+
227
+ types = Array(opts[:type])
228
+ types.each do |t|
229
+ params[key] = coerce_single_type(value, t, opts)
230
+ return true
231
+ rescue ArgumentError, TypeError => err
232
+ end
233
+
234
+ "'#{value}' could not be cast to a #{types.join(' or a ')}"
235
+ end
236
+
237
+ def coerce_single_type(param, type, options)
238
+ return param if (param.is_a?(type) rescue false)
239
+
240
+ if type.is_a?(Class) && type <= ActiveRecord::Base
241
+ type = self.class.record_type(type)
242
+ end
243
+
244
+ return type.call(param, options) if type.is_a?(Proc)
245
+
246
+ if (param.is_a?(Array) && type != Array) || ((param.is_a?(Hash) || param.is_a?(ActionController::Parameters)) && type != Hash)
247
+ raise ArgumentError
248
+ end
249
+ return param if (param.is_a?(ActionController::Parameters) && type == Hash rescue false)
250
+
251
+ # Primitives
252
+ return Integer(param) if type == Integer
253
+ return Float(param) if type == Float
254
+ return Float(param) if type == Numeric
255
+ return String(param) if type == String
256
+
257
+ # Date/Time
258
+ if TIME_TYPES.include? type
259
+ if tz = options[:timezone]
260
+ tz = ActiveSupport::TimeZone[tz] if tz.is_a?(String)
261
+ dt = options[:format].present? ? tz.strptime(param, options[:format]) : tz.parse(param)
262
+ dt = dt.to_date if type == Date
263
+ return dt
264
+ else
265
+ if options[:format].present?
266
+ return type.strptime(param, options[:format])
267
+ else
268
+ return type.parse(param)
269
+ end
270
+ end
271
+ end
272
+
273
+ # Arrays/Hashes
274
+ raise ArgumentError if (type == Array || type == Hash) && !param.respond_to?(:split)
275
+ return Array(param.split(options[:delimiter] || ',')) if type == Array
276
+ return Hash[param.split(options[:delimiter] || ',').map { |c| c.split(options[:separator] || ':') }] if type == Hash
277
+
278
+ # Booleans
279
+ if [TrueClass, FalseClass, :boolean, :bool].include?(type)
280
+ return false if /^(false|f|no|n|0)$/i === param.to_s
281
+ return true if /^(true|t|yes|y|1)$/i === param.to_s
282
+
283
+ raise ArgumentError
284
+ end
285
+
286
+ # BigDecimals
287
+ if type == BigDecimal
288
+ param = param.delete('$,').strip.to_f if param.is_a?(String)
289
+ return BigDecimal(param, (options[:precision] || DEFAULT_PRECISION))
290
+ end
291
+ nil
292
+ end
293
+
294
+ def normalize_opts(*args, **kwargs, &blk)
295
+ # Stage 1 - Convert args to kwargs
296
+ args, norm = convert_positional_args(args, kwargs)
297
+ if blk.present?
298
+ type = args.delete(:items) ? :all_items : :all_block
299
+ set_hash_key(norm, type, blk)
300
+ end
301
+ set_hash_key(norm, :type, args.pop(0)) if args.present?
302
+
303
+ # Stage 2
304
+ norm = convert_flags(norm)
305
+
306
+ # Stage 3
307
+ norm = convert_prefixed_keys(norm)
308
+
309
+ extra_kwargs = norm.keys - PREFIXES - NON_PREFIXED
310
+ raise ArgumentError, "Unrecognized postitional arguments: #{args.inspect}" if args.present?
311
+ raise ArgumentError, "Unrecognized keyword arguments: #{extra_kwargs.inspect}" if extra_kwargs.present?
312
+
313
+ norm
314
+ end
315
+
316
+ def convert_positional_args(args, kwargs)
317
+ dest_hash = { **kwargs }
318
+ args = args.reject do |arg|
319
+ next false unless arg.is_a?(Symbol)
320
+
321
+ flag, pfx = split_key(arg)
322
+ next false unless VALID_FLAGS.include?(flag) && (pfx.nil? || PREFIXES.include?(pfx))
323
+
324
+ set_hash_key(dest_hash, :"#{pfx || 'all'}_#{flag}", true)
325
+ true
326
+ end
327
+ [args, dest_hash]
328
+ end
329
+
330
+ def convert_flags(h)
331
+ dest_hash = {}
332
+ h.each do |k, v|
333
+ if VALID_FLAGS.include?(k) && normalize_prefix(v)
334
+ set_hash_key(dest_hash, :"#{normalize_prefix(v)}_#{k}", true)
335
+ else
336
+ dest_hash[k] = v
337
+ end
338
+ end
339
+ dest_hash
340
+ end
341
+
342
+ def convert_prefixed_keys(h)
343
+ dest_hash = {}
344
+
345
+ h.each do |k, v|
346
+ flag, pfx = split_key(k)
347
+ if !CHECKS.include?(flag)
348
+ dest_hash[k] = v
349
+ elsif NON_PREFIXED.include?(flag)
350
+ # TODO: Raise warning if pfx
351
+ dest_hash[k] = v
352
+ elsif normalize_prefix(flag)
353
+ dest_hash[flag] = merge_hashes(dest_hash[normalize_prefix(flag)] || {}, v)
354
+ elsif pfx.nil? || PREFIXES.include?(pfx)
355
+ pfx ||= :all
356
+ dest_hash[pfx] ||= {}
357
+ set_hash_key(dest_hash[pfx], flag, v)
358
+ else
359
+ dest_hash[k] = v
360
+ end
361
+ end
362
+
363
+ dest_hash
364
+ end
365
+
366
+ def normalize_prefix(prefix)
367
+ prefix = PREFIX_ALIASES[prefix] if PREFIX_ALIASES.include?(prefix)
368
+ return nil unless PREFIXES.include?(prefix)
369
+
370
+ prefix
371
+ end
372
+
373
+ def split_key(key)
374
+ skey = key.to_s
375
+ ALL_PREFIXES.each do |pfx|
376
+ spfx = pfx.to_s
377
+ next unless skey.starts_with?("#{spfx}_")
378
+
379
+ return [skey[(spfx.length + 1)..-1].to_sym, PREFIX_ALIASES[pfx] || pfx]
380
+ end
381
+ [key, nil]
382
+ end
383
+
384
+ def sub_parameter(k)
385
+ @subkeys.push(k)
386
+ yield
387
+ ensure
388
+ @subkeys.pop
389
+ end
390
+
391
+ def params
392
+ p = @params
393
+ @subkeys.each { |k| p = p[k] }
394
+ p
395
+ end
396
+
397
+ def merge_error_hashes(target, from)
398
+ target ||= []
399
+ if target.is_a?(Hash)
400
+ ta = []
401
+ th = target
402
+ else
403
+ ta = target
404
+ th = target[-1].is_a?(Hash) ? ta.pop : {}
405
+ end
406
+
407
+ if from.is_a?(Hash)
408
+ from.each_pair do |k, v|
409
+ th[k] = merge_error_hashes(th[k], v)
410
+ end
411
+ elsif from.is_a?(Array)
412
+ merge_error_hashes(th, from.pop) if from[-1].is_a?(Hash)
413
+ from.each { |f| ta << f }
414
+ else
415
+ ta << from
416
+ end
417
+
418
+ return th if !ta.present? && th.present?
419
+
420
+ ta << th if th.present?
421
+ ta
422
+ end
423
+
424
+ def merge_hashes(h1, h2)
425
+ h2.each do |k, v|
426
+ set_hash_key(h1, k, v)
427
+ end
428
+ h1
429
+ end
430
+
431
+ def set_hash_key(h, k, v)
432
+ k = k.to_sym
433
+ # TODO: warn if h[k].present?
434
+ h[k] = v
435
+ end
436
+
437
+ def trim_arguments(blk, args)
438
+ return args if blk.arity.negative?
439
+ args[0..(blk.arity.abs - 1)]
440
+ end
441
+ end
442
+ end
@@ -0,0 +1,3 @@
1
+ module Miscellany
2
+ VERSION = "0.1.0".freeze
3
+ end
@@ -0,0 +1,43 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path("../lib", __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+
5
+ begin
6
+ require "miscellany/version"
7
+ version = Miscellany::VERSION
8
+ rescue LoadError
9
+ version = "0.0.0.docker"
10
+ end
11
+
12
+ Gem::Specification.new do |spec|
13
+ spec.name = "miscellany"
14
+ spec.version = version
15
+ spec.authors = ["Ethan Knapp"]
16
+ spec.email = ["eknapp@instructure.com"]
17
+
18
+ spec.summary = "Gem for a bunch of random, re-usable Rails Concerns & Helpers"
19
+ spec.homepage = "https://instructure.com"
20
+
21
+ spec.files = Dir["{app,config,db,lib}/**/*", "README.md", "*.gemspec"]
22
+ spec.test_files = Dir["spec/**/*"]
23
+ spec.require_paths = ['lib']
24
+
25
+ spec.add_development_dependency "bundler", "~> 1.15"
26
+ spec.add_development_dependency "rake", "~> 10.0"
27
+ spec.add_development_dependency "rspec", "~> 3.0"
28
+ spec.add_development_dependency "rspec-rails"
29
+ spec.add_development_dependency "pg"
30
+ spec.add_development_dependency "factory"
31
+ spec.add_development_dependency "factory_bot"
32
+ spec.add_development_dependency "timecop"
33
+ spec.add_development_dependency "webmock"
34
+ spec.add_development_dependency "sinatra", ">= 0"
35
+ spec.add_development_dependency "shoulda-matchers"
36
+ spec.add_development_dependency "yard"
37
+ spec.add_development_dependency "pry"
38
+ spec.add_development_dependency "pry-nav"
39
+ spec.add_development_dependency "rubocop"
40
+
41
+ spec.add_dependency "rails", ">= 5"
42
+ spec.add_dependency "activerecord-import"
43
+ end