api_matchers 0.6.2 → 1.0.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.
Files changed (116) hide show
  1. checksums.yaml +5 -5
  2. data/.github/workflows/ci.yml +30 -0
  3. data/.gitignore +1 -0
  4. data/Gemfile +1 -1
  5. data/History.markdown +62 -50
  6. data/README.markdown +1070 -114
  7. data/TODO.markdown +39 -3
  8. data/api_matchers.gemspec +13 -6
  9. data/lib/api_matchers/collection/base.rb +65 -0
  10. data/lib/api_matchers/collection/be_sorted_by.rb +97 -0
  11. data/lib/api_matchers/collection/have_json_size.rb +54 -0
  12. data/lib/api_matchers/core/exceptions.rb +5 -0
  13. data/lib/api_matchers/core/find_in_json.rb +57 -28
  14. data/lib/api_matchers/core/http_status_codes.rb +118 -0
  15. data/lib/api_matchers/core/json_path_finder.rb +87 -0
  16. data/lib/api_matchers/core/parser.rb +4 -2
  17. data/lib/api_matchers/core/rspec_matchers.rb +130 -37
  18. data/lib/api_matchers/core/setup.rb +49 -23
  19. data/lib/api_matchers/core/value_normalizer.rb +26 -0
  20. data/lib/api_matchers/error_response/base.rb +77 -0
  21. data/lib/api_matchers/error_response/have_error.rb +69 -0
  22. data/lib/api_matchers/error_response/have_error_on.rb +173 -0
  23. data/lib/api_matchers/hateoas/base.rb +77 -0
  24. data/lib/api_matchers/hateoas/have_link.rb +102 -0
  25. data/lib/api_matchers/headers/base.rb +7 -7
  26. data/lib/api_matchers/headers/be_json.rb +2 -4
  27. data/lib/api_matchers/headers/be_xml.rb +2 -4
  28. data/lib/api_matchers/headers/have_cache_control.rb +90 -0
  29. data/lib/api_matchers/headers/have_cors_headers.rb +102 -0
  30. data/lib/api_matchers/headers/have_header.rb +102 -0
  31. data/lib/api_matchers/http_status/base.rb +48 -0
  32. data/lib/api_matchers/http_status/be_client_error.rb +17 -0
  33. data/lib/api_matchers/http_status/be_forbidden.rb +17 -0
  34. data/lib/api_matchers/http_status/be_no_content.rb +17 -0
  35. data/lib/api_matchers/http_status/be_not_found.rb +17 -0
  36. data/lib/api_matchers/http_status/be_redirect.rb +17 -0
  37. data/lib/api_matchers/http_status/be_server_error.rb +17 -0
  38. data/lib/api_matchers/http_status/be_successful.rb +17 -0
  39. data/lib/api_matchers/http_status/be_unauthorized.rb +17 -0
  40. data/lib/api_matchers/http_status/be_unprocessable.rb +17 -0
  41. data/lib/api_matchers/http_status/have_http_status.rb +39 -0
  42. data/lib/api_matchers/json_api/base.rb +83 -0
  43. data/lib/api_matchers/json_api/be_json_api_compliant.rb +158 -0
  44. data/lib/api_matchers/json_api/have_json_api_attributes.rb +62 -0
  45. data/lib/api_matchers/json_api/have_json_api_data.rb +110 -0
  46. data/lib/api_matchers/json_api/have_json_api_relationships.rb +62 -0
  47. data/lib/api_matchers/json_structure/base.rb +65 -0
  48. data/lib/api_matchers/json_structure/have_json_keys.rb +55 -0
  49. data/lib/api_matchers/json_structure/have_json_type.rb +72 -0
  50. data/lib/api_matchers/pagination/base.rb +73 -0
  51. data/lib/api_matchers/pagination/be_paginated.rb +73 -0
  52. data/lib/api_matchers/pagination/have_pagination_links.rb +74 -0
  53. data/lib/api_matchers/pagination/have_total_count.rb +77 -0
  54. data/lib/api_matchers/response_body/base.rb +10 -9
  55. data/lib/api_matchers/response_body/have_json.rb +2 -4
  56. data/lib/api_matchers/response_body/have_json_node.rb +45 -1
  57. data/lib/api_matchers/response_body/have_node.rb +2 -0
  58. data/lib/api_matchers/response_body/have_xml_node.rb +13 -14
  59. data/lib/api_matchers/response_body/match_json_schema.rb +89 -0
  60. data/lib/api_matchers/version.rb +3 -1
  61. data/lib/api_matchers.rb +75 -14
  62. data/spec/api_matchers/collection/be_sorted_by_spec.rb +110 -0
  63. data/spec/api_matchers/collection/have_json_size_spec.rb +101 -0
  64. data/spec/api_matchers/error_response/have_error_on_spec.rb +123 -0
  65. data/spec/api_matchers/error_response/have_error_spec.rb +108 -0
  66. data/spec/api_matchers/hateoas/have_link_spec.rb +105 -0
  67. data/spec/api_matchers/headers/base_spec.rb +8 -3
  68. data/spec/api_matchers/headers/be_json_spec.rb +1 -1
  69. data/spec/api_matchers/headers/be_xml_spec.rb +1 -1
  70. data/spec/api_matchers/headers/have_cache_control_spec.rb +102 -0
  71. data/spec/api_matchers/headers/have_cors_headers_spec.rb +74 -0
  72. data/spec/api_matchers/headers/have_header_spec.rb +88 -0
  73. data/spec/api_matchers/http_status/be_client_error_spec.rb +53 -0
  74. data/spec/api_matchers/http_status/be_forbidden_spec.rb +33 -0
  75. data/spec/api_matchers/http_status/be_no_content_spec.rb +33 -0
  76. data/spec/api_matchers/http_status/be_not_found_spec.rb +39 -0
  77. data/spec/api_matchers/http_status/be_redirect_spec.rb +55 -0
  78. data/spec/api_matchers/http_status/be_server_error_spec.rb +49 -0
  79. data/spec/api_matchers/http_status/be_successful_spec.rb +78 -0
  80. data/spec/api_matchers/http_status/be_unauthorized_spec.rb +33 -0
  81. data/spec/api_matchers/http_status/be_unprocessable_spec.rb +39 -0
  82. data/spec/api_matchers/http_status/have_http_status_spec.rb +81 -0
  83. data/spec/api_matchers/json_api/be_json_api_compliant_spec.rb +109 -0
  84. data/spec/api_matchers/json_api/have_json_api_attributes_spec.rb +61 -0
  85. data/spec/api_matchers/json_api/have_json_api_data_spec.rb +95 -0
  86. data/spec/api_matchers/json_api/have_json_api_relationships_spec.rb +61 -0
  87. data/spec/api_matchers/json_structure/have_json_keys_spec.rb +81 -0
  88. data/spec/api_matchers/json_structure/have_json_type_spec.rb +134 -0
  89. data/spec/api_matchers/pagination/be_paginated_spec.rb +95 -0
  90. data/spec/api_matchers/pagination/have_pagination_links_spec.rb +80 -0
  91. data/spec/api_matchers/pagination/have_total_count_spec.rb +85 -0
  92. data/spec/api_matchers/response_body/base_spec.rb +15 -7
  93. data/spec/api_matchers/response_body/have_json_node_spec.rb +57 -0
  94. data/spec/api_matchers/response_body/match_json_schema_spec.rb +86 -0
  95. metadata +152 -47
  96. data/.rvmrc.example +0 -1
  97. data/.travis.yml +0 -12
  98. data/Gemfile.lock +0 -46
  99. data/lib/api_matchers/http_status_code/base.rb +0 -32
  100. data/lib/api_matchers/http_status_code/be_bad_request.rb +0 -25
  101. data/lib/api_matchers/http_status_code/be_forbidden.rb +0 -21
  102. data/lib/api_matchers/http_status_code/be_internal_server_error.rb +0 -25
  103. data/lib/api_matchers/http_status_code/be_not_found.rb +0 -25
  104. data/lib/api_matchers/http_status_code/be_ok.rb +0 -25
  105. data/lib/api_matchers/http_status_code/be_unauthorized.rb +0 -25
  106. data/lib/api_matchers/http_status_code/be_unprocessable_entity.rb +0 -25
  107. data/lib/api_matchers/http_status_code/create_resource.rb +0 -25
  108. data/spec/api_matchers/http_status_code/base_spec.rb +0 -12
  109. data/spec/api_matchers/http_status_code/be_bad_request_spec.rb +0 -49
  110. data/spec/api_matchers/http_status_code/be_forbidden_spec.rb +0 -49
  111. data/spec/api_matchers/http_status_code/be_internal_server_error_spec.rb +0 -49
  112. data/spec/api_matchers/http_status_code/be_not_found_spec.rb +0 -49
  113. data/spec/api_matchers/http_status_code/be_ok_spec.rb +0 -49
  114. data/spec/api_matchers/http_status_code/be_unauthorized_spec.rb +0 -49
  115. data/spec/api_matchers/http_status_code/be_unprocessable_entity_spec.rb +0 -27
  116. data/spec/api_matchers/http_status_code/create_resource_spec.rb +0 -49
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module APIMatchers
4
+ module JsonStructure
5
+ class HaveJsonType < Base
6
+ TYPE_MAP = {
7
+ Integer => [Integer],
8
+ Float => [Float],
9
+ Numeric => [Integer, Float],
10
+ String => [String],
11
+ TrueClass => [TrueClass],
12
+ FalseClass => [FalseClass],
13
+ :boolean => [TrueClass, FalseClass],
14
+ Array => [Array],
15
+ Hash => [Hash],
16
+ NilClass => [NilClass]
17
+ }.freeze
18
+
19
+ def initialize(expected_type)
20
+ @expected_type = expected_type
21
+ @path = nil
22
+ end
23
+
24
+ def at_path(path)
25
+ @path = path
26
+ self
27
+ end
28
+
29
+ def failure_message
30
+ "expected JSON value at '#{@path || 'root'}' to be of type #{expected_type_name}. " \
31
+ "Got: #{@value.inspect} (#{@value.class})"
32
+ end
33
+
34
+ def failure_message_when_negated
35
+ "expected JSON value at '#{@path || 'root'}' NOT to be of type #{expected_type_name}. " \
36
+ "Got: #{@value.inspect} (#{@value.class})"
37
+ end
38
+
39
+ def description
40
+ desc = "have JSON type #{expected_type_name}"
41
+ desc += " at path '#{@path}'" if @path
42
+ desc
43
+ end
44
+
45
+ protected
46
+
47
+ def perform_match
48
+ @value = json_at_path(@path)
49
+ type_matches?
50
+ rescue ::APIMatchers::Core::Exceptions::PathNotFound
51
+ @value = nil
52
+ false
53
+ end
54
+
55
+ private
56
+
57
+ def type_matches?
58
+ allowed_types = TYPE_MAP[@expected_type] || [@expected_type]
59
+ allowed_types.any? { |type| @value.is_a?(type) }
60
+ end
61
+
62
+ def expected_type_name
63
+ case @expected_type
64
+ when :boolean
65
+ "Boolean"
66
+ else
67
+ @expected_type.to_s
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module APIMatchers
6
+ module Pagination
7
+ class Base
8
+ attr_reader :actual
9
+
10
+ def setup
11
+ ::APIMatchers::Core::Setup
12
+ end
13
+
14
+ def matches?(actual)
15
+ @actual = actual
16
+ perform_match
17
+ end
18
+
19
+ def failure_message
20
+ raise NotImplementedError, "Subclasses must implement #failure_message"
21
+ end
22
+
23
+ def failure_message_when_negated
24
+ raise NotImplementedError, "Subclasses must implement #failure_message_when_negated"
25
+ end
26
+
27
+ protected
28
+
29
+ def perform_match
30
+ raise NotImplementedError, "Subclasses must implement #perform_match"
31
+ end
32
+
33
+ def response_body
34
+ if setup.response_body_method.present?
35
+ @actual.send(setup.response_body_method)
36
+ else
37
+ @actual
38
+ end
39
+ end
40
+
41
+ def parsed_json
42
+ @parsed_json ||= begin
43
+ body = response_body
44
+ case body
45
+ when Hash, Array
46
+ body
47
+ when String
48
+ JSON.parse(body)
49
+ else
50
+ raise ::APIMatchers::InvalidJSON, "Invalid JSON: '#{body}'"
51
+ end
52
+ end
53
+ rescue JSON::ParserError
54
+ raise ::APIMatchers::InvalidJSON, "Invalid JSON: '#{response_body}'"
55
+ end
56
+
57
+ def json_at_path(path)
58
+ return parsed_json if path.nil? || path.empty?
59
+
60
+ finder = ::APIMatchers::Core::JsonPathFinder.new(parsed_json)
61
+ finder.find(path)
62
+ end
63
+
64
+ def meta_path
65
+ setup.pagination_meta_path || 'meta'
66
+ end
67
+
68
+ def links_path
69
+ setup.pagination_links_path || 'links'
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module APIMatchers
4
+ module Pagination
5
+ class BePaginated < Base
6
+ COMMON_PAGINATION_KEYS = %w[
7
+ page per_page total total_pages total_count
8
+ current_page next_page prev_page
9
+ offset limit count
10
+ ].freeze
11
+
12
+ def failure_message
13
+ "expected response to be paginated (have pagination metadata). " \
14
+ "Looked for pagination keys at '#{meta_path}' and '#{links_path}'. " \
15
+ "Got: #{truncate_json(parsed_json)}"
16
+ end
17
+
18
+ def failure_message_when_negated
19
+ "expected response NOT to be paginated, but pagination metadata was found"
20
+ end
21
+
22
+ def description
23
+ "be paginated"
24
+ end
25
+
26
+ protected
27
+
28
+ def perform_match
29
+ has_meta_pagination? || has_links_pagination? || has_root_pagination?
30
+ end
31
+
32
+ private
33
+
34
+ def has_meta_pagination?
35
+ meta = safe_json_at_path(meta_path)
36
+ return false unless meta.is_a?(Hash)
37
+
38
+ has_pagination_keys?(meta)
39
+ end
40
+
41
+ def has_links_pagination?
42
+ links = safe_json_at_path(links_path)
43
+ return false unless links.is_a?(Hash)
44
+
45
+ # Check for common link keys like next, prev, first, last
46
+ link_keys = links.keys.map(&:to_s).map(&:downcase)
47
+ (link_keys & %w[next prev previous first last self]).any?
48
+ end
49
+
50
+ def has_root_pagination?
51
+ return false unless parsed_json.is_a?(Hash)
52
+
53
+ has_pagination_keys?(parsed_json)
54
+ end
55
+
56
+ def has_pagination_keys?(hash)
57
+ keys = hash.keys.map(&:to_s).map(&:downcase)
58
+ (keys & COMMON_PAGINATION_KEYS).any?
59
+ end
60
+
61
+ def safe_json_at_path(path)
62
+ json_at_path(path)
63
+ rescue ::APIMatchers::Core::Exceptions::PathNotFound
64
+ nil
65
+ end
66
+
67
+ def truncate_json(json)
68
+ str = json.to_json
69
+ str.length > 200 ? "#{str[0..200]}..." : str
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module APIMatchers
4
+ module Pagination
5
+ class HavePaginationLinks < Base
6
+ VALID_LINK_TYPES = %w[next prev previous first last self].freeze
7
+
8
+ def initialize(*link_types)
9
+ @expected_links = normalize_link_types(link_types.flatten)
10
+ end
11
+
12
+ def failure_message
13
+ links = safe_json_at_path(links_path)
14
+
15
+ if links.nil?
16
+ "expected response to have pagination links at '#{links_path}', " \
17
+ "but no links were found. Got: #{truncate_json(parsed_json)}"
18
+ else
19
+ missing = @expected_links - actual_link_types(links)
20
+ "expected pagination links to include #{@expected_links.inspect}. " \
21
+ "Missing: #{missing.inspect}. Available: #{actual_link_types(links).inspect}"
22
+ end
23
+ end
24
+
25
+ def failure_message_when_negated
26
+ "expected response NOT to have pagination links #{@expected_links.inspect}, " \
27
+ "but they were present"
28
+ end
29
+
30
+ def description
31
+ "have pagination links #{@expected_links.inspect}"
32
+ end
33
+
34
+ protected
35
+
36
+ def perform_match
37
+ links = safe_json_at_path(links_path)
38
+ return false unless links.is_a?(Hash)
39
+
40
+ actual_links = actual_link_types(links)
41
+ @expected_links.all? { |link| actual_links.include?(link) }
42
+ end
43
+
44
+ private
45
+
46
+ def normalize_link_types(types)
47
+ types.map do |t|
48
+ normalized = t.to_s.downcase
49
+ normalized = 'prev' if normalized == 'previous'
50
+ normalized
51
+ end
52
+ end
53
+
54
+ def actual_link_types(links)
55
+ links.keys.map do |k|
56
+ normalized = k.to_s.downcase
57
+ normalized = 'prev' if normalized == 'previous'
58
+ normalized
59
+ end
60
+ end
61
+
62
+ def safe_json_at_path(path)
63
+ json_at_path(path)
64
+ rescue ::APIMatchers::Core::Exceptions::PathNotFound
65
+ nil
66
+ end
67
+
68
+ def truncate_json(json)
69
+ str = json.to_json
70
+ str.length > 200 ? "#{str[0..200]}..." : str
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module APIMatchers
4
+ module Pagination
5
+ class HaveTotalCount < Base
6
+ TOTAL_COUNT_KEYS = %w[total total_count totalCount count total_items totalItems].freeze
7
+
8
+ def initialize(expected_count)
9
+ @expected_count = expected_count
10
+ end
11
+
12
+ def failure_message
13
+ if @actual_count.nil?
14
+ "expected response to have total count of #{@expected_count}, " \
15
+ "but no total count field was found. " \
16
+ "Looked for keys: #{TOTAL_COUNT_KEYS.inspect} at '#{meta_path}' and root level. " \
17
+ "Got: #{truncate_json(parsed_json)}"
18
+ else
19
+ "expected total count to be #{@expected_count}. Got: #{@actual_count}"
20
+ end
21
+ end
22
+
23
+ def failure_message_when_negated
24
+ "expected total count NOT to be #{@expected_count}, but it was"
25
+ end
26
+
27
+ def description
28
+ "have total count of #{@expected_count}"
29
+ end
30
+
31
+ protected
32
+
33
+ def perform_match
34
+ @actual_count = find_total_count
35
+ @actual_count == @expected_count
36
+ end
37
+
38
+ private
39
+
40
+ def find_total_count
41
+ # First, try meta path
42
+ meta = safe_json_at_path(meta_path)
43
+ if meta.is_a?(Hash)
44
+ count = find_count_in_hash(meta)
45
+ return count unless count.nil?
46
+ end
47
+
48
+ # Then try root level
49
+ if parsed_json.is_a?(Hash)
50
+ count = find_count_in_hash(parsed_json)
51
+ return count unless count.nil?
52
+ end
53
+
54
+ nil
55
+ end
56
+
57
+ def find_count_in_hash(hash)
58
+ TOTAL_COUNT_KEYS.each do |key|
59
+ value = hash[key] || hash[key.to_sym]
60
+ return value.to_i if value
61
+ end
62
+ nil
63
+ end
64
+
65
+ def safe_json_at_path(path)
66
+ json_at_path(path)
67
+ rescue ::APIMatchers::Core::Exceptions::PathNotFound
68
+ nil
69
+ end
70
+
71
+ def truncate_json(json)
72
+ str = json.to_json
73
+ str.length > 200 ? "#{str[0..200]}..." : str
74
+ end
75
+ end
76
+ end
77
+ end
@@ -1,12 +1,17 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module APIMatchers
2
4
  module ResponseBody
3
5
  class Base
4
- attr_reader :setup, :expected_node, :actual
6
+ attr_reader :expected_node, :actual
5
7
  attr_writer :actual
6
8
 
7
- def initialize(options={})
9
+ def initialize(options = {})
8
10
  @expected_node = options.fetch(:expected_node)
9
- @setup = options.fetch(:setup)
11
+ end
12
+
13
+ def setup
14
+ ::APIMatchers::Core::Setup
10
15
  end
11
16
 
12
17
  def matches?(actual)
@@ -24,8 +29,8 @@ module APIMatchers
24
29
  end
25
30
 
26
31
  def response_body
27
- if @setup.response_body_method.present?
28
- @actual.send(@setup.response_body_method)
32
+ if setup.response_body_method.present?
33
+ @actual.send(setup.response_body_method)
29
34
  else
30
35
  @actual
31
36
  end
@@ -39,10 +44,6 @@ module APIMatchers
39
44
  "expected to NOT have node called: '#{@expected_node}'" << added_message << ". Got: '#{response_body}'"
40
45
  end
41
46
 
42
- # RSpec 2 compatibility:
43
- alias_method :failure_message_for_should, :failure_message
44
- alias_method :failure_message_for_should_not, :failure_message_when_negated
45
-
46
47
  def added_message
47
48
  if @with_value
48
49
  " with value: '#{@with_value}'"
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module APIMatchers
2
4
  module ResponseBody
3
5
  class HaveJson
@@ -21,10 +23,6 @@ module APIMatchers
21
23
  def failure_message_when_negated
22
24
  "expect to NOT have json: '#{response_body}'."
23
25
  end
24
-
25
- # RSpec 2 compatibility:
26
- alias_method :failure_message_for_should, :failure_message
27
- alias_method :failure_message_for_should_not, :failure_message_when_negated
28
26
  end
29
27
  end
30
28
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'json'
2
4
  require 'active_support/core_ext/hash'
3
5
 
@@ -10,10 +12,15 @@ module APIMatchers
10
12
  @actual = actual
11
13
 
12
14
  begin
13
- node = Core::FindInJSON.new(json).find(node: @expected_node.to_s, value: @with_value)
15
+ @finder = Core::FindInJSON.new(json)
16
+ node = @finder.find(node: @expected_node.to_s, value: @with_value)
14
17
 
15
18
  if @expected_including_text
16
19
  node.to_s.include?(@expected_including_text)
20
+ elsif @including_matcher
21
+ match_including(node)
22
+ elsif @including_all_matcher
23
+ match_including_all(node)
17
24
  else
18
25
  true # the node is present
19
26
  end
@@ -21,6 +28,43 @@ module APIMatchers
21
28
  false # the key was not found
22
29
  end
23
30
  end
31
+
32
+ def including(expected_element)
33
+ @including_matcher = expected_element
34
+ self
35
+ end
36
+
37
+ def including_all(expected_elements)
38
+ @including_all_matcher = expected_elements
39
+ self
40
+ end
41
+
42
+ private
43
+
44
+ def match_including(node)
45
+ return false unless node.is_a?(Array)
46
+
47
+ node.any? { |element| element_matches?(element, @including_matcher) }
48
+ end
49
+
50
+ def match_including_all(node)
51
+ return false unless node.is_a?(Array)
52
+
53
+ @including_all_matcher.all? do |expected|
54
+ node.any? { |element| element_matches?(element, expected) }
55
+ end
56
+ end
57
+
58
+ def element_matches?(element, expected)
59
+ case expected
60
+ when Hash
61
+ expected.all? do |key, value|
62
+ element.is_a?(Hash) && element[key.to_s] == value
63
+ end
64
+ else
65
+ element == expected
66
+ end
67
+ end
24
68
  end
25
69
  end
26
70
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module APIMatchers
2
4
  module ResponseBody
3
5
  class HaveNode
@@ -1,29 +1,28 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'nokogiri'
2
4
 
3
5
  module APIMatchers
4
6
  module ResponseBody
5
7
  class HaveXmlNode < Base
6
8
  def matches?(actual)
7
- value = false
8
9
  @actual = actual
9
10
  xml = Nokogiri::XML(response_body)
10
11
 
11
12
  node_set = xml.xpath("//#{@expected_node}")
12
- if node_set
13
- node_set.each do |node|
14
- if @with_value
15
- value = (node.text == @with_value.to_s)
16
- elsif @expected_including_text
17
- value = (node.text.to_s.include?(@expected_including_text))
18
- else
19
- value = node.text.present?
20
- end
21
- # if value is true, time to return
22
- return value if value
13
+ return false unless node_set
14
+
15
+ node_set.each do |node|
16
+ if @with_value
17
+ return true if node.text == @with_value.to_s
18
+ elsif @expected_including_text
19
+ return true if node.text.to_s.include?(@expected_including_text)
20
+ else
21
+ return true if node.text.present?
23
22
  end
24
23
  end
25
- # at this point, it failed to match
26
- return value
24
+
25
+ false
27
26
  end
28
27
  end
29
28
  end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module APIMatchers
6
+ module ResponseBody
7
+ class MatchJsonSchema
8
+ attr_reader :schema, :actual
9
+
10
+ def initialize(options = {})
11
+ @schema = options.fetch(:schema)
12
+ @errors = []
13
+ end
14
+
15
+ def setup
16
+ ::APIMatchers::Core::Setup
17
+ end
18
+
19
+ def matches?(actual)
20
+ @actual = actual
21
+
22
+ unless json_schemer_available?
23
+ raise LoadError, "json_schemer gem is required for JSON schema validation. Add it to your Gemfile: gem 'json_schemer'"
24
+ end
25
+
26
+ schemer = JSONSchemer.schema(normalize_schema(@schema))
27
+ result = schemer.validate(parsed_json)
28
+ @errors = result.to_a
29
+
30
+ @errors.empty?
31
+ end
32
+
33
+ def failure_message
34
+ error_details = @errors.first(5).map do |error|
35
+ " - #{error['error']} at #{error['data_pointer'].presence || 'root'}"
36
+ end.join("\n")
37
+
38
+ more = @errors.size > 5 ? "\n ... and #{@errors.size - 5} more errors" : ""
39
+
40
+ "Expected JSON to match schema.\nErrors:\n#{error_details}#{more}\n\nResponse: #{response_body}"
41
+ end
42
+
43
+ def failure_message_when_negated
44
+ "Expected JSON to NOT match schema, but it did.\n\nResponse: #{response_body}"
45
+ end
46
+
47
+ private
48
+
49
+ def json_schemer_available?
50
+ require 'json_schemer'
51
+ true
52
+ rescue LoadError
53
+ false
54
+ end
55
+
56
+ def response_body
57
+ if setup.response_body_method.present?
58
+ @actual.send(setup.response_body_method)
59
+ else
60
+ @actual
61
+ end
62
+ end
63
+
64
+ def parsed_json
65
+ body = response_body
66
+ body.is_a?(String) ? JSON.parse(body) : body
67
+ rescue JSON::ParserError => e
68
+ raise ::APIMatchers::InvalidJSON, "Invalid JSON: '#{body}'"
69
+ end
70
+
71
+ def normalize_schema(schema)
72
+ case schema
73
+ when Hash
74
+ deep_stringify_keys(schema)
75
+ when String
76
+ JSON.parse(schema)
77
+ else
78
+ schema
79
+ end
80
+ end
81
+
82
+ def deep_stringify_keys(hash)
83
+ hash.each_with_object({}) do |(key, value), result|
84
+ result[key.to_s] = value.is_a?(Hash) ? deep_stringify_keys(value) : value
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module APIMatchers
2
- VERSION = '0.6.2'
4
+ VERSION = '1.0.0'
3
5
  end