From 8f7318a75d92440498df241d72afe5059b5d1e91 Mon Sep 17 00:00:00 2001 From: Michal Date: Fri, 5 Jun 2026 17:19:16 +0200 Subject: [PATCH 01/17] Fix: require "uri" so types load on Ruby 4.0 types/email.rb and types/uri.rb reference the stdlib URI constant (URI::MailTo::EMAIL_REGEXP, URI::DEFAULT_PARSER), but nothing required "uri". On older Ruby it was loaded as a side effect of other gems; on Ruby 4.0 it is not, so requiring the gem raised NameError: uninitialized constant URI and the whole suite failed to load. Require "uri" explicitly at load time. --- Gemfile.lock | 1 + lib/rspec/json_api.rb | 1 + 2 files changed, 2 insertions(+) diff --git a/Gemfile.lock b/Gemfile.lock index 52cba3a..8834512 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -248,6 +248,7 @@ GEM PLATFORMS arm64-darwin-22 + arm64-darwin-25 x86_64-darwin-20 DEPENDENCIES diff --git a/lib/rspec/json_api.rb b/lib/rspec/json_api.rb index db874ce..60ddce2 100644 --- a/lib/rspec/json_api.rb +++ b/lib/rspec/json_api.rb @@ -2,6 +2,7 @@ # Load 3rd party libraries require "json" +require "uri" require "diffy" require "active_support/core_ext/object/blank" From ba59f62e3ca878bc12fd155eee8ac56fba71fce0 Mon Sep 17 00:00:00 2001 From: Michal Date: Fri, 5 Jun 2026 17:20:25 +0200 Subject: [PATCH 02/17] Fix: anchor UUID type with \A...\z to reject multiline input The UUID regex used ^...$ anchors, which in Ruby match line boundaries, not string boundaries. A string whose first line is a valid UUID followed by a newline and arbitrary content matched, letting crafted multiline values pass validation. Switch to \A...\z so the whole string must be a UUID. Adds a regression test. --- lib/rspec/json_api/types/uuid.rb | 2 +- spec/rspec/json_api/matchers/match_json_schema_spec.rb | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) 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/spec/rspec/json_api/matchers/match_json_schema_spec.rb b/spec/rspec/json_api/matchers/match_json_schema_spec.rb index d9a0859..878804a 100644 --- a/spec/rspec/json_api/matchers/match_json_schema_spec.rb +++ b/spec/rspec/json_api/matchers/match_json_schema_spec.rb @@ -362,6 +362,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 From e71d403ef7822fc5006f9c96fc9a16664900fff5 Mon Sep 17 00:00:00 2001 From: Michal Date: Fri, 5 Jun 2026 17:21:07 +0200 Subject: [PATCH 03/17] Fix: use Regexp#match? for boolean regex comparison compare_regexp returned `actual_value.to_s =~ expected_value`, an Integer match index or nil rather than a Boolean. It worked only by truthiness in the surrounding all? chains. Use expected_value.match?(actual_value.to_s) to return a proper Boolean. Behaviour is unchanged; existing regex specs stay green. --- lib/rspec/json_api/compare_hash.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/rspec/json_api/compare_hash.rb b/lib/rspec/json_api/compare_hash.rb index 8a033a4..978be52 100644 --- a/lib/rspec/json_api/compare_hash.rb +++ b/lib/rspec/json_api/compare_hash.rb @@ -44,7 +44,7 @@ def compare_class(actual_value, expected_value) end def compare_regexp(actual_value, expected_value) - actual_value.to_s =~ expected_value + expected_value.match?(actual_value.to_s) end def compare_proc(actual_value, expected_value) From cd8723c343763d7655af0399b4edf41e212215eb Mon Sep 17 00:00:00 2001 From: Michal Date: Fri, 5 Jun 2026 17:22:34 +0200 Subject: [PATCH 04/17] Fix: rescue invalid JSON in match_json_schema matcher matches? called JSON.parse without rescuing, so a non-JSON actual (e.g. a plain error string) raised JSON::ParserError and errored the example instead of reporting a clean mismatch. Rescue JSON::ParserError and return false, keeping the raw input for the failure message. Extracts the comparison dispatch into a private schema_match? to keep matches? small. Adds a regression test. --- .../json_api/matchers/match_json_schema.rb | 29 +++++++++++++------ .../matchers/match_json_schema_spec.rb | 12 ++++++++ 2 files changed, 32 insertions(+), 9 deletions(-) diff --git a/lib/rspec/json_api/matchers/match_json_schema.rb b/lib/rspec/json_api/matchers/match_json_schema.rb index fc31c8f..5d13387 100644 --- a/lib/rspec/json_api/matchers/match_json_schema.rb +++ b/lib/rspec/json_api/matchers/match_json_schema.rb @@ -26,15 +26,10 @@ def matches?(actual) @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 + schema_match? + rescue JSON::ParserError + @actual = actual + false end # Provides a failure message for when the JSON data does not match the expected schema. @@ -55,6 +50,22 @@ def failure_message def failure_message_when_negated "expected the JSON data not to match the provided schema, but it did." end + + private + + # Dispatches the parsed actual data to the comparison strategy for the expected schema. + # @return [Boolean] true if the actual data matches the expected schema. + def schema_match? + 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 + end 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 878804a..6a94947 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,18 @@ 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 schema does not match" do let(:expected) do { From c30eb5f0935097c06f3a9868e58645046ff281d9 Mon Sep 17 00:00:00 2001 From: Michal Date: Fri, 5 Jun 2026 17:23:45 +0200 Subject: [PATCH 05/17] Fix: avoid dig TypeError when a nested object is a scalar compare_key_paths_and_values used Hash#dig(*key_path), which raises TypeError ("String does not have #dig method") when the schema expects a nested object but the actual value at an intermediate key is a scalar. This is reachable through nested interface arrays, where the top-level deep_keys guard does not apply, so a mismatch crashed instead of failing. Add a Hash-guarded dig_path that returns nil on a non-Hash intermediate. Adds a regression test. --- lib/rspec/json_api/compare_hash.rb | 16 ++++++++++++++-- .../json_api/matchers/match_json_schema_spec.rb | 12 ++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/lib/rspec/json_api/compare_hash.rb b/lib/rspec/json_api/compare_hash.rb index 978be52..45f99af 100644 --- a/lib/rspec/json_api/compare_hash.rb +++ b/lib/rspec/json_api/compare_hash.rb @@ -17,13 +17,25 @@ def compare(actual, expected) 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) + 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 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 6a94947..04924ec 100644 --- a/spec/rspec/json_api/matchers/match_json_schema_spec.rb +++ b/spec/rspec/json_api/matchers/match_json_schema_spec.rb @@ -27,6 +27,18 @@ include_examples "incorrect-match" 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 { From 495242d790bfb0620fc377deb20e66ce974bd3da Mon Sep 17 00:00:00 2001 From: Michal Date: Fri, 5 Jun 2026 17:26:42 +0200 Subject: [PATCH 06/17] Refactor: extract Constraints module for the proc-options DSL The schema Proc options (type/value/min/max/inclusion/regex/lambda/ allow_blank) were evaluated inline in CompareHash#compare_proc, which: - relied on a sort_by hack to force allow_blank to run first inside an all? block (with a bare `return` to short-circuit), - mutated the option hash via the Hash#sanitize! monkey-patch, and - silently dropped unsupported option keys. Move the DSL into RSpec::JsonApi::Constraints with a small interface, Constraints.match(value, options). allow_blank is handled explicitly before the other options (no sort hack, no inner return), and an unsupported option now raises ArgumentError instead of being ignored. compare_proc delegates to it. Behaviour for supported options is unchanged; adds a unit spec covering the new module. --- lib/rspec/json_api.rb | 1 + lib/rspec/json_api/compare_hash.rb | 33 +-------------- lib/rspec/json_api/constraints.rb | 56 +++++++++++++++++++++++++ spec/rspec/json_api/constraints_spec.rb | 23 ++++++++++ 4 files changed, 81 insertions(+), 32 deletions(-) create mode 100644 lib/rspec/json_api/constraints.rb create mode 100644 spec/rspec/json_api/constraints_spec.rb diff --git a/lib/rspec/json_api.rb b/lib/rspec/json_api.rb index 60ddce2..54e92e7 100644 --- a/lib/rspec/json_api.rb +++ b/lib/rspec/json_api.rb @@ -8,6 +8,7 @@ # Load the json_api parts require "rspec/json_api/version" +require "rspec/json_api/constraints" require "rspec/json_api/compare_hash" require "rspec/json_api/compare_array" diff --git a/lib/rspec/json_api/compare_hash.rb b/lib/rspec/json_api/compare_hash.rb index 45f99af..ac74c64 100644 --- a/lib/rspec/json_api/compare_hash.rb +++ b/lib/rspec/json_api/compare_hash.rb @@ -5,8 +5,6 @@ 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? @@ -60,36 +58,7 @@ def compare_regexp(actual_value, 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 + Constraints.match(actual_value, expected_value.call) end def compare_array(actual_value, expected_value) 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/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 From 6bc1966b151a5fad0c58136d8dff6cab8519c56f Mon Sep 17 00:00:00 2001 From: Michal Date: Fri, 5 Jun 2026 17:29:34 +0200 Subject: [PATCH 07/17] Refactor: unify array and hash comparison behind SchemaMatch Array comparison existed twice: CompareArray handled a top-level array schema, while CompareHash#compare_array handled nested arrays, each with its own interface? check and divergent rules (e.g. CompareArray skipped the element-count check). The matcher also owned the dispatch knowledge (class equality + key-set guard + which module to call). Introduce RSpec::JsonApi::SchemaMatch with one public entry, match(actual, expected), that dispatches on shape internally and applies the top-level guards. Top-level arrays now route through the same compare_array used for nested arrays, so there is a single array implementation. Delete CompareArray and CompareHash; the matcher just calls SchemaMatch.match. Behaviour is unchanged (59 examples green). --- .rubocop.yml | 10 ++--- lib/rspec/json_api.rb | 3 +- lib/rspec/json_api/compare_array.rb | 39 ------------------- .../json_api/matchers/match_json_schema.rb | 18 +-------- .../{compare_hash.rb => schema_match.rb} | 24 +++++++++++- 5 files changed, 30 insertions(+), 64 deletions(-) delete mode 100644 lib/rspec/json_api/compare_array.rb rename lib/rspec/json_api/{compare_hash.rb => schema_match.rb} (75%) diff --git a/.rubocop.yml b/.rubocop.yml index 62233d9..052d200 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -22,21 +22,21 @@ Metrics/BlockLength: Metrics/MethodLength: Exclude: - - "lib/rspec/json_api/compare_hash.rb" + - "lib/rspec/json_api/schema_match.rb" - "lib/extensions/hash.rb" Metrics/AbcSize: Exclude: - - "lib/rspec/json_api/compare_hash.rb" + - "lib/rspec/json_api/schema_match.rb" - "lib/rspec/json_api/matchers/match_json_schema.rb" Metrics/CyclomaticComplexity: Exclude: - - "lib/rspec/json_api/compare_hash.rb" + - "lib/rspec/json_api/schema_match.rb" Metrics/PerceivedComplexity: Exclude: - - "lib/rspec/json_api/compare_hash.rb" + - "lib/rspec/json_api/schema_match.rb" Style/Documentation: Enabled: false @@ -47,4 +47,4 @@ Naming/PredicatePrefix: Naming/PredicateMethod: Exclude: - - "lib/rspec/json_api/compare_hash.rb" + - "lib/rspec/json_api/schema_match.rb" diff --git a/lib/rspec/json_api.rb b/lib/rspec/json_api.rb index 54e92e7..5a41d84 100644 --- a/lib/rspec/json_api.rb +++ b/lib/rspec/json_api.rb @@ -9,8 +9,7 @@ # Load the json_api parts require "rspec/json_api/version" require "rspec/json_api/constraints" -require "rspec/json_api/compare_hash" -require "rspec/json_api/compare_array" +require "rspec/json_api/schema_match" # Load extensions require "extensions/hash" 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/matchers/match_json_schema.rb b/lib/rspec/json_api/matchers/match_json_schema.rb index 5d13387..e64ab07 100644 --- a/lib/rspec/json_api/matchers/match_json_schema.rb +++ b/lib/rspec/json_api/matchers/match_json_schema.rb @@ -26,7 +26,7 @@ def matches?(actual) @actual = JSON.parse(actual, symbolize_names: true) @diff = Diffy::Diff.new(expected, @actual, context: 5) - schema_match? + RSpec::JsonApi::SchemaMatch.match(@actual, expected) rescue JSON::ParserError @actual = actual false @@ -50,22 +50,6 @@ def failure_message def failure_message_when_negated "expected the JSON data not to match the provided schema, but it did." end - - private - - # Dispatches the parsed actual data to the comparison strategy for the expected schema. - # @return [Boolean] true if the actual data matches the expected schema. - def schema_match? - 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 - end end end end diff --git a/lib/rspec/json_api/compare_hash.rb b/lib/rspec/json_api/schema_match.rb similarity index 75% rename from lib/rspec/json_api/compare_hash.rb rename to lib/rspec/json_api/schema_match.rb index ac74c64..45a3492 100644 --- a/lib/rspec/json_api/compare_hash.rb +++ b/lib/rspec/json_api/schema_match.rb @@ -2,9 +2,31 @@ module RSpec module JsonApi - module CompareHash + # 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 actual.deep_keys.deep_sort == expected.deep_keys.deep_sort + + compare(actual, expected) + else + compare_simple_value(actual, expected) + end + end + def compare(actual, expected) return false if actual.blank? && expected.present? From cc801b9d5d98cd70cd7e43cf58b4fb33bac4105e Mon Sep 17 00:00:00 2001 From: Michal Date: Fri, 5 Jun 2026 17:31:12 +0200 Subject: [PATCH 08/17] Refactor: replace global Hash/Array monkey-patches with Traversal module lib/extensions/hash.rb and lib/extensions/array.rb opened core Hash and Array to add deep_keys, deep_key_paths, deep_sort and sanitize!, forcing those methods onto every object in the host application - the very monkey-patching the gem's own spec_helper disables. sanitize! also shadowed Rails vocabulary. Move the structural helpers into RSpec::JsonApi::Traversal as module functions taking the data as an argument, called only from SchemaMatch. sanitize! is dropped (unused since the Constraints extraction). Core classes are no longer mutated; adds a guard test asserting so. --- lib/extensions/array.rb | 8 ----- lib/extensions/hash.rb | 36 ---------------------- lib/rspec/json_api.rb | 5 +-- lib/rspec/json_api/schema_match.rb | 9 ++++-- lib/rspec/json_api/traversal.rb | 49 ++++++++++++++++++++++++++++++ spec/rspec/json_api_spec.rb | 5 +++ 6 files changed, 62 insertions(+), 50 deletions(-) delete mode 100644 lib/extensions/array.rb delete mode 100644 lib/extensions/hash.rb create mode 100644 lib/rspec/json_api/traversal.rb 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/rspec/json_api.rb b/lib/rspec/json_api.rb index 5a41d84..9ef16f7 100644 --- a/lib/rspec/json_api.rb +++ b/lib/rspec/json_api.rb @@ -8,13 +8,10 @@ # Load the json_api parts require "rspec/json_api/version" +require "rspec/json_api/traversal" require "rspec/json_api/constraints" require "rspec/json_api/schema_match" -# Load extensions -require "extensions/hash" -require "extensions/array" - # Load matchers require "rspec/json_api/matchers" require "rspec/json_api/matchers/match_json_schema" diff --git a/lib/rspec/json_api/schema_match.rb b/lib/rspec/json_api/schema_match.rb index 45a3492..74b2546 100644 --- a/lib/rspec/json_api/schema_match.rb +++ b/lib/rspec/json_api/schema_match.rb @@ -19,7 +19,7 @@ def match(actual, expected) when Array compare_array(actual, expected) when Hash - return false unless actual.deep_keys.deep_sort == expected.deep_keys.deep_sort + return false unless same_key_structure?(actual, expected) compare(actual, expected) else @@ -27,10 +27,15 @@ def match(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 = expected.deep_key_paths | actual.deep_key_paths + keys = Traversal.deep_key_paths(expected) | Traversal.deep_key_paths(actual) compare_key_paths_and_values(keys, actual, expected) 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/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 From b3b1ac3b25b4d0a3f99ae4c4f15b12e9134a8fb5 Mon Sep 17 00:00:00 2001 From: Michal Date: Fri, 5 Jun 2026 17:32:25 +0200 Subject: [PATCH 09/17] Perf: build the failure diff lazily in match_json_schema matches? built Diffy::Diff on every assertion, including passing ones, then threw it away. Diffy is only needed to render failure_message. Move diff construction into a memoized private method called from failure_message, so the happy path does no diff work. Adds a test asserting Diffy::Diff is not instantiated when the schema matches. --- lib/rspec/json_api/matchers/match_json_schema.rb | 11 +++++++++-- .../rspec/json_api/matchers/match_json_schema_spec.rb | 8 ++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/lib/rspec/json_api/matchers/match_json_schema.rb b/lib/rspec/json_api/matchers/match_json_schema.rb index e64ab07..ba4f02e 100644 --- a/lib/rspec/json_api/matchers/match_json_schema.rb +++ b/lib/rspec/json_api/matchers/match_json_schema.rb @@ -24,7 +24,6 @@ def initialize(expected) # @return [Boolean] true if the actual JSON matches the expected schema, false otherwise. def matches?(actual) @actual = JSON.parse(actual, symbolize_names: true) - @diff = Diffy::Diff.new(expected, @actual, context: 5) RSpec::JsonApi::SchemaMatch.match(@actual, expected) rescue JSON::ParserError @@ -40,7 +39,7 @@ def failure_message got: #{actual} Diff: - #{@diff} + #{diff} MSG end @@ -50,6 +49,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/spec/rspec/json_api/matchers/match_json_schema_spec.rb b/spec/rspec/json_api/matchers/match_json_schema_spec.rb index 04924ec..4ad39e6 100644 --- a/spec/rspec/json_api/matchers/match_json_schema_spec.rb +++ b/spec/rspec/json_api/matchers/match_json_schema_spec.rb @@ -27,6 +27,14 @@ 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 nested object is replaced by a scalar" do let(:expected) do { items: [{ meta: { id: String } }] } From 6b82df4e30cf60ed5ce98cda6e6f04954484ba7a Mon Sep 17 00:00:00 2001 From: Michal Date: Fri, 5 Jun 2026 17:33:31 +0200 Subject: [PATCH 10/17] Chore: remove dead interface template and freeze generated files interface.erb was never used - InterfaceGenerator writes its file from an inline heredoc via create_file, and the template itself was broken (`<%= module RSpec %>` would render the return value of the module definition). Remove it. Also emit `# frozen_string_literal: true` from both generators and freeze the generated interface hash, matching the hand-written example_interface and the rest of the codebase. --- .../rspec/json_api/interface/interface_generator.rb | 4 +++- .../rspec/json_api/interface/templates/interface.erb | 9 --------- lib/generators/rspec/json_api/type/type_generator.rb | 2 ++ 3 files changed, 5 insertions(+), 10 deletions(-) delete mode 100644 lib/generators/rspec/json_api/interface/templates/interface.erb 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 From 599d7ea8fdd5a006842460367ff2ba5fda44ce9d Mon Sep 17 00:00:00 2001 From: Michal Date: Fri, 5 Jun 2026 17:34:40 +0200 Subject: [PATCH 11/17] Deps: depend on railties instead of the full rails meta-gem The gem only uses ActiveSupport core extensions (blank?/present?) and Rails::Generators, which lives in railties. Depending on the "rails" meta-gem forced the entire framework - ActiveRecord, ActionCable, ActionMailer, ActionMailbox, ActiveStorage, ActionText - onto every consumer of a JSON-matcher gem. Replace rails with railties, keeping the >= 6.1.4.1 floor (backwards compatible). Regenerating the lock drops the dependency graph from 87 to 65 gems. Generators verified to load against railties alone; suite green. --- Gemfile.lock | 86 +----------------------------------------- rspec-json_api.gemspec | 8 +++- 2 files changed, 7 insertions(+), 87 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 8834512..ed2f318 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -4,34 +4,12 @@ PATH rspec-json_api (1.4.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,26 +66,8 @@ 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) @@ -157,20 +92,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 +152,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,10 +160,6 @@ 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 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 From 3561d3462c61a0f4d24351f676fa0541e0b403df Mon Sep 17 00:00:00 2001 From: Michal Date: Fri, 5 Jun 2026 17:38:07 +0200 Subject: [PATCH 12/17] CI: test a Ruby x Rails compatibility matrix CI previously ran a single Ruby (3.2.2) against whatever Rails resolved (the latest), so the gemspec promise of Ruby >= 3.2 and Rails >= 6.1 was never actually exercised. Add per-Rails-line bundle gemfiles under gemfiles/ (each inherits the root dev dependencies via eval_gemfile and pins the Rails line) and a GitHub Actions matrix covering the floor (Ruby 3.2 / Rails 6.1) through the ceiling (Ruby 3.4 / Rails 8.1), with fail-fast disabled. Rubocop runs once in a separate lint job. Also add the x86_64-linux platform to the lockfile so the lint job installs cleanly on CI. --- .github/workflows/main.yml | 53 ++++++++++++++++++++++++++++---------- .gitignore | 3 +++ Gemfile.lock | 3 +++ gemfiles/rails_6_1.gemfile | 7 +++++ gemfiles/rails_7_1.gemfile | 7 +++++ gemfiles/rails_7_2.gemfile | 7 +++++ gemfiles/rails_8_0.gemfile | 7 +++++ gemfiles/rails_8_1.gemfile | 7 +++++ 8 files changed, 80 insertions(+), 14 deletions(-) create mode 100644 gemfiles/rails_6_1.gemfile create mode 100644 gemfiles/rails_7_1.gemfile create mode 100644 gemfiles/rails_7_2.gemfile create mode 100644 gemfiles/rails_8_0.gemfile create mode 100644 gemfiles/rails_8_1.gemfile 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/Gemfile.lock b/Gemfile.lock index ed2f318..b9dccc4 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -72,6 +72,8 @@ GEM 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) @@ -166,6 +168,7 @@ 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" From 946060f0ca60841fc29526ba069b264c25989714 Mon Sep 17 00:00:00 2001 From: Michal Date: Fri, 5 Jun 2026 17:39:57 +0200 Subject: [PATCH 13/17] Chore: drop rubocop complexity suppressions made unnecessary by refactors compare_hash.rb (now schema_match.rb) was excluded from MethodLength, AbcSize, CyclomaticComplexity and PerceivedComplexity, and the MethodLength list still referenced the deleted extensions/hash.rb. After extracting Constraints, unifying the comparators and splitting compare_array into compare_typed_array / compare_interface_array / compare_exact_array (plus a one-line compare_values), none of those suppressions are needed - rubocop is clean without them. Remove all four complexity excludes and the AbcSize exclude for match_json_schema.rb. The remaining Naming/* excludes are deliberate naming choices, not complexity debt, so they stay. --- .rubocop.yml | 18 ----------- lib/rspec/json_api/schema_match.rb | 50 ++++++++++++++++++------------ 2 files changed, 30 insertions(+), 38 deletions(-) diff --git a/.rubocop.yml b/.rubocop.yml index 052d200..5f83684 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -20,24 +20,6 @@ Metrics/BlockLength: Exclude: - "spec/**/*_spec.rb" -Metrics/MethodLength: - Exclude: - - "lib/rspec/json_api/schema_match.rb" - - "lib/extensions/hash.rb" - -Metrics/AbcSize: - Exclude: - - "lib/rspec/json_api/schema_match.rb" - - "lib/rspec/json_api/matchers/match_json_schema.rb" - -Metrics/CyclomaticComplexity: - Exclude: - - "lib/rspec/json_api/schema_match.rb" - -Metrics/PerceivedComplexity: - Exclude: - - "lib/rspec/json_api/schema_match.rb" - Style/Documentation: Enabled: false diff --git a/lib/rspec/json_api/schema_match.rb b/lib/rspec/json_api/schema_match.rb index 74b2546..6a733d6 100644 --- a/lib/rspec/json_api/schema_match.rb +++ b/lib/rspec/json_api/schema_match.rb @@ -63,16 +63,11 @@ def dig_path(data, key_path) 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) + 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 @@ -90,19 +85,34 @@ def compare_proc(actual_value, expected_value) def compare_array(actual_value, expected_value) if simple_type?(expected_value) - type = expected_value[0] - - actual_value.all? { |elem| compare_class(elem, type) } + compare_typed_array(actual_value, expected_value) elsif interface?(expected_value) - interface = expected_value[0] - - actual_value.all? { |elem| compare(elem, interface) } + compare_interface_array(actual_value, expected_value) else - return false if actual_value&.size != expected_value&.size + 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. + def compare_interface_array(actual_value, expected_value) + interface = expected_value[0] + + actual_value.all? { |elem| compare(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 + 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 From 64a6bff1d364eaccd66c41158073709692558667 Mon Sep 17 00:00:00 2001 From: Michal Date: Fri, 5 Jun 2026 17:52:45 +0200 Subject: [PATCH 14/17] CI: exclude vendored gems from rubocop The lint job uses bundler-cache, which installs gems into vendor/bundle inside the checkout. The project .rubocop.yml set AllCops/Exclude, which *replaces* RuboCop default excludes (including vendor/**/*), so rubocop started linting third-party gems and failed. Merge with the defaults via inherit_mode and exclude vendor explicitly. Did not surface locally because gems install outside the repo there. --- .rubocop.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.rubocop.yml b/.rubocop.yml index 5f83684..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 From a79e487d3b559ffaef63c808a8cd093b0d7d39b4 Mon Sep 17 00:00:00 2001 From: Michal Date: Fri, 5 Jun 2026 17:57:32 +0200 Subject: [PATCH 15/17] Fix: enforce key structure for interface-array elements Unifying the comparators routed top-level interface arrays through compare(elem, interface), dropping the per-element key-structure guard that CompareArray.compare used to apply (actual_elem.deep_keys == expected[0].deep_keys). An element with an extra null-valued key then matched, e.g. [{ id: "1", extra: nil }] satisfied [{ id: String }] because the extra path compared nil == nil. Route interface-array elements through match instead of compare, so each element is held to the same key-structure guard (and class check) as a top-level object. Adds a regression test. Raised by @mike927 in review of #11. --- lib/rspec/json_api/schema_match.rb | 5 ++++- .../json_api/matchers/match_json_schema_spec.rb | 12 ++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/lib/rspec/json_api/schema_match.rb b/lib/rspec/json_api/schema_match.rb index 6a733d6..4fe51b2 100644 --- a/lib/rspec/json_api/schema_match.rb +++ b/lib/rspec/json_api/schema_match.rb @@ -101,10 +101,13 @@ def compare_typed_array(actual_value, expected_value) 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| compare(elem, interface) } + actual_value.all? { |elem| match(elem, interface) } end # Any other array => element-by-element match, sizes must be equal. 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 4ad39e6..c31ff86 100644 --- a/spec/rspec/json_api/matchers/match_json_schema_spec.rb +++ b/spec/rspec/json_api/matchers/match_json_schema_spec.rb @@ -1071,6 +1071,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 From 604317946c00a3f400813037d044e5938d512d48 Mon Sep 17 00:00:00 2001 From: Michal Date: Fri, 5 Jun 2026 17:58:18 +0200 Subject: [PATCH 16/17] Fix: reset memoized diff at the start of matches? The failure diff is memoized in @diff but @actual is replaced on every matches? call. A reused matcher instance whose first match failed would render the first diff in a later failure message, even though got: showed the new actual value. Reset @diff at the start of matches? so the lazy build still happens but always reflects the current actual. Adds a regression test. Raised by @mike927 in review of #11. --- lib/rspec/json_api/matchers/match_json_schema.rb | 1 + .../json_api/matchers/match_json_schema_spec.rb | 14 ++++++++++++++ 2 files changed, 15 insertions(+) diff --git a/lib/rspec/json_api/matchers/match_json_schema.rb b/lib/rspec/json_api/matchers/match_json_schema.rb index ba4f02e..51626f6 100644 --- a/lib/rspec/json_api/matchers/match_json_schema.rb +++ b/lib/rspec/json_api/matchers/match_json_schema.rb @@ -23,6 +23,7 @@ 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) RSpec::JsonApi::SchemaMatch.match(@actual, expected) 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 c31ff86..d189638 100644 --- a/spec/rspec/json_api/matchers/match_json_schema_spec.rb +++ b/spec/rspec/json_api/matchers/match_json_schema_spec.rb @@ -35,6 +35,20 @@ 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 } }] } From cf1ec862f57f0eaa69638338da53805bf0126047 Mon Sep 17 00:00:00 2001 From: Michal Date: Fri, 5 Jun 2026 18:09:17 +0200 Subject: [PATCH 17/17] Release: bump version to 1.5.0 and update CHANGELOG --- CHANGELOG.md | 27 +++++++++++++++++++++++++++ Gemfile.lock | 2 +- lib/rspec/json_api/version.rb | 2 +- 3 files changed, 29 insertions(+), 2 deletions(-) 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 b9dccc4..bfbf2fa 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - rspec-json_api (1.4.0) + rspec-json_api (1.5.0) activesupport (>= 6.1.4.1) diffy (>= 3.4.2) railties (>= 6.1.4.1) 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