diff --git a/lib/fog/schema/data_validator.rb b/lib/fog/schema/data_validator.rb new file mode 100644 index 000000000..108f23972 --- /dev/null +++ b/lib/fog/schema/data_validator.rb @@ -0,0 +1,153 @@ +module Fog + module Schema + # This validates a data object against a Ruby based schema to see + # if they match. + # + # * An object matches the schema if +==+ or +===+ returns +true+ + # * Hashes match if all the key's values match the classes given + # in the schema as well. This can be configured in the options + # * Arrays match when every element in the data matches the case + # given in the schema. + # + # The schema and validation are very simple and probably not + # suitable for some cases. + # + # The following classes can be used to check for special behaviour + # + # * Fog::Boolean - value may be +true+ or +false+ + # * Fog::Nullable::Boolean - value may be +true+, +false+ or +nil+ + # * Fog::Nullable::Integer - value may be an Integer or +nil+ + # * Fog::Nullable::String + # * Fog::Nullable::Time + # * Fog::Nullable::Float + # * Fog::Nullable::Hash + # * Fog::Nullable::Array + # + # All the "nullable" objects will pass if the value is of the class + # or if it is +nil+. This allows you to match APIs that may include + # keys when the value is not available in some cases but will + # always be a String. Such as an password that is only displayed + # on the reset action. + # + # The keys for "nullable" resources should always be present but + # original matcher had a bug that allowed these to also appear to + # work as optional keys/values. + # + # If you need the original behaviour, data with a missing key is + # still valid, then you may pass the +:allow_optional_rules+ + # option to the #validate method. + # + # That is not recommended because you are describing a schema + # with optional keys in a format that does not support it. + # + # Setting +:allow_extra_keys+ as +true+ allows the data to + # contain keys not declared by the schema and still pass. This + # is useful if new attributes appear in the API in a backwards + # compatible manner and can be ignored. + # + # This is the behaviour you would have seen with +strict+ being + # +false+ in the original test helper. + # + # @example Schema example + # { + # "id" => String, + # "ram" => Integer, + # "disks" => [ + # "size" => Float + # ], + # "dns_name" => Fog::Nullable::String, + # "active" => Fog::Boolean, + # "created" => DateTime + # } + # + class DataValidator + + def initialize + @message = nil + end + + # Checks if the data structure matches the schema passed in and + # returns +true+ if it fits. + # + # @param [Object] data Hash or Array to check + # @param [Object] schema Schema pattern to check against + # @param [Boolean] options + # @option options [Boolean] :allow_extra_keys + # If +true+ does not fail if extra keys are in the data + # that are not in the schema. + # @option options [Boolean] :allow_optional_rules + # If +true+ does not fail if extra keys are in the schema + # that do not match the data. Not recommended! + # + # @return [Boolean] Did the data fit the schema? + def validate(data, schema, options = {}) + valid = validate_value(schema, data, options) + + unless valid + @message = "#{data.inspect} does not match #{schema.inspect}" + end + valid + end + + # This returns the last message set by the validator + # + # @return [String] + def message + @message + end + + private + + # This contains a slightly modified version of the Hashidator gem + # but unfortunately the gem does not cope with Array schemas. + # + # @see https://github.com/vangberg/hashidator/blob/master/lib/hashidator.rb + # + def validate_value(validator, value, options) + Fog::Logger.write :debug, "[yellow][DEBUG] #{value.inspect} against #{validator.inspect}[/]\n" + + # When being strict values not specified in the schema are fails + unless options[:allow_extra_keys] + if validator.respond_to?(:empty?) && value.respond_to?(:empty?) + # Validator is empty but values are not + return false if !value.empty? && validator.empty? + end + end + + unless options[:allow_optional_rules] + if validator.respond_to?(:empty?) && value.respond_to?(:empty?) + # Validator has rules left but no more values + return false if value.empty? && !validator.empty? + end + end + + case validator + when Array + return false if value.is_a?(Hash) + value.respond_to?(:all?) && value.all? {|x| validate_value(validator[0], x, options)} + when Symbol + value.respond_to? validator + when Hash + return false if value.is_a?(Array) + validator.all? do |key, sub_validator| + Fog::Logger.write :debug, "[blue][DEBUG] #{key.inspect} against #{sub_validator.inspect}[/]\n" + validate_value(sub_validator, value[key], options) + end + else + result = validator == value + result = validator === value unless result + # Repeat unless we have a Boolean already + unless (result.is_a?(TrueClass) || result.is_a?(FalseClass)) + result = validate_value(result, value, options) + end + if result + Fog::Logger.write :debug, "[green][DEBUG] Validation passed: #{value.inspect} against #{validator.inspect}[/]\n" + else + Fog::Logger.write :debug, "[red][DEBUG] Validation failed: #{value.inspect} against #{validator.inspect}[/]\n" + end + result + end + end + end + end +end diff --git a/tests/helpers/formats_helper.rb b/tests/helpers/formats_helper.rb index 59a2bd30c..00caa1a85 100644 --- a/tests/helpers/formats_helper.rb +++ b/tests/helpers/formats_helper.rb @@ -1,3 +1,5 @@ +require "fog/schema/data_validator" + # format related hackery # allows both true.is_a?(Fog::Boolean) and false.is_a?(Fog::Boolean) # allows both nil.is_a?(Fog::Nullable::String) and ''.is_a?(Fog::Nullable::String) @@ -35,10 +37,10 @@ module Shindo # Strict mode will fail if the data has additional keys. Setting # +strict+ to +false+ will allow additional keys to appear. # - # @param [Hash] schmea A Hash schema + # @param [Hash] schema A Hash schema # @param [Hash] options Options to change validation rules # @option options [Boolean] :allow_extra_keys - # If +true+ deoes not fail when keys are in the data that are + # If +true+ does not fail when keys are in the data that are # not specified in the schema. This allows new values to # appear in API output without breaking the check. # @option options [Boolean] :allow_optional_rules @@ -47,13 +49,13 @@ module Shindo # @yield Data to check with schema # # @example Using in a test - # Shindo.tests("data matches schema") do - # data = {:string => "Hello" } - # data_matches_schema(:string => String) { data } + # Shindo.tests("comparing welcome data against schema") do + # data = {:welcome => "Hello" } + # data_matches_schema(:welcome => String) { data } # end # - # data matches schema - # + has proper format + # comparing welcome data against schema + # + data matches schema # # @example Example schema # { @@ -70,11 +72,14 @@ module Shindo # @return [Boolean] def data_matches_schema(schema, options = {}) test('data matches schema') do - confirm_data_matches_schema(yield, schema, options) + validator = Fog::Schema::DataValidator.new + valid = validator.validate(yield, schema, options) + @message = validator.message unless valid + valid end end - # @deprecation #formats is deprecated. Use #data_matches_schema instead + # @deprecated #formats is deprecated. Use #data_matches_schema instead def formats(format, strict = true) test('has proper format') do if strict @@ -82,86 +87,10 @@ module Shindo else options = {:allow_extra_keys => true, :allow_optional_rules => true} end - confirm_data_matches_schema(yield, format, options) - end - end - - private - - # Checks if the data structure matches the schema passed in and - # returns true if it fits. - # - # @param [Object] data Hash or Array to check - # @param [Object] schema Schema pattern to check against - # @param [Boolean] options - # @option options [Boolean] :allow_extra_keys - # If +true+ does not fail if extra keys are in the data - # that are not in the schema. Allows - # @option options [Boolean] :allow_optional_rules - # If +true+ does not fail if extra keys are in the schema - # that do not match the data. Not recommended! - # - # @return [Boolean] Did the data fit the schema? - def confirm_data_matches_schema(data, schema, options = {}) - # Clear message passed to the Shindo tests - @message = nil - - valid = validate_value(schema, data, options) - - unless valid - @message = "#{data.inspect} does not match #{schema.inspect}" - end - valid - end - - # This contains a slightly modified version of the Hashidator gem - # but unfortunately the gem does not cope with Array schemas. - # - # @see https://github.com/vangberg/hashidator/blob/master/lib/hashidator.rb - # - def validate_value(validator, value, options) - Fog::Logger.write :debug, "[yellow][DEBUG] #{value.inspect} against #{validator.inspect}[/]\n" - - # When being strict values not specified in the schema are fails - unless options[:allow_extra_keys] - if validator.respond_to?(:empty?) && value.respond_to?(:empty?) - # Validator is empty but values are not - return false if !value.empty? && validator.empty? - end - end - - unless options[:allow_optional_rules] - if validator.respond_to?(:empty?) && value.respond_to?(:empty?) - # Validator has rules left but no more values - return false if value.empty? && !validator.empty? - end - end - - case validator - when Array - return false if value.is_a?(Hash) - value.respond_to?(:all?) && value.all? {|x| validate_value(validator[0], x, options)} - when Symbol - value.respond_to? validator - when Hash - return false if value.is_a?(Array) - validator.all? do |key, sub_validator| - Fog::Logger.write :debug, "[blue][DEBUG] #{key.inspect} against #{sub_validator.inspect}[/]\n" - validate_value(sub_validator, value[key], options) - end - else - result = validator == value - result = validator === value unless result - # Repeat unless we have a Boolean already - unless (result.is_a?(TrueClass) || result.is_a?(FalseClass)) - result = validate_value(result, value, options) - end - if result - Fog::Logger.write :debug, "[green][DEBUG] Validation passed: #{value.inspect} against #{validator.inspect}[/]\n" - else - Fog::Logger.write :debug, "[red][DEBUG] Validation failed: #{value.inspect} against #{validator.inspect}[/]\n" - end - result + validator = Fog::Schema::DataValidator.new + valid = validator.validate(yield, format, options) + @message = validator.message unless valid + valid end end end diff --git a/tests/helpers/formats_helper_tests.rb b/tests/helpers/formats_helper_tests.rb index 6248b251d..f62c79ed4 100644 --- a/tests/helpers/formats_helper_tests.rb +++ b/tests/helpers/formats_helper_tests.rb @@ -1,5 +1,60 @@ Shindo.tests('test_helper', 'meta') do + tests('comparing welcome data against schema') do + data = {:welcome => "Hello" } + data_matches_schema(:welcome => String) { data } + end + + tests('#data_matches_schema') do + tests('when value matches schema expectation') do + data_matches_schema({"key" => String}) { {"key" => "Value"} } + end + + tests('when values within an array all match schema expectation') do + data_matches_schema({"key" => [Integer]}) { {"key" => [1, 2]} } + end + + tests('when nested values match schema expectation') do + data_matches_schema({"key" => {:nested_key => String}}) { {"key" => {:nested_key => "Value"}} } + end + + tests('when collection of values all match schema expectation') do + data_matches_schema([{"key" => String}]) { [{"key" => "Value"}, {"key" => "Value"}] } + end + + tests('when collection is empty although schema covers optional members') do + data_matches_schema([{"key" => String}], {:allow_optional_rules => true}) { [] } + end + + tests('when additional keys are passed and not strict') do + data_matches_schema({"key" => String}, {:allow_extra_keys => true}) { {"key" => "Value", :extra => "Bonus"} } + end + + tests('when value is nil and schema expects NilClass') do + data_matches_schema({"key" => NilClass}) { {"key" => nil} } + end + + tests('when value and schema match as hashes') do + data_matches_schema({}) { {} } + end + + tests('when value and schema match as arrays') do + data_matches_schema([]) { [] } + end + + tests('when value is a Time') do + data_matches_schema({"time" => Time}) { {"time" => Time.now} } + end + + tests('when key is missing but value should be NilClass (#1477)') do + data_matches_schema({"key" => NilClass}, {:allow_optional_rules => true}) { {} } + end + + tests('when key is missing but value is nullable (#1477)') do + data_matches_schema({"key" => Fog::Nullable::String}, {:allow_optional_rules => true}) { {} } + end + end + tests('#formats backwards compatible changes') do tests('when value matches schema expectation') do @@ -52,109 +107,5 @@ Shindo.tests('test_helper', 'meta') do end - tests('data matches schema') do - data = {:welcome => "Hello" } - data_matches_schema(:welcome => String) { data } - end - - tests('#confirm_data_matches_schema') do - - tests('returns true') do - - returns(true, 'when value matches schema expectation') do - confirm_data_matches_schema({"key" => "Value"}, {"key" => String}) - end - - returns(true, 'when values within an array all match schema expectation') do - confirm_data_matches_schema({"key" => [1, 2]}, {"key" => [Integer]}) - end - - returns(true, 'when nested values match schema expectation') do - confirm_data_matches_schema({"key" => {:nested_key => "Value"}}, {"key" => {:nested_key => String}}) - end - - returns(true, 'when collection of values all match schema expectation') do - confirm_data_matches_schema([{"key" => "Value"}, {"key" => "Value"}], [{"key" => String}]) - end - - returns(true, 'when collection is empty although schema covers optional members') do - confirm_data_matches_schema([], [{"key" => String}], {:allow_optional_rules => true}) - end - - returns(true, 'when additional keys are passed and not strict') do - confirm_data_matches_schema({"key" => "Value", :extra => "Bonus"}, {"key" => String}, {:allow_extra_keys => true}) - end - - returns(true, 'when value is nil and schema expects NilClass') do - confirm_data_matches_schema({"key" => nil}, {"key" => NilClass}) - end - - returns(true, 'when value and schema match as hashes') do - confirm_data_matches_schema({}, {}) - end - - returns(true, 'when value and schema match as arrays') do - confirm_data_matches_schema([], []) - end - - returns(true, 'when value is a Time') do - confirm_data_matches_schema({"time" => Time.now}, {"time" => Time}) - end - - returns(true, 'when key is missing but value should be NilClass (#1477)') do - confirm_data_matches_schema({}, {"key" => NilClass}, {:allow_optional_rules => true}) - end - - returns(true, 'when key is missing but value is nullable (#1477)') do - confirm_data_matches_schema({}, {"key" => Fog::Nullable::String}, {:allow_optional_rules => true}) - end - - end - - tests('returns false') do - - returns(false, 'when value does not match schema expectation') do - confirm_data_matches_schema({"key" => nil}, {"key" => String}) - end - - returns(false, 'when key formats do not match') do - confirm_data_matches_schema({"key" => "Value"}, {:key => String}) - end - - returns(false, 'when additional keys are passed and strict') do - confirm_data_matches_schema({"key" => "Missing"}, {}) - end - - returns(false, 'when some keys do not appear') do - confirm_data_matches_schema({}, {"key" => String}) - end - - returns(false, 'when collection contains a member that does not match schema') do - confirm_data_matches_schema([{"key" => "Value"}, {"key" => 5}], [{"key" => String}]) - end - - returns(false, 'when hash and array are compared') do - confirm_data_matches_schema({}, []) - end - - returns(false, 'when array and hash are compared') do - confirm_data_matches_schema([], {}) - end - - returns(false, 'when a hash is expected but another data type is found') do - confirm_data_matches_schema({"key" => {:nested_key => []}}, {"key" => {:nested_key => {}}}) - end - - returns(false, 'when key is missing but value should be NilClass (#1477)') do - confirm_data_matches_schema({}, {"key" => NilClass}, {:allow_optional_rules => false}) - end - - returns(false, 'when key is missing but value is nullable (#1477)') do - confirm_data_matches_schema({}, {"key" => Fog::Nullable::String}, {:allow_optional_rules => false}) - end - - end - - end end diff --git a/tests/helpers/schema_validator_tests.rb b/tests/helpers/schema_validator_tests.rb new file mode 100644 index 000000000..d19e69fb9 --- /dev/null +++ b/tests/helpers/schema_validator_tests.rb @@ -0,0 +1,103 @@ +Shindo.tests('Fog::Schema::DataValidator', 'meta') do + + validator = Fog::Schema::DataValidator.new + + tests('#validate') do + + tests('returns true') do + + returns(true, 'when value matches schema expectation') do + validator.validate({"key" => "Value"}, {"key" => String}) + end + + returns(true, 'when values within an array all match schema expectation') do + validator.validate({"key" => [1, 2]}, {"key" => [Integer]}) + end + + returns(true, 'when nested values match schema expectation') do + validator.validate({"key" => {:nested_key => "Value"}}, {"key" => {:nested_key => String}}) + end + + returns(true, 'when collection of values all match schema expectation') do + validator.validate([{"key" => "Value"}, {"key" => "Value"}], [{"key" => String}]) + end + + returns(true, 'when collection is empty although schema covers optional members') do + validator.validate([], [{"key" => String}], {:allow_optional_rules => true}) + end + + returns(true, 'when additional keys are passed and not strict') do + validator.validate({"key" => "Value", :extra => "Bonus"}, {"key" => String}, {:allow_extra_keys => true}) + end + + returns(true, 'when value is nil and schema expects NilClass') do + validator.validate({"key" => nil}, {"key" => NilClass}) + end + + returns(true, 'when value and schema match as hashes') do + validator.validate({}, {}) + end + + returns(true, 'when value and schema match as arrays') do + validator.validate([], []) + end + + returns(true, 'when value is a Time') do + validator.validate({"time" => Time.now}, {"time" => Time}) + end + + returns(true, 'when key is missing but value should be NilClass (#1477)') do + validator.validate({}, {"key" => NilClass}, {:allow_optional_rules => true}) + end + + returns(true, 'when key is missing but value is nullable (#1477)') do + validator.validate({}, {"key" => Fog::Nullable::String}, {:allow_optional_rules => true}) + end + + end + + tests('returns false') do + + returns(false, 'when value does not match schema expectation') do + validator.validate({"key" => nil}, {"key" => String}) + end + + returns(false, 'when key formats do not match') do + validator.validate({"key" => "Value"}, {:key => String}) + end + + returns(false, 'when additional keys are passed and strict') do + validator.validate({"key" => "Missing"}, {}) + end + + returns(false, 'when some keys do not appear') do + validator.validate({}, {"key" => String}) + end + + returns(false, 'when collection contains a member that does not match schema') do + validator.validate([{"key" => "Value"}, {"key" => 5}], [{"key" => String}]) + end + + returns(false, 'when hash and array are compared') do + validator.validate({}, []) + end + + returns(false, 'when array and hash are compared') do + validator.validate([], {}) + end + + returns(false, 'when a hash is expected but another data type is found') do + validator.validate({"key" => {:nested_key => []}}, {"key" => {:nested_key => {}}}) + end + + returns(false, 'when key is missing but value should be NilClass (#1477)') do + validator.validate({}, {"key" => NilClass}, {:allow_optional_rules => false}) + end + + returns(false, 'when key is missing but value is nullable (#1477)') do + validator.validate({}, {"key" => Fog::Nullable::String}, {:allow_optional_rules => false}) + end + + end + end +end