decanter 2.1.1 → 3.1.1

Sign up to get free protection for your applications and to get access to all the features.
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