input_sanitizer 0.1.10 → 0.4.1

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