input_sanitizer 0.2.2 → 0.3.33

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 (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