rspec-json_api 1.3.1 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Inherits the dev dependencies from the root Gemfile and pins the Rails line
4
+ # under test. Generated/maintained for the CI compatibility matrix.
5
+ eval_gemfile File.expand_path("../Gemfile", __dir__)
6
+
7
+ gem "rails", "~> 7.1.0"
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Inherits the dev dependencies from the root Gemfile and pins the Rails line
4
+ # under test. Generated/maintained for the CI compatibility matrix.
5
+ eval_gemfile File.expand_path("../Gemfile", __dir__)
6
+
7
+ gem "rails", "~> 7.2.0"
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Inherits the dev dependencies from the root Gemfile and pins the Rails line
4
+ # under test. Generated/maintained for the CI compatibility matrix.
5
+ eval_gemfile File.expand_path("../Gemfile", __dir__)
6
+
7
+ gem "rails", "~> 8.0.0"
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Inherits the dev dependencies from the root Gemfile and pins the Rails line
4
+ # under test. Generated/maintained for the CI compatibility matrix.
5
+ eval_gemfile File.expand_path("../Gemfile", __dir__)
6
+
7
+ gem "rails", "~> 8.1.0"
@@ -8,12 +8,14 @@ module Rspec
8
8
 
9
9
  def copy_interface_file
10
10
  create_file "spec/rspec/json_api/interfaces/#{file_name}.rb", <<~FILE
11
+ # frozen_string_literal: true
12
+
11
13
  module RSpec
12
14
  module JsonApi
13
15
  module Interfaces
14
16
  #{file_name.upcase} = {
15
17
  # name: String
16
- }
18
+ }.freeze
17
19
  end
18
20
  end
19
21
  end
@@ -6,8 +6,10 @@ module Rspec
6
6
  class TypeGenerator < Rails::Generators::NamedBase
7
7
  source_root File.expand_path("templates", __dir__)
8
8
 
9
- def copy_interface_file
9
+ def copy_type_file
10
10
  create_file "spec/rspec/json_api/types/#{file_name}.rb", <<~FILE
11
+ # frozen_string_literal: true
12
+
11
13
  module RSpec
12
14
  module JsonApi
13
15
  module Types
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpec
4
+ module JsonApi
5
+ # Constraints evaluates the option hash produced by a schema Proc
6
+ # (e.g. `-> { { type: Integer, min: 1, max: 10, allow_blank: true } }`)
7
+ # against an actual value.
8
+ #
9
+ # allow_blank is a modifier, not a constraint of its own: when it is true a
10
+ # blank value is accepted and the remaining options are skipped; otherwise
11
+ # the value is checked against every other option.
12
+ module Constraints
13
+ module_function
14
+
15
+ SUPPORTED_OPTIONS = %i[allow_blank type value min max inclusion regex lambda].freeze
16
+
17
+ # @param value [Object] the actual value being matched.
18
+ # @param options [Hash] the option hash returned by the schema Proc.
19
+ # @return [Boolean] true when the value satisfies the options.
20
+ # @raise [ArgumentError] when an option key is not supported.
21
+ def match(value, options)
22
+ validate!(options)
23
+
24
+ return true if value.blank? && options[:allow_blank]
25
+
26
+ options.except(:allow_blank).all? do |option, condition|
27
+ satisfies?(value, option, condition)
28
+ end
29
+ end
30
+
31
+ def validate!(options)
32
+ unknown = options.keys - SUPPORTED_OPTIONS
33
+ return if unknown.empty?
34
+
35
+ raise ArgumentError, "Unsupported match option(s): #{unknown.join(", ")}"
36
+ end
37
+
38
+ def satisfies?(value, option, condition)
39
+ case option
40
+ when :type then value.instance_of?(condition)
41
+ when :value then value == condition
42
+ when :inclusion then condition.include?(value)
43
+ when :regex then condition.match?(value.to_s)
44
+ when :lambda then condition.call(value)
45
+ when :min, :max then within_bound?(value, option, condition)
46
+ end
47
+ end
48
+
49
+ def within_bound?(value, option, condition)
50
+ return false unless value.is_a?(Numeric) && condition.is_a?(Numeric)
51
+
52
+ option == :min ? value >= condition : value <= condition
53
+ end
54
+ end
55
+ end
56
+ end
@@ -23,18 +23,13 @@ module RSpec
23
23
  # @param actual [String] The JSON string to test against the expected schema.
24
24
  # @return [Boolean] true if the actual JSON matches the expected schema, false otherwise.
25
25
  def matches?(actual)
26
+ @diff = nil
26
27
  @actual = JSON.parse(actual, symbolize_names: true)
27
- @diff = Diffy::Diff.new(expected, @actual, context: 5)
28
28
 
29
- return false unless @actual.instance_of?(expected.class)
30
-
31
- if expected.instance_of?(Array)
32
- RSpec::JsonApi::CompareArray.compare(@actual, expected)
33
- else
34
- return false unless @actual.deep_keys.deep_sort == expected.deep_keys.deep_sort
35
-
36
- RSpec::JsonApi::CompareHash.compare(@actual, expected)
37
- end
29
+ RSpec::JsonApi::SchemaMatch.match(@actual, expected)
30
+ rescue JSON::ParserError
31
+ @actual = actual
32
+ false
38
33
  end
39
34
 
40
35
  # Provides a failure message for when the JSON data does not match the expected schema.
@@ -45,7 +40,7 @@ module RSpec
45
40
  got: #{actual}
46
41
 
47
42
  Diff:
48
- #{@diff}
43
+ #{diff}
49
44
  MSG
50
45
  end
51
46
 
@@ -55,6 +50,14 @@ module RSpec
55
50
  def failure_message_when_negated
56
51
  "expected the JSON data not to match the provided schema, but it did."
57
52
  end
53
+
54
+ private
55
+
56
+ # The diff is only needed to render a failure message, so it is built
57
+ # lazily and memoized rather than on every matches? call.
58
+ def diff
59
+ @diff ||= Diffy::Diff.new(expected, actual, context: 5)
60
+ end
58
61
  end
59
62
  end
60
63
  end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpec
4
+ module JsonApi
5
+ # SchemaMatch compares parsed JSON (a Hash, an Array, or a scalar) against an
6
+ # expected schema. It is the single entry point behind the match_json_schema
7
+ # matcher: callers hand it the actual and expected values and it dispatches on
8
+ # shape internally, so the matcher does not need to know whether it is looking
9
+ # at an object or a collection.
10
+ module SchemaMatch
11
+ module_function
12
+
13
+ # Top-level comparison. Applies the shape guards (class equality and, for
14
+ # objects, key-set equality) before recursing.
15
+ def match(actual, expected)
16
+ return false unless actual.instance_of?(expected.class)
17
+
18
+ case expected
19
+ when Array
20
+ compare_array(actual, expected)
21
+ when Hash
22
+ return false unless same_key_structure?(actual, expected)
23
+
24
+ compare(actual, expected)
25
+ else
26
+ compare_simple_value(actual, expected)
27
+ end
28
+ end
29
+
30
+ def same_key_structure?(actual, expected)
31
+ Traversal.deep_sort(Traversal.deep_keys(actual)) ==
32
+ Traversal.deep_sort(Traversal.deep_keys(expected))
33
+ end
34
+
35
+ def compare(actual, expected)
36
+ return false if actual.blank? && expected.present?
37
+
38
+ keys = Traversal.deep_key_paths(expected) | Traversal.deep_key_paths(actual)
39
+
40
+ compare_key_paths_and_values(keys, actual, expected)
41
+ end
42
+
43
+ def compare_key_paths_and_values(keys, actual, expected)
44
+ keys.all? do |key_path|
45
+ actual_value = dig_path(actual, key_path)
46
+ expected_value = dig_path(expected, key_path)
47
+
48
+ compare_values(actual_value, expected_value)
49
+ end
50
+ end
51
+
52
+ # Digs a key path without raising when an intermediate value is not a Hash.
53
+ # Plain Hash#dig raises TypeError if it walks into a scalar (e.g. a schema
54
+ # expects a nested object but the actual value is a String), so a mismatch
55
+ # would crash instead of failing the match.
56
+ def dig_path(data, key_path)
57
+ key_path.reduce(data) do |value, key|
58
+ break nil unless value.is_a?(Hash)
59
+
60
+ value[key]
61
+ end
62
+ end
63
+
64
+ def compare_values(actual_value, expected_value)
65
+ case expected_value
66
+ when Class then compare_class(actual_value, expected_value)
67
+ when Regexp then compare_regexp(actual_value, expected_value)
68
+ when Proc then compare_proc(actual_value, expected_value)
69
+ when Array then compare_array(actual_value, expected_value)
70
+ else compare_simple_value(actual_value, expected_value)
71
+ end
72
+ end
73
+
74
+ def compare_class(actual_value, expected_value)
75
+ actual_value.instance_of?(expected_value)
76
+ end
77
+
78
+ def compare_regexp(actual_value, expected_value)
79
+ expected_value.match?(actual_value.to_s)
80
+ end
81
+
82
+ def compare_proc(actual_value, expected_value)
83
+ Constraints.match(actual_value, expected_value.call)
84
+ end
85
+
86
+ def compare_array(actual_value, expected_value)
87
+ if simple_type?(expected_value)
88
+ compare_typed_array(actual_value, expected_value)
89
+ elsif interface?(expected_value)
90
+ compare_interface_array(actual_value, expected_value)
91
+ else
92
+ compare_exact_array(actual_value, expected_value)
93
+ end
94
+ end
95
+
96
+ # [SomeClass] => every element must be an instance of SomeClass.
97
+ def compare_typed_array(actual_value, expected_value)
98
+ type = expected_value[0]
99
+
100
+ actual_value.all? { |elem| compare_class(elem, type) }
101
+ end
102
+
103
+ # [{ ...interface... }] => every element must match the single interface.
104
+ # Elements go through match (not compare) so each one is held to the same
105
+ # key-structure guard as a top-level object; otherwise an element with an
106
+ # extra null-valued key would slip through (nil == nil).
107
+ def compare_interface_array(actual_value, expected_value)
108
+ interface = expected_value[0]
109
+
110
+ actual_value.all? { |elem| match(elem, interface) }
111
+ end
112
+
113
+ # Any other array => element-by-element match, sizes must be equal.
114
+ def compare_exact_array(actual_value, expected_value)
115
+ return false if actual_value&.size != expected_value&.size
116
+
117
+ expected_value.each_with_index.all? do |elem, index|
118
+ elem.is_a?(Hash) ? compare(actual_value[index], elem) : compare_simple_value(actual_value[index], elem)
119
+ end
120
+ end
121
+
122
+ def compare_simple_value(actual_value, expected_value)
123
+ actual_value == expected_value
124
+ end
125
+
126
+ def simple_type?(expected_value)
127
+ expected_value.size == 1 && expected_value[0].instance_of?(Class)
128
+ end
129
+
130
+ def interface?(expected_value)
131
+ expected_value.size == 1 && expected_value[0].is_a?(Hash)
132
+ end
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpec
4
+ module JsonApi
5
+ # Traversal holds the structural helpers used to compare JSON shapes:
6
+ # collecting the nested key structure of a Hash and sorting it into a
7
+ # canonical order. These were previously monkey-patched onto core Hash and
8
+ # Array; keeping them here means the gem no longer mutates those classes in
9
+ # the host application.
10
+ module Traversal
11
+ module_function
12
+
13
+ # The nested keys of a hash, with each nested hash's keys inlined as an
14
+ # array, e.g. { a: 1, b: { c: 2 } } => [:a, :b, [:c]].
15
+ def deep_keys(hash)
16
+ hash.each_with_object([]) do |(key, value), keys|
17
+ keys << key
18
+ keys << deep_keys(value) if value.respond_to?(:keys)
19
+ end
20
+ end
21
+
22
+ # Every leaf key path of a hash, e.g. { a: { b: 1 }, c: 2 } => [[:a, :b], [:c]].
23
+ def deep_key_paths(hash)
24
+ stack = hash.map { |key, value| [[key], value] }
25
+ key_map = []
26
+
27
+ until stack.empty?
28
+ key, value = stack.pop
29
+
30
+ key_map << key unless value.is_a?(Hash)
31
+
32
+ next unless value.is_a?(Hash)
33
+
34
+ value.each { |k, v| stack.push([key.dup << k, v]) }
35
+ end
36
+
37
+ key_map.reverse
38
+ end
39
+
40
+ # Recursively sorts an array (and any nested arrays) into a canonical order
41
+ # so two key structures can be compared regardless of original order.
42
+ def deep_sort(array)
43
+ array
44
+ .map { |element| element.is_a?(Array) ? deep_sort(element) : element }
45
+ .sort_by { |element| element.is_a?(Array) ? element.first.to_s : element.to_s }
46
+ end
47
+ end
48
+ end
49
+ end
@@ -3,7 +3,7 @@
3
3
  module RSpec
4
4
  module JsonApi
5
5
  module Types
6
- 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}$/
6
+ 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/
7
7
  end
8
8
  end
9
9
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module RSpec
4
4
  module JsonApi
5
- VERSION = "1.3.1"
5
+ VERSION = "1.5.0"
6
6
  end
7
7
  end
@@ -1,18 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Load 3th party libraries
3
+ # Load 3rd party libraries
4
4
  require "json"
5
+ require "uri"
5
6
  require "diffy"
6
7
  require "active_support/core_ext/object/blank"
7
8
 
8
9
  # Load the json_api parts
9
10
  require "rspec/json_api/version"
10
- require "rspec/json_api/compare_hash"
11
- require "rspec/json_api/compare_array"
12
-
13
- # Load extentions
14
- require "extentions/hash"
15
- require "extentions/array"
11
+ require "rspec/json_api/traversal"
12
+ require "rspec/json_api/constraints"
13
+ require "rspec/json_api/schema_match"
16
14
 
17
15
  # Load matchers
18
16
  require "rspec/json_api/matchers"
@@ -11,11 +11,12 @@ Gem::Specification.new do |spec|
11
11
  spec.summary = "RSpec extension to test JSON API response."
12
12
  spec.homepage = "https://github.com/nomtek/rspec-json_api"
13
13
  spec.license = "MIT"
14
- spec.required_ruby_version = Gem::Requirement.new(">= 3.0.0")
14
+ spec.required_ruby_version = Gem::Requirement.new(">= 3.2.0")
15
15
 
16
16
  spec.metadata["homepage_uri"] = spec.homepage
17
17
  spec.metadata["source_code_uri"] = "https://github.com/nomtek/rspec-json_api"
18
18
  spec.metadata["changelog_uri"] = "https://github.com/nomtek/rspec-json_api/blob/master/CHANGELOG.md"
19
+ spec.metadata["rubygems_mfa_required"] = "true"
19
20
 
20
21
  # Specify which files should be added to the gem when it is released.
21
22
  # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
@@ -26,10 +27,14 @@ Gem::Specification.new do |spec|
26
27
  spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
27
28
  spec.require_paths = ["lib"]
28
29
 
29
- # Uncomment to register a new dependency of your gem
30
+ # Runtime dependencies. The gem only needs ActiveSupport's blank?/present?
31
+ # core extensions and Rails::Generators (which lives in railties); depending
32
+ # on the full "rails" meta-gem would force ActiveRecord, ActionCable,
33
+ # ActionMailer, ActionMailbox, ActiveStorage, ActionText, etc. on every
34
+ # consumer of a JSON-matcher gem. The >= 6.1.4.1 floor is unchanged.
30
35
  spec.add_dependency "activesupport", ">= 6.1.4.1"
31
36
  spec.add_dependency "diffy", ">= 3.4.2"
32
- spec.add_dependency "rails", ">= 6.1.4.1"
37
+ spec.add_dependency "railties", ">= 6.1.4.1"
33
38
  spec.add_dependency "rspec-rails", ">= 5.0.2"
34
39
 
35
40
  # For more information and examples about making a new gem, checkout our
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rspec-json_api
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.1
4
+ version: 1.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michal Gajowiak
8
- autorequire:
9
8
  bindir: exe
10
9
  cert_chain: []
11
- date: 2024-02-26 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: activesupport
@@ -39,7 +38,7 @@ dependencies:
39
38
  - !ruby/object:Gem::Version
40
39
  version: 3.4.2
41
40
  - !ruby/object:Gem::Dependency
42
- name: rails
41
+ name: railties
43
42
  requirement: !ruby/object:Gem::Requirement
44
43
  requirements:
45
44
  - - ">="
@@ -66,13 +65,13 @@ dependencies:
66
65
  - - ">="
67
66
  - !ruby/object:Gem::Version
68
67
  version: 5.0.2
69
- description:
70
68
  email:
71
69
  - m.gajowiak@nomtek.com
72
70
  executables: []
73
71
  extensions: []
74
72
  extra_rdoc_files: []
75
73
  files:
74
+ - ".gitattributes"
76
75
  - ".github/workflows/main.yml"
77
76
  - ".gitignore"
78
77
  - ".rspec"
@@ -87,21 +86,24 @@ files:
87
86
  - Rakefile
88
87
  - bin/console
89
88
  - bin/setup
90
- - lib/extentions/array.rb
91
- - lib/extentions/hash.rb
89
+ - gemfiles/rails_6_1.gemfile
90
+ - gemfiles/rails_7_1.gemfile
91
+ - gemfiles/rails_7_2.gemfile
92
+ - gemfiles/rails_8_0.gemfile
93
+ - gemfiles/rails_8_1.gemfile
92
94
  - lib/generators/rspec/json_api/install/install_generator.rb
93
95
  - lib/generators/rspec/json_api/install/templates/rspec/json_api/interfaces/.empty_directory
94
96
  - lib/generators/rspec/json_api/install/templates/rspec/json_api/types/.empty_directory
95
97
  - lib/generators/rspec/json_api/interface/interface_generator.rb
96
- - lib/generators/rspec/json_api/interface/templates/interface.erb
97
98
  - lib/generators/rspec/json_api/type/type_generator.rb
98
99
  - lib/rspec/json_api.rb
99
- - lib/rspec/json_api/compare_array.rb
100
- - lib/rspec/json_api/compare_hash.rb
100
+ - lib/rspec/json_api/constraints.rb
101
101
  - lib/rspec/json_api/interfaces/example_interface.rb
102
102
  - lib/rspec/json_api/matchers.rb
103
103
  - lib/rspec/json_api/matchers/have_no_content.rb
104
104
  - lib/rspec/json_api/matchers/match_json_schema.rb
105
+ - lib/rspec/json_api/schema_match.rb
106
+ - lib/rspec/json_api/traversal.rb
105
107
  - lib/rspec/json_api/types/email.rb
106
108
  - lib/rspec/json_api/types/uri.rb
107
109
  - lib/rspec/json_api/types/uuid.rb
@@ -114,7 +116,7 @@ metadata:
114
116
  homepage_uri: https://github.com/nomtek/rspec-json_api
115
117
  source_code_uri: https://github.com/nomtek/rspec-json_api
116
118
  changelog_uri: https://github.com/nomtek/rspec-json_api/blob/master/CHANGELOG.md
117
- post_install_message:
119
+ rubygems_mfa_required: 'true'
118
120
  rdoc_options: []
119
121
  require_paths:
120
122
  - lib
@@ -122,15 +124,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
122
124
  requirements:
123
125
  - - ">="
124
126
  - !ruby/object:Gem::Version
125
- version: 3.0.0
127
+ version: 3.2.0
126
128
  required_rubygems_version: !ruby/object:Gem::Requirement
127
129
  requirements:
128
130
  - - ">="
129
131
  - !ruby/object:Gem::Version
130
132
  version: '0'
131
133
  requirements: []
132
- rubygems_version: 3.4.10
133
- signing_key:
134
+ rubygems_version: 4.0.3
134
135
  specification_version: 4
135
136
  summary: RSpec extension to test JSON API response.
136
137
  test_files: []
@@ -1,8 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- class Array
4
- def deep_sort
5
- map { |element| element.is_a?(Array) ? element.deep_sort : element }
6
- .sort_by { |el| el.is_a?(Array) ? el.first.to_s : el.to_s }
7
- end
8
- end
@@ -1,36 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # Extension methods for hash class
4
- class Hash
5
- def deep_keys
6
- each_with_object([]) do |(k, v), keys|
7
- keys << k
8
- keys << v.deep_keys if v.respond_to?(:keys)
9
- end
10
- end
11
-
12
- def deep_key_paths
13
- stack = map { |k, v| [[k], v] }
14
- key_map = []
15
-
16
- until stack.empty?
17
- key, value = stack.pop
18
-
19
- key_map << key unless value.is_a? Hash
20
-
21
- next unless value.is_a? Hash
22
-
23
- value.map do |k, v|
24
- stack.push [key.dup << k, v]
25
- end
26
- end
27
-
28
- key_map.reverse
29
- end
30
-
31
- def sanitize!(keys)
32
- keep_if do |k, _v|
33
- keys.include?(k)
34
- end
35
- end
36
- end
@@ -1,9 +0,0 @@
1
- <%= module RSpec %>
2
- <%= module JsonApi %>
3
- <%= module Interfaces %>
4
- <%= const_set(file_name, {
5
-
6
- }.freeze) %>
7
- <%= end %>
8
- <%= end %>
9
- <%= end %>
@@ -1,39 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module RSpec
4
- module JsonApi
5
- module CompareArray
6
- extend self
7
-
8
- def compare(actual, expected)
9
- if interface?(expected)
10
- actual.all? do |actual_elem|
11
- return false unless actual_elem.deep_keys == expected[0].deep_keys
12
-
13
- CompareHash.compare(actual_elem, expected[0])
14
- end
15
- else
16
- actual.each_with_index.all? do |actual_elem, index|
17
- compare_primitive_type_element(actual, expected, actual_elem, index)
18
- end
19
- end
20
- end
21
-
22
- private
23
-
24
- def interface?(expected_value)
25
- expected_value.size == 1 && expected_value[0].is_a?(Hash)
26
- end
27
-
28
- def compare_primitive_type_element(actual, expected, actual_elem, index)
29
- if actual[index].respond_to?(:deep_keys) && expected[index].respond_to?(:deep_keys)
30
- return false unless actual[index].deep_keys == expected[index].deep_keys
31
-
32
- CompareHash.compare(actual_elem, expected[index])
33
- else
34
- CompareHash.compare_values(actual[index], expected[index])
35
- end
36
- end
37
- end
38
- end
39
- end