decanter 2.1.1 → 3.1.1

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.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/.codeclimate.yml +38 -0
  3. data/.github/ISSUE_TEMPLATE/BUG_REPORT.md +33 -0
  4. data/.github/ISSUE_TEMPLATE/FEATURE_REQUEST.md +18 -0
  5. data/.github/PULL_REQUEST_TEMPLATE.md +8 -0
  6. data/.gitignore +5 -0
  7. data/.rspec +2 -0
  8. data/.ruby-version +1 -0
  9. data/.travis.yml +10 -0
  10. data/CODE_OF_CONDUCT.md +76 -0
  11. data/CONTRIBUTING.md +36 -0
  12. data/Gemfile +4 -0
  13. data/Gemfile.lock +102 -0
  14. data/LICENSE.txt +21 -0
  15. data/README.md +241 -0
  16. data/Rakefile +1 -0
  17. data/bin/console +3 -4
  18. data/decanter.gemspec +39 -0
  19. data/lib/decanter.rb +18 -10
  20. data/lib/decanter/base.rb +0 -2
  21. data/lib/decanter/configuration.rb +2 -3
  22. data/lib/decanter/core.rb +136 -126
  23. data/lib/decanter/exceptions.rb +4 -7
  24. data/lib/decanter/extensions.rb +11 -11
  25. data/lib/decanter/parser.rb +13 -5
  26. data/lib/decanter/parser/array_parser.rb +28 -0
  27. data/lib/decanter/parser/base.rb +1 -2
  28. data/lib/decanter/parser/boolean_parser.rb +4 -3
  29. data/lib/decanter/parser/compose_parser.rb +27 -0
  30. data/lib/decanter/parser/core.rb +8 -16
  31. data/lib/decanter/parser/date_parser.rb +6 -7
  32. data/lib/decanter/parser/datetime_parser.rb +15 -0
  33. data/lib/decanter/parser/float_parser.rb +7 -5
  34. data/lib/decanter/parser/hash_parser.rb +6 -9
  35. data/lib/decanter/parser/integer_parser.rb +8 -4
  36. data/lib/decanter/parser/pass_parser.rb +5 -3
  37. data/lib/decanter/parser/phone_parser.rb +3 -3
  38. data/lib/decanter/parser/string_parser.rb +4 -5
  39. data/lib/decanter/parser/utils.rb +1 -3
  40. data/lib/decanter/parser/value_parser.rb +3 -4
  41. data/lib/decanter/railtie.rb +15 -11
  42. data/lib/decanter/version.rb +1 -3
  43. data/lib/generators/decanter/install_generator.rb +3 -5
  44. data/lib/generators/decanter/templates/initializer.rb +1 -4
  45. data/lib/generators/rails/decanter_generator.rb +5 -7
  46. data/lib/generators/rails/parser_generator.rb +3 -5
  47. data/lib/generators/rails/resource_override.rb +0 -2
  48. data/migration-guides/v3.0.0.md +21 -0
  49. metadata +47 -20
  50. data/lib/decanter/decant.rb +0 -11
  51. data/lib/decanter/parser/date_time_parser.rb +0 -21
  52. data/lib/decanter/parser/join_parser.rb +0 -14
  53. data/lib/decanter/parser/key_value_splitter_parser.rb +0 -18
  54. data/lib/decanter/parser/time_parser.rb +0 -19
@@ -0,0 +1 @@
1
+ require "bundler/gem_tasks"
@@ -1,8 +1,7 @@
1
1
  #!/usr/bin/env ruby
2
- # frozen_string_literal: true
3
2
 
4
- require 'bundler/setup'
5
- require 'decanter'
3
+ require "bundler/setup"
4
+ require "decanter"
6
5
 
7
6
  # You can add fixtures and/or initialization code here to make experimenting
8
7
  # with your gem easier. You can also use a different console, if you like.
@@ -11,5 +10,5 @@ require 'decanter'
11
10
  # require "pry"
12
11
  # Pry.start
13
12
 
14
- require 'irb'
13
+ require "irb"
15
14
  IRB.start
@@ -0,0 +1,39 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'decanter/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = 'decanter'
8
+ spec.version = Decanter::VERSION
9
+ spec.authors = ['Ryan Francis', 'David Corwin']
10
+ spec.email = ['ryan@launchpadlab.com']
11
+
12
+ spec.summary = %q{Form Parser for Rails}
13
+ spec.description = %q{Decanter aims to reduce complexity in Rails controllers by creating a place for transforming data before it hits the model and database.}
14
+ spec.homepage = 'https://github.com/launchpadlab/decanter'
15
+ spec.license = 'MIT'
16
+
17
+ # Prevent pushing this gem to RubyGems.org by setting 'allowed_push_host', or
18
+ # delete this section to allow pushing this gem to any host.
19
+ if spec.respond_to?(:metadata)
20
+ spec.metadata['allowed_push_host'] = 'https://rubygems.org'
21
+ else
22
+ raise 'RubyGems 2.0 or newer is required to protect against public gem pushes.'
23
+ end
24
+
25
+ spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
26
+ spec.bindir = 'exe'
27
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
28
+ spec.require_paths = ['lib']
29
+
30
+ spec.add_dependency 'actionpack', '>= 4.2.10'
31
+ spec.add_dependency 'activesupport'
32
+ spec.add_dependency 'rails-html-sanitizer', '>= 1.0.4'
33
+
34
+ spec.add_development_dependency 'bundler', '~> 1.9'
35
+ spec.add_development_dependency 'dotenv'
36
+ spec.add_development_dependency 'rake', '~> 10.0'
37
+ spec.add_development_dependency 'rspec-rails'
38
+ spec.add_development_dependency 'simplecov', '~> 0.15.1'
39
+ end
@@ -1,9 +1,9 @@
1
- # frozen_string_literal: true
2
-
3
1
  require 'active_support/all'
4
2
 
5
3
  module Decanter
4
+
6
5
  class << self
6
+
7
7
  def decanter_for(klass_or_sym)
8
8
  decanter_name =
9
9
  case klass_or_sym
@@ -12,9 +12,13 @@ module Decanter
12
12
  when Symbol
13
13
  klass_or_sym.to_s.singularize.camelize
14
14
  else
15
- raise ArgumentError, "cannot lookup decanter for #{klass_or_sym} with class #{klass_or_sym.class}"
16
- end + 'Decanter'
17
- decanter_name.constantize
15
+ raise ArgumentError.new("cannot lookup decanter for #{klass_or_sym} with class #{klass_or_sym.class}")
16
+ end.concat('Decanter')
17
+ begin
18
+ decanter_name.constantize
19
+ rescue
20
+ raise NameError.new("uninitialized constant #{decanter_name}")
21
+ end
18
22
  end
19
23
 
20
24
  def decanter_from(klass_or_string)
@@ -23,20 +27,24 @@ module Decanter
23
27
  when Class
24
28
  klass_or_string
25
29
  when String
26
- klass_or_string.constantize
30
+ begin
31
+ klass_or_string.constantize
32
+ rescue
33
+ raise NameError.new("uninitialized constant #{klass_or_string}")
34
+ end
27
35
  else
28
- raise ArgumentError, "cannot find decanter from #{klass_or_string} with class #{klass_or_string.class}"
36
+ raise ArgumentError.new("cannot find decanter from #{klass_or_string} with class #{klass_or_string.class}")
29
37
  end
30
38
 
31
39
  unless constant.ancestors.include? Decanter::Base
32
- raise ArgumentError, "#{constant.name} is not a decanter"
40
+ raise ArgumentError.new("#{constant.name} is not a decanter")
33
41
  end
34
42
 
35
43
  constant
36
44
  end
37
45
 
38
46
  def configuration
39
- @configuration ||= Decanter::Configuration.new
47
+ @config ||= Decanter::Configuration.new
40
48
  end
41
49
 
42
50
  def config
@@ -54,4 +62,4 @@ require 'decanter/base'
54
62
  require 'decanter/extensions'
55
63
  require 'decanter/exceptions'
56
64
  require 'decanter/parser'
57
- require 'decanter/railtie' if defined?(::Rails)
65
+ require 'decanter/railtie' if defined?(::Rails)
@@ -1,5 +1,3 @@
1
- # frozen_string_literal: true
2
-
3
1
  require 'decanter/core'
4
2
 
5
3
  module Decanter
@@ -1,11 +1,10 @@
1
- # frozen_string_literal: true
2
-
3
1
  module Decanter
4
2
  class Configuration
3
+
5
4
  attr_accessor :strict
6
5
 
7
6
  def initialize
8
- @strict = :with_exception
7
+ @strict = true
9
8
  end
10
9
  end
11
10
  end
@@ -1,196 +1,197 @@
1
- # frozen_string_literal: true
2
-
3
1
  module Decanter
4
2
  module Core
3
+
5
4
  def self.included(base)
6
5
  base.extend(ClassMethods)
7
6
  end
8
7
 
9
8
  module ClassMethods
10
- # Declare an ordinary parameter transformation.
11
- def input(name, parsers = nil, **options)
12
- options[:type] = :input
13
- options[:parsers] = parsers
14
- handler(name, options)
15
- end
9
+
10
+ def input(name, parsers=nil, **options)
16
11
 
17
- # rubocop:disable Naming/PredicateName
12
+ _name = [name].flatten
18
13
 
19
- # Declare a _has many_ association for a parameter.
20
- def has_many(name, **options)
21
- options[:type] = :has_many
22
- options[:assoc] = name
23
- handler(name, options)
24
- end
14
+ if _name.length > 1 && parsers.blank?
15
+ raise ArgumentError.new("#{self.name} no parser specified for input with multiple values.")
16
+ end
25
17
 
26
- # Declare a _has one_ association for a parameter.
27
- def has_one(name, **options)
28
- options[:type] = :has_one
29
- options[:assoc] = name
30
- handler(name, options)
18
+ handlers[_name] = {
19
+ key: options.fetch(:key, _name.first),
20
+ name: _name,
21
+ options: options,
22
+ parsers: parsers,
23
+ type: :input
24
+ }
31
25
  end
32
- # rubocop:enable Naming/PredicateName
33
26
 
34
- # Add a parameter handler to the class. Takes a name, and a set of
35
- # options. This is a generic method for any sort of handler, e.g.
36
- #
37
- # handle :foo, type: :input, parsers: [:string], as: :bar
38
- def handler(name, options)
39
- name = options.fetch(:as, name)
40
- parsers = options.delete(:parsers)
41
-
42
- if Array(name).length > 1 && parsers.blank?
43
- raise ArgumentError,
44
- "#{name} no parser specified for input with multiple values."
45
- end
46
-
47
- if handlers.key?(name)
48
- raise ArgumentError, "Handler for #{name} already defined"
49
- end
27
+ def has_many(assoc, **options)
28
+ handlers[assoc] = {
29
+ assoc: assoc,
30
+ key: options.fetch(:key, assoc),
31
+ name: assoc,
32
+ options: options,
33
+ type: :has_many
34
+ }
35
+ end
50
36
 
51
- handlers[name] = {
52
- key: options.fetch(:key, Array(name).first),
53
- assoc: options.delete(:assoc),
54
- type: options.delete(:type),
37
+ def has_one(assoc, **options)
38
+ handlers[assoc] = {
39
+ assoc: assoc,
40
+ key: options.fetch(:key, assoc),
41
+ name: assoc,
55
42
  options: options,
56
- parsers: Array(parsers)
43
+ type: :has_one
57
44
  }
58
45
  end
59
46
 
60
- # List of parameters to ignore.
61
47
  def ignore(*args)
62
48
  keys_to_ignore.push(*args)
63
49
  end
64
50
 
65
- # Set a level of strictness when dealing with parameters that are present
66
- # but not expected.
67
- #
68
- # with_exception: Raise an exception
69
- # true: Delete the parameter
70
- # false: Allow the parameter through
71
- #
72
51
  def strict(mode)
73
- raise(ArgumentError, "#{name}: Unknown strict value #{mode}") unless [:with_exception, true, false].include? mode
52
+ raise(ArgumentError, "#{self.name}: Unknown strict value #{mode}") unless [true, false].include? mode
74
53
  @strict_mode = mode
75
54
  end
76
55
 
77
- # Take a parameter hash, and handle it with the various decanters
78
- # defined.
79
56
  def decant(args)
80
57
  return handle_empty_args if args.blank?
81
58
  return empty_required_input_error unless required_input_keys_present?(args)
59
+ args = args.to_unsafe_h.with_indifferent_access if args.class.name == 'ActionController::Parameters'
60
+ {}.merge( unhandled_keys(args) )
61
+ .merge( handled_keys(args) )
62
+ end
82
63
 
83
- if args.is_a?(ActionController::Parameters)
84
- args.permit!
85
- args = args.to_h
86
- end
64
+ def handle_empty_args
65
+ any_inputs_required? ? empty_args_error : {}
66
+ end
87
67
 
88
- args = args.deep_symbolize_keys
89
- handled_keys(args).merge(unhandled_keys(args))
68
+ def any_inputs_required?
69
+ required_inputs.any?
90
70
  end
91
71
 
92
72
  def required_inputs
93
- handlers.map do |name, handler|
94
- name if handler[:options][:required]
95
- end
73
+ handlers.map do |h|
74
+ options = h.last[:options]
75
+ h.first.first if options && options[:required]
76
+ end
96
77
  end
97
78
 
98
- def required_input_keys_present?(args = {})
99
- return true unless required_inputs.any?
79
+ def required_input_keys_present?(args={})
80
+ return true unless any_inputs_required?
100
81
  compact_inputs = required_inputs.compact
101
82
  compact_inputs.all? do |input|
102
83
  args.keys.map(&:to_sym).include?(input) && !args[input].nil?
103
84
  end
104
85
  end
105
86
 
106
- def empty_required_input_error(name = nil)
107
- raise MissingRequiredInputValue, "No value found for required argument #{name}"
87
+ def empty_required_input_error
88
+ raise(MissingRequiredInputValue, 'Required inputs have been declared, but no values for those inputs were passed.')
89
+ end
90
+
91
+ def empty_args_error
92
+ raise(ArgumentError, 'Decanter has required inputs but no values were passed')
108
93
  end
109
94
 
110
95
  # protected
111
96
 
112
97
  def unhandled_keys(args)
113
- unhandled = args.keys
114
- unhandled -= keys_to_ignore
115
- unhandled -= handlers.keys.flatten
116
-
117
- return {} unless unhandled.any?
98
+ unhandled_keys = args.keys.map(&:to_sym) -
99
+ handlers.keys.flatten.uniq -
100
+ keys_to_ignore -
101
+ handlers.values
102
+ .select { |handler| handler[:type] != :input }
103
+ .map { |handler| "#{handler[:name]}_attributes".to_sym }
118
104
 
119
- case strict_mode
120
- when true
121
- {}
122
- when :with_exception
123
- raise(UnhandledKeysError, "#{name} received unhandled keys: #{unhandled.join(', ')}.")
124
- else
125
- args.select { |key| unhandled.include? key }
126
- end
105
+ return {} unless unhandled_keys.any?
106
+ raise(UnhandledKeysError, "#{self.name} received unhandled keys: #{unhandled_keys.join(', ')}.") if strict_mode
107
+ args.select { |key| unhandled_keys.include? key }
127
108
  end
128
109
 
129
110
  def handled_keys(args)
130
- handlers.reduce({}) do |acc, curr|
131
- name, handler = *curr
132
- values = args.values_at(*name)
133
- values = values.length == 1 ? values.first : values
134
-
135
- is_empty_input = Array(values).all?(&:blank?)
136
- if is_empty_input
137
- empty_required_input_error(name) if handler[:options][:required]
138
- # Skip handling empty inputs
139
- next acc
140
- end
111
+ arg_keys = args.keys.map(&:to_sym)
112
+ inputs, assocs = handlers.values.partition { |handler| handler[:type] == :input }
141
113
 
142
- acc.merge handle(handler, values)
143
- end
114
+ {}.merge(
115
+ # Inputs
116
+ inputs.select { |handler| (arg_keys & handler[:name]).any? }
117
+ .reduce({}) { |memo, handler| memo.merge handle_input(handler, args) }
118
+ ).merge(
119
+ # Associations
120
+ assocs.reduce({}) { |memo, handler| memo.merge handle_association(handler, args) }
121
+ )
144
122
  end
145
123
 
146
- def handle(handler, values)
147
- decanter = decanter_for_handler(handler) unless handler[:type] == :input
148
-
149
- val = case handler[:type]
150
- when :input
151
- parse(handler[:parsers], values, handler[:options])
152
- when :has_one
153
- decanter.decant(values)
154
- when :has_many
155
- # should sort here, really.
156
- values = values.values if values.is_a?(Hash)
157
- values.compact.map { |v| decanter.decant(v) }
158
- end
124
+ def handle(handler, args)
125
+ values = args.values_at(*handler[:name])
126
+ values = values.length == 1 ? values.first : values
127
+ self.send("handle_#{handler[:type]}", handler, values)
128
+ end
159
129
 
160
- { handler[:key] => val }
130
+ def handle_input(handler, args)
131
+ values = args.values_at(*handler[:name])
132
+ values = values.length == 1 ? values.first : values
133
+ parse(handler[:key], handler[:parsers], values, handler[:options])
161
134
  end
162
135
 
163
- def decanter_for_handler(handler)
164
- if (specified_decanter = handler[:options][:decanter])
165
- Decanter.decanter_from(specified_decanter)
136
+ def handle_association(handler, args)
137
+ assoc_handlers = [
138
+ handler,
139
+ handler.merge({
140
+ key: handler[:options].fetch(:key, "#{handler[:name]}_attributes").to_sym,
141
+ name: "#{handler[:name]}_attributes".to_sym
142
+ })
143
+ ]
144
+
145
+ assoc_handler_names = assoc_handlers.map { |_handler| _handler[:name] }
146
+
147
+ case args.values_at(*assoc_handler_names).compact.length
148
+ when 0
149
+ {}
150
+ when 1
151
+ _handler = assoc_handlers.detect { |_handler| args.has_key?(_handler[:name]) }
152
+ self.send("handle_#{_handler[:type]}", _handler, args[_handler[:name]])
166
153
  else
167
- Decanter.decanter_for(handler[:assoc])
154
+ raise ArgumentError.new("Handler #{handler[:name]} matches multiple keys: #{assoc_handler_names}.")
168
155
  end
169
156
  end
170
157
 
171
- def handle_empty_args
172
- required_inputs.any? ? empty_required_input_error : {}
158
+ def handle_has_many(handler, values)
159
+ decanter = decanter_for_handler(handler)
160
+ if values.is_a?(Hash)
161
+ parsed_values = values.map do |index, input_values|
162
+ next if input_values.nil?
163
+ decanter.decant(input_values)
164
+ end
165
+ return { handler[:key] => parsed_values }
166
+ else
167
+ {
168
+ handler[:key] => values.compact.map { |value| decanter.decant(value) }
169
+ }
170
+ end
173
171
  end
174
172
 
175
- def parse(parsers, values, options)
176
- return values if parsers.nil?
177
-
178
- Parser.parsers_for(parsers).each do |parser|
179
- unless values.is_a?(Hash)
180
- values = parser.parse(values, options)
181
- next
182
- end
173
+ def handle_has_one(handler, values)
174
+ { handler[:key] => decanter_for_handler(handler).decant(values) }
175
+ end
183
176
 
184
- # For hashes, we operate the parser on each member
185
- values.each do |k, v|
186
- values[k] = parser.parse(v, options)
187
- end
177
+ def decanter_for_handler(handler)
178
+ if specified_decanter = handler[:options][:decanter]
179
+ Decanter::decanter_from(specified_decanter)
180
+ else
181
+ Decanter::decanter_for(handler[:assoc])
188
182
  end
183
+ end
189
184
 
190
- values
185
+ def parse(key, parsers, value, options)
186
+ return { key => value } unless parsers
187
+ if options[:required] && value_missing?(value)
188
+ raise ArgumentError.new("No value for required argument: #{key}")
189
+ end
190
+ parser_classes = Parser.parsers_for(parsers)
191
+ Parser.compose_parsers(parser_classes).parse(key, value, options)
191
192
  end
192
193
 
193
- def handlers
194
+ def handlers
194
195
  @handlers ||= {}
195
196
  end
196
197
 
@@ -201,6 +202,15 @@ module Decanter
201
202
  def strict_mode
202
203
  @strict_mode.nil? ? Decanter.configuration.strict : @strict_mode
203
204
  end
205
+
206
+ # Helpers
207
+
208
+ private
209
+
210
+ def value_missing?(value)
211
+ value.nil? || value == ""
212
+ end
213
+
204
214
  end
205
215
  end
206
216
  end