input_sanitizer 0.2.2 → 0.3.33

Sign up to get free protection for your applications and to get access to all the features.
Files changed (47) hide show
  1. checksums.yaml +5 -5
  2. data/.gitignore +2 -0
  3. data/.travis.yml +2 -0
  4. data/CHANGELOG +92 -0
  5. data/LICENSE +201 -22
  6. data/README.md +7 -0
  7. data/input_sanitizer.gemspec +15 -5
  8. data/lib/input_sanitizer/errors.rb +142 -0
  9. data/lib/input_sanitizer/extended_converters/comma_joined_integers_converter.rb +15 -0
  10. data/lib/input_sanitizer/extended_converters/comma_joined_strings_converter.rb +15 -0
  11. data/lib/input_sanitizer/extended_converters/positive_integer_converter.rb +12 -0
  12. data/lib/input_sanitizer/extended_converters/specific_values_converter.rb +19 -0
  13. data/lib/input_sanitizer/extended_converters.rb +5 -55
  14. data/lib/input_sanitizer/restricted_hash.rb +49 -8
  15. data/lib/input_sanitizer/v1/clean_field.rb +38 -0
  16. data/lib/input_sanitizer/{default_converters.rb → v1/default_converters.rb} +8 -11
  17. data/lib/input_sanitizer/v1/sanitizer.rb +163 -0
  18. data/lib/input_sanitizer/v1.rb +22 -0
  19. data/lib/input_sanitizer/v2/clean_field.rb +36 -0
  20. data/lib/input_sanitizer/v2/clean_payload_collection_field.rb +41 -0
  21. data/lib/input_sanitizer/v2/clean_query_collection_field.rb +40 -0
  22. data/lib/input_sanitizer/v2/error_collection.rb +49 -0
  23. data/lib/input_sanitizer/v2/nested_sanitizer_factory.rb +19 -0
  24. data/lib/input_sanitizer/v2/payload_sanitizer.rb +130 -0
  25. data/lib/input_sanitizer/v2/payload_transform.rb +42 -0
  26. data/lib/input_sanitizer/v2/query_sanitizer.rb +33 -0
  27. data/lib/input_sanitizer/v2/types.rb +213 -0
  28. data/lib/input_sanitizer/v2.rb +13 -0
  29. data/lib/input_sanitizer/version.rb +1 -1
  30. data/lib/input_sanitizer.rb +5 -2
  31. data/spec/extended_converters/comma_joined_integers_converter_spec.rb +18 -0
  32. data/spec/extended_converters/comma_joined_strings_converter_spec.rb +18 -0
  33. data/spec/extended_converters/positive_integer_converter_spec.rb +18 -0
  34. data/spec/extended_converters/specific_values_converter_spec.rb +27 -0
  35. data/spec/restricted_hash_spec.rb +37 -7
  36. data/spec/sanitizer_spec.rb +32 -22
  37. data/spec/spec_helper.rb +3 -1
  38. data/spec/{default_converters_spec.rb → v1/default_converters_spec.rb} +27 -9
  39. data/spec/v2/converters_spec.rb +174 -0
  40. data/spec/v2/payload_sanitizer_spec.rb +460 -0
  41. data/spec/v2/payload_transform_spec.rb +98 -0
  42. data/spec/v2/query_sanitizer_spec.rb +300 -0
  43. data/v2.md +52 -0
  44. metadata +86 -30
  45. data/Gemfile.lock +0 -44
  46. data/lib/input_sanitizer/sanitizer.rb +0 -179
  47. data/spec/extended_converters_spec.rb +0 -78
@@ -0,0 +1,22 @@
1
+ module InputSanitizer::V1
2
+ end
3
+
4
+ dir = File.dirname(__FILE__)
5
+ require File.join(dir, 'errors')
6
+ require File.join(dir, 'restricted_hash')
7
+ require File.join(dir, 'v1', 'default_converters')
8
+ require File.join(dir, 'v1', 'clean_field')
9
+ require File.join(dir, 'v1', 'sanitizer')
10
+ require File.join(dir, 'extended_converters')
11
+
12
+ # Backward compatibility
13
+ module InputSanitizer
14
+ Sanitizer = V1::Sanitizer
15
+
16
+ IntegerConverter = V1::IntegerConverter
17
+ StringConverter = V1::StringConverter
18
+ DateConverter = V1::DateConverter
19
+ TimeConverter = V1::TimeConverter
20
+ BooleanConverter = V1::BooleanConverter
21
+ AllowNil = V1::AllowNil
22
+ end
@@ -0,0 +1,36 @@
1
+ class InputSanitizer::V2::CleanField < MethodStruct.new(:data, :has_key, :default, :collection, :type, :converter, :options)
2
+ def call
3
+ if has_key
4
+ convert
5
+ elsif default
6
+ converter.call(default, options)
7
+ elsif options[:required]
8
+ raise InputSanitizer::ValueMissingError
9
+ else
10
+ raise InputSanitizer::OptionalValueOmitted
11
+ end
12
+ end
13
+
14
+ private
15
+ def convert
16
+ if collection
17
+ collection_clean.call(
18
+ :data => data,
19
+ :collection => collection,
20
+ :converter => converter,
21
+ :options => options
22
+ )
23
+ else
24
+ converter.call(data, options)
25
+ end
26
+ end
27
+
28
+ def collection_clean
29
+ case type
30
+ when :payload
31
+ InputSanitizer::V2::CleanPayloadCollectionField
32
+ when :query
33
+ InputSanitizer::V2::CleanQueryCollectionField
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,41 @@
1
+ class InputSanitizer::V2::CleanPayloadCollectionField < MethodStruct.new(:data, :converter, :collection, :options)
2
+ def call
3
+ return nil if options[:allow_nil] && data == nil
4
+
5
+ validate_type
6
+ validate_size
7
+
8
+ result, errors = [], {}
9
+
10
+ data.each_with_index do |value, idx|
11
+ begin
12
+ result << converter.call(value, options)
13
+ rescue InputSanitizer::ValidationError => e
14
+ errors[idx] = e
15
+ end
16
+ end
17
+
18
+ if errors.any?
19
+ raise InputSanitizer::CollectionError.new(errors)
20
+ else
21
+ result
22
+ end
23
+ end
24
+
25
+ private
26
+ def validate_type
27
+ unless data.respond_to?(:to_ary)
28
+ raise InputSanitizer::TypeMismatchError.new(data, :array)
29
+ end
30
+ end
31
+
32
+ def validate_size
33
+ if collection.respond_to?(:fetch)
34
+ if collection[:minimum] && data.length < collection[:minimum]
35
+ raise InputSanitizer::CollectionLengthError.new(data.length, collection[:minimum], collection[:maximum])
36
+ elsif collection[:maximum] && data.length > collection[:maximum]
37
+ raise InputSanitizer::CollectionLengthError.new(data.length, collection[:minimum], collection[:maximum])
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,40 @@
1
+ class InputSanitizer::V2::CleanQueryCollectionField < MethodStruct.new(:data, :converter, :collection, :options)
2
+ def call
3
+ validate_type
4
+ validate_size
5
+
6
+ result, errors = [], {}
7
+ items.each_with_index do |value, idx|
8
+ begin
9
+ result << converter.call(value, options)
10
+ rescue InputSanitizer::ValidationError => e
11
+ errors[idx] = e
12
+ end
13
+ end
14
+
15
+ if errors.any?
16
+ raise InputSanitizer::CollectionError.new(errors)
17
+ else
18
+ result
19
+ end
20
+ end
21
+
22
+ private
23
+ def items
24
+ @items ||= data.to_s.split(',')
25
+ end
26
+
27
+ def validate_type
28
+ InputSanitizer::V2::Types::StringCheck.new.call(data)
29
+ end
30
+
31
+ def validate_size
32
+ if collection.respond_to?(:fetch)
33
+ if collection[:minimum] && items.length < collection[:minimum]
34
+ raise InputSanitizer::CollectionLengthError.new(items.length, collection[:minimum], collection[:maximum])
35
+ elsif collection[:maximum] && items.length > collection[:maximum]
36
+ raise InputSanitizer::CollectionLengthError.new(items.length, collection[:minimum], collection[:maximum])
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,49 @@
1
+ class InputSanitizer::V2::ErrorCollection
2
+ include Enumerable
3
+
4
+ attr_reader :error_codes, :messages
5
+
6
+ def initialize(errors)
7
+ @error_codes = {}
8
+ @messages = {}
9
+ @error_details = {}
10
+
11
+ errors.each do |error|
12
+ (@error_codes[error.field] ||= []) << error.code
13
+ (@messages[error.field] ||= []) << error.message
14
+ error_value_hash = { :value => error.value }
15
+ (@error_details[error.field] ||= []) << error_value_hash
16
+ end
17
+ end
18
+
19
+ def [](attribute)
20
+ @error_codes[attribute]
21
+ end
22
+
23
+ def each
24
+ @error_codes.each_key do |attribute|
25
+ self[attribute].each { |error| yield attribute, error }
26
+ end
27
+ end
28
+
29
+ def size
30
+ @error_codes.size
31
+ end
32
+ alias_method :length, :size
33
+ alias_method :count, :size
34
+
35
+ def empty?
36
+ @error_codes.empty?
37
+ end
38
+
39
+ def add(attribute, code = :invalid, options = {})
40
+ (@error_codes[attribute] ||= []) << code
41
+ (@messages[attribute] ||= []) << options.delete(:messages)
42
+ (@error_details[attribute] ||= []) << options
43
+ end
44
+
45
+ def to_hash
46
+ messages.dup
47
+ end
48
+ alias_method :full_messages, :to_hash
49
+ end
@@ -0,0 +1,19 @@
1
+ class InputSanitizer::V2::NestedSanitizerFactory
2
+ class NilAllowed
3
+ def cleaned
4
+ nil
5
+ end
6
+
7
+ def valid?
8
+ true
9
+ end
10
+ end
11
+
12
+ def self.for(nested_sanitizer_klass, value, options)
13
+ if value.nil? && options[:allow_nil] && !options[:collection]
14
+ NilAllowed.new
15
+ else
16
+ nested_sanitizer_klass.new(value, options)
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,130 @@
1
+ class InputSanitizer::V2::PayloadSanitizer < InputSanitizer::Sanitizer
2
+ attr_reader :validation_context
3
+
4
+ def initialize(data, validation_context = {})
5
+ super data
6
+
7
+ self.validation_context = validation_context || {}
8
+ end
9
+
10
+ def validation_context=(context)
11
+ raise ArgumentError, "validation_context must be a Hash" unless context && context.is_a?(Hash)
12
+ @validation_context = context
13
+ end
14
+
15
+ def error_collection
16
+ @error_collection ||= InputSanitizer::V2::ErrorCollection.new(errors)
17
+ end
18
+
19
+ def self.converters
20
+ {
21
+ :integer => InputSanitizer::V2::Types::IntegerCheck.new,
22
+ :float => InputSanitizer::V2::Types::FloatCheck.new,
23
+ :string => InputSanitizer::V2::Types::StringCheck.new,
24
+ :boolean => InputSanitizer::V2::Types::BooleanCheck.new,
25
+ :datetime => InputSanitizer::V2::Types::DatetimeCheck.new,
26
+ :date => InputSanitizer::V2::Types::DatetimeCheck.new(:check_date => true),
27
+ :url => InputSanitizer::V2::Types::URLCheck.new,
28
+ }
29
+ end
30
+ initialize_types_dsl
31
+
32
+ def self.nested(*keys)
33
+ options = keys.pop
34
+ sanitizer = options.delete(:sanitizer)
35
+ keys.push(options)
36
+ raise "You did not define a sanitizer for nested value" if sanitizer == nil
37
+ converter = lambda { |value, converter_options|
38
+ instance = InputSanitizer::V2::NestedSanitizerFactory.for(sanitizer, value, converter_options)
39
+ raise InputSanitizer::NestedError.new(instance.errors) unless instance.valid?
40
+ instance.cleaned
41
+ }
42
+
43
+ keys << {} unless keys.last.is_a?(Hash)
44
+ keys.last[:nested] = true
45
+
46
+ self.set_keys_to_converter(keys, converter)
47
+ end
48
+
49
+ private
50
+ def perform_clean
51
+ super
52
+ @data.reject { |key, _| self.class.fields.keys.include?(key) }.each { |key, _| @errors << InputSanitizer::ExtraneousParamError.new("/#{key}") }
53
+ end
54
+
55
+ def prepare_options!(options)
56
+ return options if @validation_context.empty?
57
+ context = @validation_context.dup
58
+ context_provided_values = context.delete(:provided)
59
+
60
+ intersection = options.keys & context.keys
61
+
62
+ unless intersection.empty?
63
+ message = "validation context and converter options have the same keys: #{intersection}. " \
64
+ "In order to proceed please fix the configuration. " \
65
+ "In the meantime aborting ..."
66
+ raise RuntimeError, message
67
+ end
68
+
69
+ if context_provided_values
70
+ options[:provided] ||= {}
71
+ options[:provided] = options[:provided].merge(context_provided_values)
72
+ end
73
+
74
+ options.merge(context)
75
+ end
76
+
77
+ def clean_field(field, hash)
78
+ options = hash[:options].clone
79
+ collection = options.delete(:collection)
80
+ default = options.delete(:default)
81
+ has_key = @data.has_key?(field)
82
+ value = @data[field]
83
+ is_nested = options.delete(:nested)
84
+
85
+ provide = options.delete(:provide)
86
+ provided = Array(provide).inject({}) { |memo, value| memo[value] = @data[value]; memo }
87
+ options[:provided] = provided
88
+
89
+ if is_nested && has_key
90
+ if collection
91
+ raise InputSanitizer::TypeMismatchError.new(value, "array") unless value.is_a?(Array)
92
+ elsif !options[:allow_nil] || (options[:allow_nil] && !value.nil?)
93
+ raise InputSanitizer::TypeMismatchError.new(value, "hash") unless value.is_a?(Hash)
94
+ end
95
+ end
96
+
97
+ @cleaned[field] = InputSanitizer::V2::CleanField.call(
98
+ :data => value,
99
+ :has_key => has_key,
100
+ :default => default,
101
+ :collection => collection,
102
+ :type => sanitizer_type,
103
+ :converter => hash[:converter],
104
+ :options => prepare_options!(options)
105
+ )
106
+ rescue InputSanitizer::OptionalValueOmitted
107
+ rescue InputSanitizer::ValidationError => error
108
+ @errors += handle_error(field, error)
109
+ end
110
+
111
+ def handle_error(field, error)
112
+ case error
113
+ when InputSanitizer::CollectionError
114
+ error.collection_errors.map do |index, error|
115
+ handle_error("#{field}/#{index}", error)
116
+ end
117
+ when InputSanitizer::NestedError
118
+ error.nested_errors.map do |error|
119
+ handle_error("#{field}#{error.field}", error)
120
+ end
121
+ else
122
+ error.field = "/#{field}"
123
+ Array(error)
124
+ end.flatten
125
+ end
126
+
127
+ def sanitizer_type
128
+ :payload
129
+ end
130
+ end
@@ -0,0 +1,42 @@
1
+ require 'active_support/core_ext/hash/indifferent_access'
2
+
3
+ class InputSanitizer::V2::PayloadTransform
4
+ attr_reader :original_payload, :context
5
+
6
+ def self.call(original_payload, context = {})
7
+ new(original_payload, context).call
8
+ end
9
+
10
+ def initialize(original_payload, context = {})
11
+ fail "#{self.class} is missing #transform method" unless respond_to?(:transform)
12
+ @original_payload, @context = original_payload, context
13
+ end
14
+
15
+ def call
16
+ transform
17
+ payload
18
+ end
19
+
20
+ private
21
+ def rename(from, to)
22
+ if has?(from)
23
+ data = payload.delete(from)
24
+ payload[to] = data
25
+ end
26
+ end
27
+
28
+ def merge_in(field, options = {})
29
+ if source = payload.delete(field)
30
+ source = options[:using].call(source) if options[:using]
31
+ payload.merge!(source)
32
+ end
33
+ end
34
+
35
+ def has?(key)
36
+ payload.has_key?(key)
37
+ end
38
+
39
+ def payload
40
+ @payload ||= original_payload.with_indifferent_access
41
+ end
42
+ end
@@ -0,0 +1,33 @@
1
+ class InputSanitizer::V2::QuerySanitizer < InputSanitizer::V2::PayloadSanitizer
2
+ def self.converters
3
+ {
4
+ :integer => InputSanitizer::V2::Types::CoercingIntegerCheck.new,
5
+ :float => InputSanitizer::V2::Types::CoercingFloatCheck.new,
6
+ :string => InputSanitizer::V2::Types::StringCheck.new,
7
+ :boolean => InputSanitizer::V2::Types::CoercingBooleanCheck.new,
8
+ :datetime => InputSanitizer::V2::Types::DatetimeCheck.new,
9
+ :date => InputSanitizer::V2::Types::DatetimeCheck.new(:check_date => true),
10
+ :url => InputSanitizer::V2::Types::URLCheck.new,
11
+ }
12
+ end
13
+ initialize_types_dsl
14
+
15
+ def self.sort_by(allowed_values, options = {})
16
+ set_keys_to_converter([:sort_by, { :allow => allowed_values }.merge(options)], InputSanitizer::V2::Types::SortByCheck.new)
17
+ end
18
+
19
+ # allow underscore cache buster by default
20
+ string :_
21
+
22
+ private
23
+ def perform_clean
24
+ super
25
+ @errors.each do |error|
26
+ error.field = error.field[1..-1] if error.field.start_with?('/')
27
+ end
28
+ end
29
+
30
+ def sanitizer_type
31
+ :query
32
+ end
33
+ end
@@ -0,0 +1,213 @@
1
+ require 'active_support/core_ext/object/blank'
2
+
3
+ module InputSanitizer::V2::Types
4
+ class IntegerCheck
5
+ def call(value, options = {})
6
+ if value == nil && (options[:allow_nil] == false || options[:allow_blank] == false || options[:required] == true)
7
+ raise InputSanitizer::BlankValueError
8
+ elsif value == nil
9
+ value
10
+ else
11
+ Integer(value).tap do |integer|
12
+ raise InputSanitizer::TypeMismatchError.new(value, :integer) unless integer == value
13
+ raise InputSanitizer::ValueError.new(value, options[:minimum], options[:maximum]) if options[:minimum] && integer < options[:minimum]
14
+ raise InputSanitizer::ValueError.new(value, options[:minimum], options[:maximum]) if options[:maximum] && integer > options[:maximum]
15
+ end
16
+ end
17
+ rescue ArgumentError, TypeError
18
+ raise InputSanitizer::TypeMismatchError.new(value, :integer)
19
+ end
20
+ end
21
+
22
+ class CoercingIntegerCheck
23
+ def call(value, options = {})
24
+ if value == nil || value == 'null'
25
+ if options[:allow_nil] == false || options[:allow_blank] == false || options[:required] == true
26
+ raise InputSanitizer::BlankValueError
27
+ else
28
+ nil
29
+ end
30
+ else
31
+ Integer(value).tap do |integer|
32
+ raise InputSanitizer::ValueError.new(value, options[:minimum], options[:maximum]) if options[:minimum] && integer < options[:minimum]
33
+ raise InputSanitizer::ValueError.new(value, options[:minimum], options[:maximum]) if options[:maximum] && integer > options[:maximum]
34
+ end
35
+ end
36
+ rescue ArgumentError
37
+ raise InputSanitizer::TypeMismatchError.new(value, :integer)
38
+ end
39
+ end
40
+
41
+ class FloatCheck
42
+ def call(value, options = {})
43
+ if value == nil && (options[:allow_nil] == false || options[:allow_blank] == false || options[:required] == true)
44
+ raise InputSanitizer::BlankValueError
45
+ elsif value == nil
46
+ value
47
+ else
48
+ Float(value).tap do |float|
49
+ raise InputSanitizer::TypeMismatchError.new(value, :float) unless float == value
50
+ raise InputSanitizer::ValueError.new(value, options[:minimum], options[:maximum]) if options[:minimum] && float < options[:minimum]
51
+ raise InputSanitizer::ValueError.new(value, options[:minimum], options[:maximum]) if options[:maximum] && float > options[:maximum]
52
+ end
53
+ end
54
+ rescue ArgumentError, TypeError
55
+ raise InputSanitizer::TypeMismatchError.new(value, :float)
56
+ end
57
+ end
58
+
59
+ class CoercingFloatCheck
60
+ def call(value, options = {})
61
+ if value == nil || value == 'null'
62
+ if options[:allow_nil] == false || options[:allow_blank] == false || options[:required] == true
63
+ raise InputSanitizer::BlankValueError
64
+ else
65
+ nil
66
+ end
67
+ else
68
+ Float(value).tap do |float|
69
+ raise InputSanitizer::ValueError.new(value, options[:minimum], options[:maximum]) if options[:minimum] && float < options[:minimum]
70
+ raise InputSanitizer::ValueError.new(value, options[:minimum], options[:maximum]) if options[:maximum] && float > options[:maximum]
71
+ end
72
+ end
73
+ rescue ArgumentError
74
+ raise InputSanitizer::TypeMismatchError.new(value, :float)
75
+ end
76
+ end
77
+
78
+ class StringCheck
79
+ def call(value, options = {})
80
+ if options[:allow] && !options[:allow].include?(value)
81
+ raise InputSanitizer::ValueNotAllowedError.new(value)
82
+ elsif value.blank? && (options[:allow_blank] == false || options[:required] == true)
83
+ raise InputSanitizer::BlankValueError
84
+ elsif options[:regexp] && options[:regexp].match(value).nil?
85
+ raise InputSanitizer::RegexpMismatchError.new
86
+ elsif value == nil && options[:allow_nil] == false
87
+ raise InputSanitizer::BlankValueError
88
+ elsif value.blank?
89
+ value
90
+ else
91
+ value.to_s.tap do |string|
92
+ raise InputSanitizer::TypeMismatchError.new(value, :string) unless string == value
93
+ raise InputSanitizer::ValueError.new(value, options[:minimum], options[:maximum]) if options[:minimum] && string.length < options[:minimum]
94
+ raise InputSanitizer::ValueError.new(value, options[:minimum], options[:maximum]) if options[:maximum] && string.length > options[:maximum]
95
+ end
96
+ end
97
+ end
98
+ end
99
+
100
+ class BooleanCheck
101
+ def call(value, options = {})
102
+ if value == nil
103
+ raise InputSanitizer::BlankValueError
104
+ elsif [true, false].include?(value)
105
+ value
106
+ else
107
+ raise InputSanitizer::TypeMismatchError.new(value, :boolean)
108
+ end
109
+ end
110
+ end
111
+
112
+ class CoercingBooleanCheck
113
+ def call(value, options = {})
114
+ if [true, 'true'].include?(value)
115
+ true
116
+ elsif [false, 'false'].include?(value)
117
+ false
118
+ else
119
+ raise InputSanitizer::TypeMismatchError.new(value, :boolean)
120
+ end
121
+ end
122
+ end
123
+
124
+ class DatetimeCheck
125
+ def initialize(options = {})
126
+ @check_date = options && options[:check_date]
127
+ @klass = @check_date ? Date : DateTime
128
+ end
129
+
130
+ def call(value, options = {})
131
+ raise InputSanitizer::TypeMismatchError.new(value, @check_date ? :date : :datetime) unless value == nil || value.is_a?(String)
132
+
133
+ if value.blank? && (options[:allow_blank] == false || options[:required] == true)
134
+ raise InputSanitizer::BlankValueError
135
+ elsif value == nil && options[:allow_nil] == false
136
+ raise InputSanitizer::BlankValueError
137
+ elsif value.blank?
138
+ value
139
+ else
140
+ @klass.parse(value).tap do |datetime|
141
+ raise InputSanitizer::ValueError.new(value, options[:minimum], options[:maximum]) if options[:minimum] && datetime < options[:minimum]
142
+ raise InputSanitizer::ValueError.new(value, options[:minimum], options[:maximum]) if options[:maximum] && datetime > options[:maximum]
143
+ end
144
+ end
145
+ rescue ArgumentError, TypeError
146
+ raise InputSanitizer::TypeMismatchError.new(value, @check_date ? :date : :datetime)
147
+ end
148
+ end
149
+
150
+ class URLCheck
151
+ def call(value, options = {})
152
+ if value.blank? && (options[:allow_blank] == false || options[:required] == true)
153
+ raise InputSanitizer::BlankValueError
154
+ elsif value == nil && options[:allow_nil] == false
155
+ raise InputSanitizer::BlankValueError
156
+ elsif value.blank?
157
+ value
158
+ else
159
+ unless /\A#{URI.regexp(%w(http https)).to_s}\z/.match(value)
160
+ raise InputSanitizer::TypeMismatchError.new(value, :url)
161
+ end
162
+ value
163
+ end
164
+ end
165
+ end
166
+
167
+ class SortByCheck
168
+ def call(value, options = {})
169
+ check_options!(options)
170
+
171
+ key, direction = split(value)
172
+ direction = 'asc' if direction.blank?
173
+
174
+ # special case when fallback takes care of separator sanitization e.g. custom fields
175
+ if options[:fallback] && !allowed_directions.include?(direction)
176
+ direction = 'asc'
177
+ key = value
178
+ end
179
+
180
+ unless valid?(key, direction, options)
181
+ raise InputSanitizer::ValueNotAllowedError.new(value)
182
+ end
183
+
184
+ [key, direction]
185
+ end
186
+
187
+ private
188
+ def valid?(key, direction, options)
189
+ allowed_keys = options[:allow]
190
+ fallback = options[:fallback]
191
+
192
+ allowed_directions.include?(direction) &&
193
+ ((allowed_keys && allowed_keys.include?(key)) ||
194
+ (fallback && fallback.call(key, direction, options)))
195
+ end
196
+
197
+ def split(value)
198
+ head, _, tail = value.to_s.rpartition(':')
199
+ head.empty? ? [tail, head] : [head, tail]
200
+ end
201
+
202
+ def check_options!(options)
203
+ fallback = options[:fallback]
204
+ if fallback && !fallback.respond_to?(:call)
205
+ raise ArgumentError, ":fallback option must respond to method :call (proc, lambda etc)"
206
+ end
207
+ end
208
+
209
+ def allowed_directions
210
+ ['asc', 'desc']
211
+ end
212
+ end
213
+ end