diff --git a/lib/fog/schema/data_validator.rb b/lib/fog/schema/data_validator.rb new file mode 100644 index 000000000..68a7d7a77 --- /dev/null +++ b/lib/fog/schema/data_validator.rb @@ -0,0 +1,154 @@ +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" + + 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) + + # When being strict values not specified in the schema are fails + unless options[:allow_extra_keys] + if 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 value.respond_to?(:empty?) + # Validator has rules left but no more values + return false if value.empty? && !validator.empty? + end + end + + 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/aws/requests/compute/image_tests.rb b/tests/aws/requests/compute/image_tests.rb index 1543151ca..1fc6998d9 100644 --- a/tests/aws/requests/compute/image_tests.rb +++ b/tests/aws/requests/compute/image_tests.rb @@ -2,7 +2,7 @@ Shindo.tests('Fog::Compute[:aws] | image requests', ['aws']) do @describe_images_format = { 'imagesSet' => [{ 'architecture' => String, - 'blockDeviceMapping' => [], + 'blockDeviceMapping' => [Fog::Nullable::Hash], 'description' => Fog::Nullable::String, 'hypervisor' => String, 'imageId' => String, @@ -135,4 +135,4 @@ Shindo.tests('Fog::Compute[:aws] | image requests', ['aws']) do Fog::Compute[:aws].modify_image_attribute('ami-00000000', { 'Add.UserId' => ['123456789012'] }).body end end -end \ No newline at end of file +end diff --git a/tests/aws/requests/compute/security_group_tests.rb b/tests/aws/requests/compute/security_group_tests.rb index 40d062e91..ceea99e36 100644 --- a/tests/aws/requests/compute/security_group_tests.rb +++ b/tests/aws/requests/compute/security_group_tests.rb @@ -15,7 +15,7 @@ Shindo.tests('Fog::Compute[:aws] | security group requests', ['aws']) do 'fromPort' => Fog::Nullable::Integer, 'groups' => [{ 'groupName' => Fog::Nullable::String, 'userId' => String, 'groupId' => String }], 'ipProtocol' => String, - 'ipRanges' => [], + 'ipRanges' => [Fog::Nullable::Hash], 'toPort' => Fog::Nullable::Integer, }], 'ipPermissionsEgress' => [], diff --git a/tests/helpers/formats_helper.rb b/tests/helpers/formats_helper.rb index 00c3067bf..3d5d39826 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) @@ -25,62 +27,73 @@ end module Shindo class Tests - def formats(format, strict=true) - raise ArgumentError, 'format is nil' unless format + # Generates a Shindo test that compares a hash schema to the result + # of the passed in block returning true if they match. + # + # The schema that is passed in is a Hash or Array of hashes that + # have Classes in place of values. When checking the schema the + # value should match the Class. + # + # Strict mode will fail if the data has additional keys. Setting + # +strict+ to +false+ will allow additional keys to appear. + # + # @param [Hash] schema A Hash schema + # @param [Hash] options Options to change validation rules + # @option options [Boolean] :allow_extra_keys + # 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 + # If +true+ does not fail if extra keys are in the schema + # that do not match the data. Not recommended! + # @yield Data to check with schema + # + # @example Using in a test + # Shindo.tests("comparing welcome data against schema") do + # data = {:welcome => "Hello" } + # data_matches_schema(:welcome => String) { data } + # end + # + # comparing welcome data against schema + # + data matches schema + # + # @example Example schema + # { + # "id" => String, + # "ram" => Integer, + # "disks" => [ + # { + # "size" => Float + # } + # ], + # "dns_name" => Fog::Nullable::String, + # "active" => Fog::Boolean, + # "created" => DateTime + # } + # + # @return [Boolean] + def data_matches_schema(schema, options = {}) + test('data matches schema') do + validator = Fog::Schema::DataValidator.new + valid = validator.validate(yield, schema, options) + @message = validator.message unless valid + valid + end + end + # @deprecated #formats is deprecated. Use #data_matches_schema instead + def formats(format, strict = true) test('has proper format') do - formats_kernel(instance_eval(&Proc.new), format, true, strict) - end - end - - private - - def formats_kernel(original_data, original_format, original = true, strict = true) - valid = true - data = original_data.dup - format = original_format.dup - if format.is_a?(Array) - data = {:element => data} - format = {:element => format} - end - for key, value in format - datum = data.delete(key) - format.delete(key) - case value - when Array - p("#{key.inspect} not Array: #{datum.inspect}") unless datum.is_a?(Array) - valid &&= datum.is_a?(Array) - if datum.is_a?(Array) && !value.empty? - for element in datum - type = value.first - if type.is_a?(Hash) - valid &&= formats_kernel({:element => element}, {:element => type}, false, strict) - else - valid &&= element.is_a?(type) - end - end - end - when Hash - p("#{key.inspect} not Hash: #{datum.inspect}") unless datum.is_a?(Hash) - valid &&= datum.is_a?(Hash) - valid &&= formats_kernel(datum, value, false, strict) + if strict + options = {:allow_extra_keys => false, :allow_optional_rules => true} else - p "#{key.inspect} not #{value.inspect}: #{datum.inspect}" unless datum.is_a?(value) - valid &&= datum.is_a?(value) + options = {:allow_extra_keys => true, :allow_optional_rules => true} end + validator = Fog::Schema::DataValidator.new + valid = validator.validate(yield, format, options) + @message = validator.message unless valid + valid end - p data unless data.empty? - p format unless format.empty? - if strict - valid &&= data.empty? && format.empty? - else - valid &&= format.empty? - end - if !valid && original - @message = "#{original_data.inspect} does not match #{original_format.inspect}" - end - valid end - end end diff --git a/tests/helpers/formats_helper_tests.rb b/tests/helpers/formats_helper_tests.rb index 8bd857c73..f62c79ed4 100644 --- a/tests/helpers/formats_helper_tests.rb +++ b/tests/helpers/formats_helper_tests.rb @@ -1,60 +1,111 @@ Shindo.tests('test_helper', 'meta') do - tests('#formats_kernel') do - - tests('returns true') do - - test('when format of value matches') do - formats_kernel({:a => :b}, {:a => Symbol}) - end - - test('when format of nested array elements matches') do - formats_kernel({:a => [:b, :c]}, {:a => [Symbol]}) - end - - test('when format of nested hash matches') do - formats_kernel({:a => {:b => :c}}, {:a => {:b => Symbol}}) - end - - test('when format of an array') do - formats_kernel([{:a => :b}], [{:a => Symbol}]) - end - - test('non strict extra data') do - formats_kernel({:a => :b, :b => :c}, {:a => Symbol}, true, false) - end + 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('returns false') do + tests('when values within an array all match schema expectation') do + data_matches_schema({"key" => [Integer]}) { {"key" => [1, 2]} } + end - test('when format of value does not match') do - !formats_kernel({:a => :b}, {:a => String}) - end + tests('when nested values match schema expectation') do + data_matches_schema({"key" => {:nested_key => String}}) { {"key" => {:nested_key => "Value"}} } + end - test('when not all keys are checked') do - !formats_kernel({:a => :b}, {}) - end + tests('when collection of values all match schema expectation') do + data_matches_schema([{"key" => String}]) { [{"key" => "Value"}, {"key" => "Value"}] } + end - test('when some keys do not appear') do - !formats_kernel({}, {:a => String}) - end + tests('when collection is empty although schema covers optional members') do + data_matches_schema([{"key" => String}], {:allow_optional_rules => true}) { [] } + end - test('when an array is expected but another data type is found') do - !formats_kernel({:a => 'not an array'}, {:a => []}) - 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 - test('when a hash is expected but another data type is found') do - !formats_kernel({:a => 'not a hash'}, {:a => {}}, true, false) - 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 - test('non strict extra data') do - !formats_kernel({:a => :b, :b => :c}, {:z => Symbol}, true, false) - 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 + formats({"key" => String}) { {"key" => "Value"} } + end + + tests('when values within an array all match schema expectation') do + formats({"key" => [Integer]}) { {"key" => [1, 2]} } + end + + tests('when nested values match schema expectation') do + formats({"key" => {:nested_key => String}}) { {"key" => {:nested_key => "Value"}} } + end + + tests('when collection of values all match schema expectation') do + formats([{"key" => String}]) { [{"key" => "Value"}, {"key" => "Value"}] } + end + + tests('when collection is empty although schema covers optional members') do + formats([{"key" => String}]) { [] } + end + + tests('when additional keys are passed and not strict') do + formats({"key" => String}, false) { {"key" => "Value", :extra => "Bonus"} } + end + + tests('when value is nil and schema expects NilClass') do + formats({"key" => NilClass}) { {"key" => nil} } + end + + tests('when value and schema match as hashes') do + formats({}) { {} } + end + + tests('when value and schema match as arrays') do + formats([]) { [] } + end + + tests('when value is a Time') do + formats({"time" => Time}) { {"time" => Time.now} } + end + + tests('when key is missing but value should be NilClass (#1477)') do + formats({"key" => NilClass}) { {} } + end + + tests('when key is missing but value is nullable (#1477)') do + formats({"key" => Fog::Nullable::String}) { {} } 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..8715af391 --- /dev/null +++ b/tests/helpers/schema_validator_tests.rb @@ -0,0 +1,107 @@ +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}]) + 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 collection has multiple schema patterns') do + validator.validate([{"key" => "Value"}], [{"key" => Integer}, {"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 diff --git a/tests/openstack/requests/identity/role_tests.rb b/tests/openstack/requests/identity/role_tests.rb index 8124c7e44..6bf25ab2d 100644 --- a/tests/openstack/requests/identity/role_tests.rb +++ b/tests/openstack/requests/identity/role_tests.rb @@ -27,7 +27,13 @@ Shindo.tests('Fog::Identity[:openstack] | role requests', ['openstack']) do Fog::Identity[:openstack].list_roles_for_user_on_tenant(@tenant['id'], @user['id']).body['roles'] end + tests("#delete_user_role with tenant").returns("") do + Fog::Identity[:openstack].delete_user_role(@tenant['id'], @user['id'], @role['id']).body + end + tests("#delete_user_role with tenant").formats(@role_format) do + # FIXME - Response (under mocks) is empty String which does not match schema + pending Fog::Identity[:openstack].delete_user_role(@tenant['id'], @user['id'], @role['id']).body end