input_sanitizer 0.1.10 → 0.4.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 (49) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/ci.yaml +26 -0
  3. data/.github/workflows/gempush.yml +28 -0
  4. data/.gitignore +2 -1
  5. data/CHANGELOG +99 -0
  6. data/LICENSE +201 -22
  7. data/README.md +24 -4
  8. data/input_sanitizer.gemspec +10 -4
  9. data/lib/input_sanitizer.rb +5 -2
  10. data/lib/input_sanitizer/errors.rb +142 -0
  11. data/lib/input_sanitizer/extended_converters.rb +5 -42
  12. data/lib/input_sanitizer/extended_converters/comma_joined_integers_converter.rb +15 -0
  13. data/lib/input_sanitizer/extended_converters/comma_joined_strings_converter.rb +15 -0
  14. data/lib/input_sanitizer/extended_converters/positive_integer_converter.rb +12 -0
  15. data/lib/input_sanitizer/extended_converters/specific_values_converter.rb +19 -0
  16. data/lib/input_sanitizer/restricted_hash.rb +49 -8
  17. data/lib/input_sanitizer/v1.rb +22 -0
  18. data/lib/input_sanitizer/v1/clean_field.rb +38 -0
  19. data/lib/input_sanitizer/{default_converters.rb → v1/default_converters.rb} +20 -13
  20. data/lib/input_sanitizer/v1/sanitizer.rb +166 -0
  21. data/lib/input_sanitizer/v2.rb +13 -0
  22. data/lib/input_sanitizer/v2/clean_field.rb +36 -0
  23. data/lib/input_sanitizer/v2/clean_payload_collection_field.rb +41 -0
  24. data/lib/input_sanitizer/v2/clean_query_collection_field.rb +40 -0
  25. data/lib/input_sanitizer/v2/error_collection.rb +49 -0
  26. data/lib/input_sanitizer/v2/nested_sanitizer_factory.rb +19 -0
  27. data/lib/input_sanitizer/v2/payload_sanitizer.rb +130 -0
  28. data/lib/input_sanitizer/v2/payload_transform.rb +42 -0
  29. data/lib/input_sanitizer/v2/query_sanitizer.rb +33 -0
  30. data/lib/input_sanitizer/v2/types.rb +227 -0
  31. data/lib/input_sanitizer/version.rb +1 -1
  32. data/spec/extended_converters/comma_joined_integers_converter_spec.rb +18 -0
  33. data/spec/extended_converters/comma_joined_strings_converter_spec.rb +18 -0
  34. data/spec/extended_converters/positive_integer_converter_spec.rb +18 -0
  35. data/spec/extended_converters/specific_values_converter_spec.rb +27 -0
  36. data/spec/restricted_hash_spec.rb +37 -7
  37. data/spec/sanitizer_spec.rb +129 -26
  38. data/spec/spec_helper.rb +17 -2
  39. data/spec/v1/default_converters_spec.rb +141 -0
  40. data/spec/v2/converters_spec.rb +174 -0
  41. data/spec/v2/payload_sanitizer_spec.rb +534 -0
  42. data/spec/v2/payload_transform_spec.rb +98 -0
  43. data/spec/v2/query_sanitizer_spec.rb +300 -0
  44. data/v2.md +52 -0
  45. metadata +105 -40
  46. data/.travis.yml +0 -12
  47. data/lib/input_sanitizer/sanitizer.rb +0 -140
  48. data/spec/default_converters_spec.rb +0 -101
  49. data/spec/extended_converters_spec.rb +0 -62
@@ -0,0 +1,166 @@
1
+ class InputSanitizer::V1::Sanitizer
2
+ def initialize(data)
3
+ @data = symbolize_keys(data)
4
+ @performed = false
5
+ @errors = []
6
+ @cleaned = InputSanitizer::RestrictedHash.new(self.class.fields.keys)
7
+ end
8
+
9
+ def self.clean(data)
10
+ new(data).cleaned
11
+ end
12
+
13
+ def [](field)
14
+ cleaned[field]
15
+ end
16
+
17
+ def cleaned
18
+ return @cleaned if @performed
19
+
20
+ perform_clean
21
+
22
+ @performed = true
23
+ @cleaned.freeze
24
+ end
25
+
26
+ def valid?
27
+ cleaned
28
+ @errors.empty?
29
+ end
30
+
31
+ def errors
32
+ cleaned
33
+ @errors
34
+ end
35
+
36
+ def self.converters
37
+ {
38
+ :integer => InputSanitizer::V1::IntegerConverter.new,
39
+ :string => InputSanitizer::V1::StringConverter.new,
40
+ :date => InputSanitizer::V1::DateConverter.new,
41
+ :time => InputSanitizer::V1::TimeConverter.new,
42
+ :boolean => InputSanitizer::V1::BooleanConverter.new,
43
+ :integer_or_blank => InputSanitizer::V1::IntegerConverter.new.extend(InputSanitizer::V1::AllowNil),
44
+ :string_or_blank => InputSanitizer::V1::StringConverter.new.extend(InputSanitizer::V1::AllowNil),
45
+ :date_or_blank => InputSanitizer::V1::DateConverter.new.extend(InputSanitizer::V1::AllowNil),
46
+ :time_or_blank => InputSanitizer::V1::TimeConverter.new.extend(InputSanitizer::V1::AllowNil),
47
+ :boolean_or_blank => InputSanitizer::V1::BooleanConverter.new.extend(InputSanitizer::V1::AllowNil),
48
+ }
49
+ end
50
+
51
+ def self.inherited(subclass)
52
+ subclass.fields = self.fields.dup
53
+ end
54
+
55
+ def self.initialize_types_dsl
56
+ converters.keys.each do |name|
57
+ class_eval <<-END
58
+ def self.#{name}(*keys)
59
+ set_keys_to_converter(keys, :#{name})
60
+ end
61
+ END
62
+ end
63
+ end
64
+
65
+ initialize_types_dsl
66
+
67
+ def self.custom(*keys)
68
+ options = keys.pop
69
+ converter = options.delete(:converter)
70
+ keys.push(options)
71
+ raise "You did not define a converter for a custom type" if converter == nil
72
+ self.set_keys_to_converter(keys, converter)
73
+ end
74
+
75
+ def self.nested(*keys)
76
+ options = keys.pop
77
+ sanitizer = options.delete(:sanitizer)
78
+ keys.push(options)
79
+ raise "You did not define a sanitizer for nested value" if sanitizer == nil
80
+ converter = lambda { |value|
81
+ instance = sanitizer.new(value)
82
+ raise InputSanitizer::ConversionError.new(instance.errors) unless instance.valid?
83
+ instance.cleaned
84
+ }
85
+
86
+ keys << {} unless keys.last.is_a?(Hash)
87
+ keys.last[:nested] = true
88
+
89
+ self.set_keys_to_converter(keys, converter)
90
+ end
91
+
92
+ protected
93
+ def self.fields
94
+ @fields ||= {}
95
+ end
96
+
97
+ def self.fields=(new_fields)
98
+ @fields = new_fields
99
+ end
100
+
101
+ private
102
+ def self.extract_options!(array)
103
+ array.last.is_a?(Hash) ? array.pop : {}
104
+ end
105
+
106
+ def perform_clean
107
+ self.class.fields.each { |field, hash| clean_field(field, hash) }
108
+ end
109
+
110
+ def clean_field(field, hash)
111
+ if hash[:options][:nested] && @data.has_key?(field)
112
+ if hash[:options][:collection]
113
+ raise InputSanitizer::ConversionError.new("expected an array") unless @data[field].is_a?(Array)
114
+ else
115
+ raise InputSanitizer::ConversionError.new("expected a hash") unless @data[field].is_a?(Hash)
116
+ end
117
+ end
118
+
119
+ @cleaned[field] = InputSanitizer::V1::CleanField.call(hash[:options].merge({
120
+ :has_key => @data.has_key?(field),
121
+ :data => @data[field],
122
+ :converter => hash[:converter],
123
+ :provide => @data[hash[:options][:provide]],
124
+ }))
125
+ rescue InputSanitizer::ConversionError => error
126
+ add_error(field, :invalid_value, @data[field], error.message)
127
+ rescue InputSanitizer::ValueMissingError => error
128
+ add_error(field, :missing, nil, nil)
129
+ rescue InputSanitizer::OptionalValueOmitted
130
+ end
131
+
132
+ def add_error(field, error_type, value, description = nil)
133
+ @errors << {
134
+ :field => field,
135
+ :type => error_type,
136
+ :value => value,
137
+ :description => description
138
+ }
139
+ end
140
+
141
+ def symbolize_keys(data)
142
+ symbolized_hash = {}
143
+
144
+ data.each do |key, value|
145
+ symbolized_hash[key.to_sym] = value
146
+ end
147
+
148
+ symbolized_hash
149
+ end
150
+
151
+ def self.set_keys_to_converter(keys, converter_or_type)
152
+ options = extract_options!(keys)
153
+ converter = if converter_or_type.is_a?(Symbol)
154
+ converters[converter_or_type]
155
+ else
156
+ converter_or_type
157
+ end
158
+
159
+ keys.each do |key|
160
+ fields[key] = {
161
+ :converter => converter,
162
+ :options => options
163
+ }
164
+ end
165
+ end
166
+ end
@@ -0,0 +1,13 @@
1
+ module InputSanitizer::V2
2
+ end
3
+
4
+ dir = File.dirname(__FILE__)
5
+ require File.join(dir, 'v2/types')
6
+ require File.join(dir, 'v2/clean_payload_collection_field')
7
+ require File.join(dir, 'v2/clean_query_collection_field')
8
+ require File.join(dir, 'v2/clean_field')
9
+ require File.join(dir, 'v2/nested_sanitizer_factory')
10
+ require File.join(dir, 'v2/error_collection')
11
+ require File.join(dir, 'v2/payload_sanitizer')
12
+ require File.join(dir, 'v2/query_sanitizer')
13
+ require File.join(dir, 'v2/payload_transform')
@@ -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