miscellany 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,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