diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 4a8b7b4..6eebee5 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,20 +1,45 @@ name: Ruby -on: [push,pull_request] +on: [push, pull_request] jobs: - build: + test: runs-on: ubuntu-latest + name: "rspec — Ruby ${{ matrix.ruby }} / ${{ matrix.gemfile }}" + strategy: + fail-fast: false + matrix: + # Covers the supported range from the floor (Ruby 3.2 / Rails 6.1) to + # the current ceiling (Ruby 3.4 / Rails 8.1). Curated pairs avoid + # Ruby/Rails combinations that are not mutually supported. + include: + - { ruby: "3.2", gemfile: rails_6_1 } + - { ruby: "3.2", gemfile: rails_7_1 } + - { ruby: "3.3", gemfile: rails_7_2 } + - { ruby: "3.3", gemfile: rails_8_0 } + - { ruby: "3.4", gemfile: rails_8_0 } + - { ruby: "3.4", gemfile: rails_8_1 } + env: + BUNDLE_GEMFILE: gemfiles/${{ matrix.gemfile }}.gemfile steps: - - uses: actions/checkout@v4 - - name: Set up Ruby - uses: ruby/setup-ruby@v1 - with: - ruby-version: 3.2.2 - bundler: 4.0.4 - - name: Bundle gems - run: bundle install - - name: Run rspec - run: bundle exec rspec - - name: Run rubocop - run: bundle exec rubocop + - uses: actions/checkout@v4 + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + bundler-cache: true + - name: Run rspec + run: bundle exec rspec + + lint: + runs-on: ubuntu-latest + name: rubocop + steps: + - uses: actions/checkout@v4 + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.2" + bundler-cache: true + - name: Run rubocop + run: bundle exec rubocop diff --git a/.gitignore b/.gitignore index b1877d2..900724a 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,6 @@ # built gem files *.gem + +# per-appraisal lockfiles (resolved fresh in CI) +gemfiles/*.gemfile.lock diff --git a/.rubocop.yml b/.rubocop.yml index 62233d9..4532e5d 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,9 +1,18 @@ +# Append to (rather than replace) RuboCop's default AllCops/Exclude, so the +# defaults such as vendor/**/* stay excluded. Without this, CI installs gems +# into vendor/bundle (bundler-cache) and RuboCop would lint third-party gems. +inherit_mode: + merge: + - Exclude + AllCops: TargetRubyVersion: 3.2 NewCops: enable SuggestExtensions: false Exclude: - "README.md" + - "vendor/**/*" + - "gemfiles/vendor/**/*" Style/StringLiterals: Enabled: true @@ -20,24 +29,6 @@ Metrics/BlockLength: Exclude: - "spec/**/*_spec.rb" -Metrics/MethodLength: - Exclude: - - "lib/rspec/json_api/compare_hash.rb" - - "lib/extensions/hash.rb" - -Metrics/AbcSize: - Exclude: - - "lib/rspec/json_api/compare_hash.rb" - - "lib/rspec/json_api/matchers/match_json_schema.rb" - -Metrics/CyclomaticComplexity: - Exclude: - - "lib/rspec/json_api/compare_hash.rb" - -Metrics/PerceivedComplexity: - Exclude: - - "lib/rspec/json_api/compare_hash.rb" - Style/Documentation: Enabled: false @@ -47,4 +38,4 @@ Naming/PredicatePrefix: Naming/PredicateMethod: Exclude: - - "lib/rspec/json_api/compare_hash.rb" + - "lib/rspec/json_api/schema_match.rb" diff --git a/CHANGELOG.md b/CHANGELOG.md index ce13bd0..f6a7210 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,32 @@ ## [Unreleased] +## [1.5.0] - 2026-06-05 + +### Added +- CI compatibility matrix across Ruby 3.2–3.4 and Rails 6.1, 7.1, 7.2, 8.0 and 8.1 (`gemfiles/` + GitHub Actions matrix), so the advertised version support is actually tested. +- `RSpec::JsonApi::Constraints` module encapsulating the schema `Proc` options DSL. +- `RSpec::JsonApi::SchemaMatch` as the single comparison entry point, and `RSpec::JsonApi::Traversal` for the internal structural helpers. + +### Changed +- Depend on `railties` instead of the full `rails` meta-gem; the gem only uses ActiveSupport core extensions and `Rails::Generators`. Drops the dependency graph from 87 to 65 gems. The `>= 6.1.4.1` floor is unchanged. +- Unified array and hash comparison behind `SchemaMatch`; removed the duplicate `CompareArray` and `CompareHash` modules. +- Stopped monkey-patching core `Hash`/`Array`; `deep_keys`, `deep_key_paths`, `deep_sort` and `sanitize!` moved into the internal `Traversal` module. +- The failure diff is now built lazily, only when a match fails. +- An unsupported schema `Proc` option now raises `ArgumentError` instead of being silently ignored. +- Generators emit `# frozen_string_literal: true` and freeze the generated interface hash. + +### Fixed +- Require `uri` so the built-in types load on Ruby 4.0 (previously raised `NameError` on load). +- Anchor the `UUID` type with `\A...\z` so multiline strings no longer pass validation. +- Rescue invalid JSON in `match_json_schema` instead of raising `JSON::ParserError`. +- Avoid a `TypeError` when a schema expects a nested object but the actual value is a scalar. +- Enforce key structure for interface-array elements; an element with an extra null-valued key no longer matches. +- Reset the memoized diff at the start of `matches?` so a reused matcher reflects the current actual value. +- Use `Regexp#match?` for regex comparison so it returns a Boolean instead of a match index. + +### Removed +- Dead/broken `interface.erb` generator template. + ## [1.4.0] - 2026-01-23 ### Changed diff --git a/Gemfile.lock b/Gemfile.lock index 52cba3a..bfbf2fa 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,37 +1,15 @@ PATH remote: . specs: - rspec-json_api (1.4.0) + rspec-json_api (1.5.0) activesupport (>= 6.1.4.1) diffy (>= 3.4.2) - rails (>= 6.1.4.1) + railties (>= 6.1.4.1) rspec-rails (>= 5.0.2) GEM remote: https://rubygems.org/ specs: - action_text-trix (2.1.16) - railties - actioncable (8.1.2) - actionpack (= 8.1.2) - activesupport (= 8.1.2) - nio4r (~> 2.0) - websocket-driver (>= 0.6.1) - zeitwerk (~> 2.6) - actionmailbox (8.1.2) - actionpack (= 8.1.2) - activejob (= 8.1.2) - activerecord (= 8.1.2) - activestorage (= 8.1.2) - activesupport (= 8.1.2) - mail (>= 2.8.0) - actionmailer (8.1.2) - actionpack (= 8.1.2) - actionview (= 8.1.2) - activejob (= 8.1.2) - activesupport (= 8.1.2) - mail (>= 2.8.0) - rails-dom-testing (~> 2.2) actionpack (8.1.2) actionview (= 8.1.2) activesupport (= 8.1.2) @@ -42,35 +20,12 @@ GEM rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) useragent (~> 0.16) - actiontext (8.1.2) - action_text-trix (~> 2.1.15) - actionpack (= 8.1.2) - activerecord (= 8.1.2) - activestorage (= 8.1.2) - activesupport (= 8.1.2) - globalid (>= 0.6.0) - nokogiri (>= 1.8.5) actionview (8.1.2) activesupport (= 8.1.2) builder (~> 3.1) erubi (~> 1.11) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) - activejob (8.1.2) - activesupport (= 8.1.2) - globalid (>= 0.3.6) - activemodel (8.1.2) - activesupport (= 8.1.2) - activerecord (8.1.2) - activemodel (= 8.1.2) - activesupport (= 8.1.2) - timeout (>= 0.4.0) - activestorage (8.1.2) - actionpack (= 8.1.2) - activejob (= 8.1.2) - activerecord (= 8.1.2) - activesupport (= 8.1.2) - marcel (~> 1.0) activesupport (8.1.2) base64 bigdecimal @@ -97,8 +52,6 @@ GEM drb (2.2.3) erb (6.0.1) erubi (1.13.1) - globalid (1.3.0) - activesupport (>= 6.1) i18n (1.14.8) concurrent-ruby (~> 1.0) io-console (0.8.2) @@ -113,30 +66,14 @@ GEM loofah (2.25.0) crass (~> 1.0.2) nokogiri (>= 1.12.0) - mail (2.9.0) - logger - mini_mime (>= 0.1.1) - net-imap - net-pop - net-smtp - marcel (1.1.0) - mini_mime (1.1.5) minitest (6.0.1) prism (~> 1.5) - net-imap (0.6.2) - date - net-protocol - net-pop (0.1.2) - net-protocol - net-protocol (0.2.2) - timeout - net-smtp (0.5.1) - net-protocol - nio4r (2.7.5) nokogiri (1.19.0-arm64-darwin) racc (~> 1.4) nokogiri (1.19.0-x86_64-darwin) racc (~> 1.4) + nokogiri (1.19.0-x86_64-linux-gnu) + racc (~> 1.4) parallel (1.27.0) parser (3.3.10.1) ast (~> 2.4.1) @@ -157,20 +94,6 @@ GEM rack (>= 1.3) rackup (2.3.1) rack (>= 3) - rails (8.1.2) - actioncable (= 8.1.2) - actionmailbox (= 8.1.2) - actionmailer (= 8.1.2) - actionpack (= 8.1.2) - actiontext (= 8.1.2) - actionview (= 8.1.2) - activejob (= 8.1.2) - activemodel (= 8.1.2) - activerecord (= 8.1.2) - activestorage (= 8.1.2) - activesupport (= 8.1.2) - bundler (>= 1.15.0) - railties (= 8.1.2) rails-dom-testing (2.3.0) activesupport (>= 5.0.0) minitest @@ -231,7 +154,6 @@ GEM securerandom (0.4.1) stringio (3.2.0) thor (1.5.0) - timeout (0.6.0) tsort (0.2.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) @@ -240,15 +162,13 @@ GEM unicode-emoji (4.2.0) uri (1.1.1) useragent (0.16.11) - websocket-driver (0.8.0) - base64 - websocket-extensions (>= 0.1.0) - websocket-extensions (0.1.5) zeitwerk (2.7.4) PLATFORMS arm64-darwin-22 + arm64-darwin-25 x86_64-darwin-20 + x86_64-linux DEPENDENCIES activesupport (>= 6.1.4.1) diff --git a/gemfiles/rails_6_1.gemfile b/gemfiles/rails_6_1.gemfile new file mode 100644 index 0000000..3d9827d --- /dev/null +++ b/gemfiles/rails_6_1.gemfile @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +# Inherits the dev dependencies from the root Gemfile and pins the Rails line +# under test. Generated/maintained for the CI compatibility matrix. +eval_gemfile File.expand_path("../Gemfile", __dir__) + +gem "rails", "~> 6.1.0" diff --git a/gemfiles/rails_7_1.gemfile b/gemfiles/rails_7_1.gemfile new file mode 100644 index 0000000..67c9551 --- /dev/null +++ b/gemfiles/rails_7_1.gemfile @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +# Inherits the dev dependencies from the root Gemfile and pins the Rails line +# under test. Generated/maintained for the CI compatibility matrix. +eval_gemfile File.expand_path("../Gemfile", __dir__) + +gem "rails", "~> 7.1.0" diff --git a/gemfiles/rails_7_2.gemfile b/gemfiles/rails_7_2.gemfile new file mode 100644 index 0000000..7380461 --- /dev/null +++ b/gemfiles/rails_7_2.gemfile @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +# Inherits the dev dependencies from the root Gemfile and pins the Rails line +# under test. Generated/maintained for the CI compatibility matrix. +eval_gemfile File.expand_path("../Gemfile", __dir__) + +gem "rails", "~> 7.2.0" diff --git a/gemfiles/rails_8_0.gemfile b/gemfiles/rails_8_0.gemfile new file mode 100644 index 0000000..bab2dbc --- /dev/null +++ b/gemfiles/rails_8_0.gemfile @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +# Inherits the dev dependencies from the root Gemfile and pins the Rails line +# under test. Generated/maintained for the CI compatibility matrix. +eval_gemfile File.expand_path("../Gemfile", __dir__) + +gem "rails", "~> 8.0.0" diff --git a/gemfiles/rails_8_1.gemfile b/gemfiles/rails_8_1.gemfile new file mode 100644 index 0000000..32dba6b --- /dev/null +++ b/gemfiles/rails_8_1.gemfile @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +# Inherits the dev dependencies from the root Gemfile and pins the Rails line +# under test. Generated/maintained for the CI compatibility matrix. +eval_gemfile File.expand_path("../Gemfile", __dir__) + +gem "rails", "~> 8.1.0" diff --git a/lib/extensions/array.rb b/lib/extensions/array.rb deleted file mode 100644 index 7cf9987..0000000 --- a/lib/extensions/array.rb +++ /dev/null @@ -1,8 +0,0 @@ -# frozen_string_literal: true - -class Array - def deep_sort - map { |element| element.is_a?(Array) ? element.deep_sort : element } - .sort_by { |el| el.is_a?(Array) ? el.first.to_s : el.to_s } - end -end diff --git a/lib/extensions/hash.rb b/lib/extensions/hash.rb deleted file mode 100644 index 1303736..0000000 --- a/lib/extensions/hash.rb +++ /dev/null @@ -1,36 +0,0 @@ -# frozen_string_literal: true - -# Extension methods for hash class -class Hash - def deep_keys - each_with_object([]) do |(k, v), keys| - keys << k - keys << v.deep_keys if v.respond_to?(:keys) - end - end - - def deep_key_paths - stack = map { |k, v| [[k], v] } - key_map = [] - - until stack.empty? - key, value = stack.pop - - key_map << key unless value.is_a? Hash - - next unless value.is_a? Hash - - value.map do |k, v| - stack.push [key.dup << k, v] - end - end - - key_map.reverse - end - - def sanitize!(keys) - keep_if do |k, _v| - keys.include?(k) - end - end -end diff --git a/lib/generators/rspec/json_api/interface/interface_generator.rb b/lib/generators/rspec/json_api/interface/interface_generator.rb index 726a711..56bb7b5 100644 --- a/lib/generators/rspec/json_api/interface/interface_generator.rb +++ b/lib/generators/rspec/json_api/interface/interface_generator.rb @@ -8,12 +8,14 @@ class InterfaceGenerator < Rails::Generators::NamedBase def copy_interface_file create_file "spec/rspec/json_api/interfaces/#{file_name}.rb", <<~FILE + # frozen_string_literal: true + module RSpec module JsonApi module Interfaces #{file_name.upcase} = { # name: String - } + }.freeze end end end diff --git a/lib/generators/rspec/json_api/interface/templates/interface.erb b/lib/generators/rspec/json_api/interface/templates/interface.erb deleted file mode 100644 index 3e5b960..0000000 --- a/lib/generators/rspec/json_api/interface/templates/interface.erb +++ /dev/null @@ -1,9 +0,0 @@ -<%= module RSpec %> - <%= module JsonApi %> - <%= module Interfaces %> - <%= const_set(file_name, { - - }.freeze) %> - <%= end %> - <%= end %> -<%= end %> \ No newline at end of file diff --git a/lib/generators/rspec/json_api/type/type_generator.rb b/lib/generators/rspec/json_api/type/type_generator.rb index e78fbd5..89acc8f 100644 --- a/lib/generators/rspec/json_api/type/type_generator.rb +++ b/lib/generators/rspec/json_api/type/type_generator.rb @@ -8,6 +8,8 @@ class TypeGenerator < Rails::Generators::NamedBase def copy_type_file create_file "spec/rspec/json_api/types/#{file_name}.rb", <<~FILE + # frozen_string_literal: true + module RSpec module JsonApi module Types diff --git a/lib/rspec/json_api.rb b/lib/rspec/json_api.rb index db874ce..9ef16f7 100644 --- a/lib/rspec/json_api.rb +++ b/lib/rspec/json_api.rb @@ -2,17 +2,15 @@ # Load 3rd party libraries require "json" +require "uri" require "diffy" require "active_support/core_ext/object/blank" # Load the json_api parts require "rspec/json_api/version" -require "rspec/json_api/compare_hash" -require "rspec/json_api/compare_array" - -# Load extensions -require "extensions/hash" -require "extensions/array" +require "rspec/json_api/traversal" +require "rspec/json_api/constraints" +require "rspec/json_api/schema_match" # Load matchers require "rspec/json_api/matchers" diff --git a/lib/rspec/json_api/compare_array.rb b/lib/rspec/json_api/compare_array.rb deleted file mode 100644 index 4ea9c54..0000000 --- a/lib/rspec/json_api/compare_array.rb +++ /dev/null @@ -1,39 +0,0 @@ -# frozen_string_literal: true - -module RSpec - module JsonApi - module CompareArray - extend self - - def compare(actual, expected) - if interface?(expected) - actual.all? do |actual_elem| - return false unless actual_elem.deep_keys == expected[0].deep_keys - - CompareHash.compare(actual_elem, expected[0]) - end - else - actual.each_with_index.all? do |actual_elem, index| - compare_primitive_type_element(actual, expected, actual_elem, index) - end - end - end - - private - - def interface?(expected_value) - expected_value.size == 1 && expected_value[0].is_a?(Hash) - end - - def compare_primitive_type_element(actual, expected, actual_elem, index) - if actual[index].respond_to?(:deep_keys) && expected[index].respond_to?(:deep_keys) - return false unless actual[index].deep_keys == expected[index].deep_keys - - CompareHash.compare(actual_elem, expected[index]) - else - CompareHash.compare_values(actual[index], expected[index]) - end - end - end - end -end diff --git a/lib/rspec/json_api/compare_hash.rb b/lib/rspec/json_api/compare_hash.rb deleted file mode 100644 index 8a033a4..0000000 --- a/lib/rspec/json_api/compare_hash.rb +++ /dev/null @@ -1,114 +0,0 @@ -# frozen_string_literal: true - -module RSpec - module JsonApi - module CompareHash - module_function - - SUPPORTED_OPTIONS = %i[allow_blank type value min max inclusion regex lambda].freeze - - def compare(actual, expected) - return false if actual.blank? && expected.present? - - keys = expected.deep_key_paths | actual.deep_key_paths - - compare_key_paths_and_values(keys, actual, expected) - end - - def compare_key_paths_and_values(keys, actual, expected) - keys.all? do |key_path| - actual_value = actual.dig(*key_path) - expected_value = expected.dig(*key_path) - - compare_values(actual_value, expected_value) - end - end - - def compare_values(actual_value, expected_value) - case expected_value - when Class - compare_class(actual_value, expected_value) - when Regexp - compare_regexp(actual_value, expected_value) - when Proc - compare_proc(actual_value, expected_value) - when Array - compare_array(actual_value, expected_value) - else - compare_simple_value(actual_value, expected_value) - end - end - - def compare_class(actual_value, expected_value) - actual_value.instance_of?(expected_value) - end - - def compare_regexp(actual_value, expected_value) - actual_value.to_s =~ expected_value - end - - def compare_proc(actual_value, expected_value) - payload = expected_value.call - payload.sanitize!(SUPPORTED_OPTIONS) - payload = payload.sort_by { |k, _v| k == :allow_blank ? 0 : 1 }.to_h - - payload.all? do |condition_key, condition_value| - case condition_key - when :allow_blank - return true if actual_value.blank? && condition_value - - true - when :type - compare_class(actual_value, condition_value) - when :value - compare_simple_value(actual_value, condition_value) - when :inclusion - condition_value.include?(actual_value) - when :min - return false if !condition_value.is_a?(Numeric) || !actual_value.is_a?(Numeric) - - actual_value >= condition_value - when :max - return false if !condition_value.is_a?(Numeric) || !actual_value.is_a?(Numeric) - - actual_value <= condition_value - when :regex - compare_regexp(actual_value, condition_value) - when :lambda - condition_value.call(actual_value) - end - end - end - - def compare_array(actual_value, expected_value) - if simple_type?(expected_value) - type = expected_value[0] - - actual_value.all? { |elem| compare_class(elem, type) } - elsif interface?(expected_value) - interface = expected_value[0] - - actual_value.all? { |elem| compare(elem, interface) } - else - return false if actual_value&.size != expected_value&.size - - expected_value.each_with_index.all? do |elem, index| - elem.is_a?(Hash) ? compare(actual_value[index], elem) : compare_simple_value(actual_value[index], elem) - end - end - end - - def compare_simple_value(actual_value, expected_value) - actual_value == expected_value - end - - def simple_type?(expected_value) - expected_value.size == 1 && expected_value[0].instance_of?(Class) - end - - def interface?(expected_value) - expected_value.size == 1 && expected_value[0].is_a?(Hash) - end - end - end -end diff --git a/lib/rspec/json_api/constraints.rb b/lib/rspec/json_api/constraints.rb new file mode 100644 index 0000000..eb03abe --- /dev/null +++ b/lib/rspec/json_api/constraints.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +module RSpec + module JsonApi + # Constraints evaluates the option hash produced by a schema Proc + # (e.g. `-> { { type: Integer, min: 1, max: 10, allow_blank: true } }`) + # against an actual value. + # + # allow_blank is a modifier, not a constraint of its own: when it is true a + # blank value is accepted and the remaining options are skipped; otherwise + # the value is checked against every other option. + module Constraints + module_function + + SUPPORTED_OPTIONS = %i[allow_blank type value min max inclusion regex lambda].freeze + + # @param value [Object] the actual value being matched. + # @param options [Hash] the option hash returned by the schema Proc. + # @return [Boolean] true when the value satisfies the options. + # @raise [ArgumentError] when an option key is not supported. + def match(value, options) + validate!(options) + + return true if value.blank? && options[:allow_blank] + + options.except(:allow_blank).all? do |option, condition| + satisfies?(value, option, condition) + end + end + + def validate!(options) + unknown = options.keys - SUPPORTED_OPTIONS + return if unknown.empty? + + raise ArgumentError, "Unsupported match option(s): #{unknown.join(", ")}" + end + + def satisfies?(value, option, condition) + case option + when :type then value.instance_of?(condition) + when :value then value == condition + when :inclusion then condition.include?(value) + when :regex then condition.match?(value.to_s) + when :lambda then condition.call(value) + when :min, :max then within_bound?(value, option, condition) + end + end + + def within_bound?(value, option, condition) + return false unless value.is_a?(Numeric) && condition.is_a?(Numeric) + + option == :min ? value >= condition : value <= condition + end + end + end +end diff --git a/lib/rspec/json_api/matchers/match_json_schema.rb b/lib/rspec/json_api/matchers/match_json_schema.rb index fc31c8f..51626f6 100644 --- a/lib/rspec/json_api/matchers/match_json_schema.rb +++ b/lib/rspec/json_api/matchers/match_json_schema.rb @@ -23,18 +23,13 @@ def initialize(expected) # @param actual [String] The JSON string to test against the expected schema. # @return [Boolean] true if the actual JSON matches the expected schema, false otherwise. def matches?(actual) + @diff = nil @actual = JSON.parse(actual, symbolize_names: true) - @diff = Diffy::Diff.new(expected, @actual, context: 5) - return false unless @actual.instance_of?(expected.class) - - if expected.instance_of?(Array) - RSpec::JsonApi::CompareArray.compare(@actual, expected) - else - return false unless @actual.deep_keys.deep_sort == expected.deep_keys.deep_sort - - RSpec::JsonApi::CompareHash.compare(@actual, expected) - end + RSpec::JsonApi::SchemaMatch.match(@actual, expected) + rescue JSON::ParserError + @actual = actual + false end # Provides a failure message for when the JSON data does not match the expected schema. @@ -45,7 +40,7 @@ def failure_message got: #{actual} Diff: - #{@diff} + #{diff} MSG end @@ -55,6 +50,14 @@ def failure_message def failure_message_when_negated "expected the JSON data not to match the provided schema, but it did." end + + private + + # The diff is only needed to render a failure message, so it is built + # lazily and memoized rather than on every matches? call. + def diff + @diff ||= Diffy::Diff.new(expected, actual, context: 5) + end end end end diff --git a/lib/rspec/json_api/schema_match.rb b/lib/rspec/json_api/schema_match.rb new file mode 100644 index 0000000..4fe51b2 --- /dev/null +++ b/lib/rspec/json_api/schema_match.rb @@ -0,0 +1,135 @@ +# frozen_string_literal: true + +module RSpec + module JsonApi + # SchemaMatch compares parsed JSON (a Hash, an Array, or a scalar) against an + # expected schema. It is the single entry point behind the match_json_schema + # matcher: callers hand it the actual and expected values and it dispatches on + # shape internally, so the matcher does not need to know whether it is looking + # at an object or a collection. + module SchemaMatch + module_function + + # Top-level comparison. Applies the shape guards (class equality and, for + # objects, key-set equality) before recursing. + def match(actual, expected) + return false unless actual.instance_of?(expected.class) + + case expected + when Array + compare_array(actual, expected) + when Hash + return false unless same_key_structure?(actual, expected) + + compare(actual, expected) + else + compare_simple_value(actual, expected) + end + end + + def same_key_structure?(actual, expected) + Traversal.deep_sort(Traversal.deep_keys(actual)) == + Traversal.deep_sort(Traversal.deep_keys(expected)) + end + + def compare(actual, expected) + return false if actual.blank? && expected.present? + + keys = Traversal.deep_key_paths(expected) | Traversal.deep_key_paths(actual) + + compare_key_paths_and_values(keys, actual, expected) + end + + def compare_key_paths_and_values(keys, actual, expected) + keys.all? do |key_path| + actual_value = dig_path(actual, key_path) + expected_value = dig_path(expected, key_path) + + compare_values(actual_value, expected_value) + end + end + + # Digs a key path without raising when an intermediate value is not a Hash. + # Plain Hash#dig raises TypeError if it walks into a scalar (e.g. a schema + # expects a nested object but the actual value is a String), so a mismatch + # would crash instead of failing the match. + def dig_path(data, key_path) + key_path.reduce(data) do |value, key| + break nil unless value.is_a?(Hash) + + value[key] + end + end + + def compare_values(actual_value, expected_value) + case expected_value + when Class then compare_class(actual_value, expected_value) + when Regexp then compare_regexp(actual_value, expected_value) + when Proc then compare_proc(actual_value, expected_value) + when Array then compare_array(actual_value, expected_value) + else compare_simple_value(actual_value, expected_value) + end + end + + def compare_class(actual_value, expected_value) + actual_value.instance_of?(expected_value) + end + + def compare_regexp(actual_value, expected_value) + expected_value.match?(actual_value.to_s) + end + + def compare_proc(actual_value, expected_value) + Constraints.match(actual_value, expected_value.call) + end + + def compare_array(actual_value, expected_value) + if simple_type?(expected_value) + compare_typed_array(actual_value, expected_value) + elsif interface?(expected_value) + compare_interface_array(actual_value, expected_value) + else + compare_exact_array(actual_value, expected_value) + end + end + + # [SomeClass] => every element must be an instance of SomeClass. + def compare_typed_array(actual_value, expected_value) + type = expected_value[0] + + actual_value.all? { |elem| compare_class(elem, type) } + end + + # [{ ...interface... }] => every element must match the single interface. + # Elements go through match (not compare) so each one is held to the same + # key-structure guard as a top-level object; otherwise an element with an + # extra null-valued key would slip through (nil == nil). + def compare_interface_array(actual_value, expected_value) + interface = expected_value[0] + + actual_value.all? { |elem| match(elem, interface) } + end + + # Any other array => element-by-element match, sizes must be equal. + def compare_exact_array(actual_value, expected_value) + return false if actual_value&.size != expected_value&.size + + expected_value.each_with_index.all? do |elem, index| + elem.is_a?(Hash) ? compare(actual_value[index], elem) : compare_simple_value(actual_value[index], elem) + end + end + + def compare_simple_value(actual_value, expected_value) + actual_value == expected_value + end + + def simple_type?(expected_value) + expected_value.size == 1 && expected_value[0].instance_of?(Class) + end + + def interface?(expected_value) + expected_value.size == 1 && expected_value[0].is_a?(Hash) + end + end + end +end diff --git a/lib/rspec/json_api/traversal.rb b/lib/rspec/json_api/traversal.rb new file mode 100644 index 0000000..f326b66 --- /dev/null +++ b/lib/rspec/json_api/traversal.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +module RSpec + module JsonApi + # Traversal holds the structural helpers used to compare JSON shapes: + # collecting the nested key structure of a Hash and sorting it into a + # canonical order. These were previously monkey-patched onto core Hash and + # Array; keeping them here means the gem no longer mutates those classes in + # the host application. + module Traversal + module_function + + # The nested keys of a hash, with each nested hash's keys inlined as an + # array, e.g. { a: 1, b: { c: 2 } } => [:a, :b, [:c]]. + def deep_keys(hash) + hash.each_with_object([]) do |(key, value), keys| + keys << key + keys << deep_keys(value) if value.respond_to?(:keys) + end + end + + # Every leaf key path of a hash, e.g. { a: { b: 1 }, c: 2 } => [[:a, :b], [:c]]. + def deep_key_paths(hash) + stack = hash.map { |key, value| [[key], value] } + key_map = [] + + until stack.empty? + key, value = stack.pop + + key_map << key unless value.is_a?(Hash) + + next unless value.is_a?(Hash) + + value.each { |k, v| stack.push([key.dup << k, v]) } + end + + key_map.reverse + end + + # Recursively sorts an array (and any nested arrays) into a canonical order + # so two key structures can be compared regardless of original order. + def deep_sort(array) + array + .map { |element| element.is_a?(Array) ? deep_sort(element) : element } + .sort_by { |element| element.is_a?(Array) ? element.first.to_s : element.to_s } + end + end + end +end diff --git a/lib/rspec/json_api/types/uuid.rb b/lib/rspec/json_api/types/uuid.rb index 9aaf244..19489df 100644 --- a/lib/rspec/json_api/types/uuid.rb +++ b/lib/rspec/json_api/types/uuid.rb @@ -3,7 +3,7 @@ module RSpec module JsonApi module Types - UUID = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/ + UUID = /\A[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\z/ end end end diff --git a/lib/rspec/json_api/version.rb b/lib/rspec/json_api/version.rb index 36312f8..363b347 100644 --- a/lib/rspec/json_api/version.rb +++ b/lib/rspec/json_api/version.rb @@ -2,6 +2,6 @@ module RSpec module JsonApi - VERSION = "1.4.0" + VERSION = "1.5.0" end end diff --git a/rspec-json_api.gemspec b/rspec-json_api.gemspec index 99d2a8b..ecd42cb 100644 --- a/rspec-json_api.gemspec +++ b/rspec-json_api.gemspec @@ -27,10 +27,14 @@ Gem::Specification.new do |spec| spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } spec.require_paths = ["lib"] - # Uncomment to register a new dependency of your gem + # Runtime dependencies. The gem only needs ActiveSupport's blank?/present? + # core extensions and Rails::Generators (which lives in railties); depending + # on the full "rails" meta-gem would force ActiveRecord, ActionCable, + # ActionMailer, ActionMailbox, ActiveStorage, ActionText, etc. on every + # consumer of a JSON-matcher gem. The >= 6.1.4.1 floor is unchanged. spec.add_dependency "activesupport", ">= 6.1.4.1" spec.add_dependency "diffy", ">= 3.4.2" - spec.add_dependency "rails", ">= 6.1.4.1" + spec.add_dependency "railties", ">= 6.1.4.1" spec.add_dependency "rspec-rails", ">= 5.0.2" # For more information and examples about making a new gem, checkout our diff --git a/spec/rspec/json_api/constraints_spec.rb b/spec/rspec/json_api/constraints_spec.rb new file mode 100644 index 0000000..b35ff85 --- /dev/null +++ b/spec/rspec/json_api/constraints_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +RSpec.describe RSpec::JsonApi::Constraints do + describe ".match" do + it "raises ArgumentError for an unsupported option" do + expect { described_class.match("value", bogus: 1) } + .to raise_error(ArgumentError, /Unsupported match option/) + end + + it "accepts a blank value when allow_blank is true" do + expect(described_class.match(nil, value: "John", allow_blank: true)).to be(true) + end + + it "rejects a blank value that fails a companion constraint when allow_blank is false" do + expect(described_class.match(nil, value: "John", allow_blank: false)).to be(false) + end + + it "checks remaining constraints when the value is present" do + expect(described_class.match(5, type: Integer, min: 3, max: 10)).to be(true) + expect(described_class.match(2, type: Integer, min: 3)).to be(false) + end + end +end diff --git a/spec/rspec/json_api/matchers/match_json_schema_spec.rb b/spec/rspec/json_api/matchers/match_json_schema_spec.rb index d9a0859..d189638 100644 --- a/spec/rspec/json_api/matchers/match_json_schema_spec.rb +++ b/spec/rspec/json_api/matchers/match_json_schema_spec.rb @@ -15,6 +15,52 @@ end end + context "when actual is not valid JSON" do + let(:expected) do + { id: String } + end + + let(:actual) do + "this is not json" + end + + include_examples "incorrect-match" + end + + context "when the schema matches" do + it "does not build a diff" do + expect(Diffy::Diff).not_to receive(:new) + + expect({ id: "1" }.to_json).to match_json_schema({ id: String }) + end + end + + context "when a matcher instance is reused for a second match" do + it "reflects the latest actual value in the failure message" do + matcher = match_json_schema({ id: Integer }) + + matcher.matches?({ id: "first" }.to_json) + matcher.failure_message + + matcher.matches?({ id: "second" }.to_json) + + expect(matcher.failure_message).to include("second") + expect(matcher.failure_message).not_to include("first") + end + end + + context "when a nested object is replaced by a scalar" do + let(:expected) do + { items: [{ meta: { id: String } }] } + end + + let(:actual) do + { items: [{ meta: "not-an-object" }] }.to_json + end + + include_examples "incorrect-match" + end + context "when schema does not match" do let(:expected) do { @@ -362,6 +408,14 @@ include_examples "incorrect-match" end + + context "when a valid uuid is followed by a newline and extra content" do + let(:actual) do + { uuid: "07bbf12b-df44-4c8d-9415-aa33f51c5fc2\nmalicious" }.to_json + end + + include_examples "incorrect-match" + end end end @@ -1031,6 +1085,18 @@ include_examples "incorrect-match" end + + context "when an element has an extra null-valued key" do + let(:actual) do + [{ id: "8eccff73-f134-42f2-aed4-751d1f4ebd4f", extra: nil }].to_json + end + + let(:expected) do + [{ id: String }] + end + + include_examples "incorrect-match" + end end end diff --git a/spec/rspec/json_api_spec.rb b/spec/rspec/json_api_spec.rb index 7298eec..1307511 100644 --- a/spec/rspec/json_api_spec.rb +++ b/spec/rspec/json_api_spec.rb @@ -4,4 +4,9 @@ it "has a version number" do expect(RSpec::JsonApi::VERSION).not_to be nil end + + it "does not monkey-patch core Hash and Array" do + expect({}).not_to respond_to(:deep_keys, :deep_key_paths, :sanitize!) + expect([]).not_to respond_to(:deep_sort) + end end