api_matchers 0.6.1 → 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 (115) hide show
  1. checksums.yaml +5 -5
  2. data/.github/workflows/ci.yml +30 -0
  3. data/.gitignore +1 -1
  4. data/Gemfile +1 -1
  5. data/History.markdown +62 -46
  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 +154 -48
  96. data/.rvmrc.example +0 -1
  97. data/.travis.yml +0 -12
  98. data/lib/api_matchers/http_status_code/base.rb +0 -32
  99. data/lib/api_matchers/http_status_code/be_bad_request.rb +0 -25
  100. data/lib/api_matchers/http_status_code/be_forbidden.rb +0 -21
  101. data/lib/api_matchers/http_status_code/be_internal_server_error.rb +0 -25
  102. data/lib/api_matchers/http_status_code/be_not_found.rb +0 -25
  103. data/lib/api_matchers/http_status_code/be_ok.rb +0 -25
  104. data/lib/api_matchers/http_status_code/be_unauthorized.rb +0 -25
  105. data/lib/api_matchers/http_status_code/be_unprocessable_entity.rb +0 -25
  106. data/lib/api_matchers/http_status_code/create_resource.rb +0 -25
  107. data/spec/api_matchers/http_status_code/base_spec.rb +0 -12
  108. data/spec/api_matchers/http_status_code/be_bad_request_spec.rb +0 -49
  109. data/spec/api_matchers/http_status_code/be_forbidden_spec.rb +0 -49
  110. data/spec/api_matchers/http_status_code/be_internal_server_error_spec.rb +0 -49
  111. data/spec/api_matchers/http_status_code/be_not_found_spec.rb +0 -49
  112. data/spec/api_matchers/http_status_code/be_ok_spec.rb +0 -49
  113. data/spec/api_matchers/http_status_code/be_unauthorized_spec.rb +0 -49
  114. data/spec/api_matchers/http_status_code/be_unprocessable_entity_spec.rb +0 -27
  115. data/spec/api_matchers/http_status_code/create_resource_spec.rb +0 -49
data/TODO.markdown CHANGED
@@ -1,3 +1,39 @@
1
- * Refactor Core::FindInJSON#find method.
2
- * Improve the error messages from all matchers.
3
- * Adding some include matcher about node arrays.
1
+ # TODO
2
+
3
+ ## Future Improvements
4
+
5
+ ### New Matchers
6
+ - [ ] `include_json` - Deep partial JSON matching for flexible assertions
7
+ - [ ] `have_json_path` - Verify a JSON path exists without checking value
8
+ - [ ] `match_json_pattern` - Pattern-based JSON matching with wildcards
9
+ - [ ] `be_valid_uuid` - Validate UUID format in responses
10
+ - [ ] `be_valid_iso8601` - Validate ISO 8601 date/time formats
11
+ - [ ] `have_etag` - Check for ETag header presence and format
12
+ - [ ] `be_gzip_encoded` - Verify response compression
13
+
14
+ ### Enhancements
15
+ - [ ] Add `at_index` chain for collection matchers (e.g., `have_json_keys(:id).at_index(0)`)
16
+ - [ ] Support JSONPath syntax as alternative to dot-notation paths
17
+ - [ ] Add `including` chain for partial key matching
18
+ - [ ] Support regex patterns in `with_value` and `with_href` chains
19
+ - [ ] Add `within` tolerance for numeric comparisons
20
+
21
+ ### Configuration
22
+ - [ ] Add configurable JSON parser (support Oj, Yajl as alternatives)
23
+ - [ ] Add configurable date/time parsing for sorting comparisons
24
+ - [ ] Support custom error message formatters
25
+
26
+ ### Documentation
27
+ - [ ] Add YARD documentation for all public methods
28
+ - [ ] Create a cookbook with real-world API testing examples
29
+ - [ ] Add migration guide from other API testing libraries
30
+
31
+ ### Performance
32
+ - [ ] Lazy JSON parsing - only parse when needed
33
+ - [ ] Cache parsed JSON across chained matchers
34
+ - [ ] Benchmark suite for performance regression testing
35
+
36
+ ### Compatibility
37
+ - [ ] Minitest adapter for non-RSpec users
38
+ - [ ] Integration examples with popular HTTP clients (Faraday, HTTParty, RestClient)
39
+ - [ ] Rails system test integration guide
data/api_matchers.gemspec CHANGED
@@ -1,12 +1,14 @@
1
- # -*- encoding: utf-8 -*-
1
+ # frozen_string_literal: true
2
+
2
3
  require File.expand_path('../lib/api_matchers/version', __FILE__)
3
4
 
4
5
  Gem::Specification.new do |gem|
5
6
  gem.authors = ["Tomas D'Stefano"]
6
7
  gem.email = ["tomas_stefano@successoft.com"]
7
- gem.description = %q{Collection of RSpec matchers for create your API.}
8
- gem.summary = %q{Collection of RSpec matchers for create your API.}
8
+ gem.description = %q{Collection of RSpec matchers for your API.}
9
+ gem.summary = %q{Collection of RSpec matchers for your API.}
9
10
  gem.homepage = "https://github.com/tomas-stefano/api_matchers"
11
+ gem.license = "MIT"
10
12
 
11
13
  gem.files = `git ls-files`.split($\)
12
14
  gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) }
@@ -15,7 +17,12 @@ Gem::Specification.new do |gem|
15
17
  gem.require_paths = ["lib"]
16
18
  gem.version = APIMatchers::VERSION
17
19
 
18
- gem.add_dependency 'rspec', '~> 3.1'
19
- gem.add_dependency 'activesupport', '>= 3.2.5'
20
- gem.add_dependency 'nokogiri', '>= 1.5.2'
20
+ gem.required_ruby_version = '>= 3.1'
21
+
22
+ gem.add_dependency 'rspec', '>= 3.12', '< 4.0'
23
+ gem.add_dependency 'activesupport', '>= 7.0', '< 9.0'
24
+ gem.add_dependency 'nokogiri', '>= 1.15'
25
+
26
+ gem.add_development_dependency 'rake', '~> 13.0'
27
+ gem.add_development_dependency 'json_schemer', '~> 2.0'
21
28
  end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module APIMatchers
6
+ module Collection
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
+ end
64
+ end
65
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module APIMatchers
4
+ module Collection
5
+ class BeSortedBy < Base
6
+ def initialize(field)
7
+ @field = field.to_s
8
+ @path = nil
9
+ @direction = :ascending
10
+ end
11
+
12
+ def at_path(path)
13
+ @path = path
14
+ self
15
+ end
16
+
17
+ def ascending
18
+ @direction = :ascending
19
+ self
20
+ end
21
+
22
+ def descending
23
+ @direction = :descending
24
+ self
25
+ end
26
+
27
+ def failure_message
28
+ if @collection.nil?
29
+ "expected JSON at '#{@path || 'root'}' to be an array sorted by '#{@field}', " \
30
+ "but path was not found or value was not an array"
31
+ else
32
+ "expected JSON array at '#{@path || 'root'}' to be sorted by '#{@field}' #{@direction}. " \
33
+ "Got values: #{extract_values.inspect}"
34
+ end
35
+ end
36
+
37
+ def failure_message_when_negated
38
+ "expected JSON array at '#{@path || 'root'}' NOT to be sorted by '#{@field}' #{@direction}, " \
39
+ "but it was"
40
+ end
41
+
42
+ def description
43
+ desc = "be sorted by '#{@field}' #{@direction}"
44
+ desc += " at path '#{@path}'" if @path
45
+ desc
46
+ end
47
+
48
+ protected
49
+
50
+ def perform_match
51
+ value = json_at_path(@path)
52
+ unless value.is_a?(Array)
53
+ @collection = nil
54
+ return false
55
+ end
56
+
57
+ @collection = value
58
+ return true if @collection.empty? || @collection.size == 1
59
+
60
+ sorted?
61
+ rescue ::APIMatchers::Core::Exceptions::PathNotFound
62
+ @collection = nil
63
+ false
64
+ end
65
+
66
+ private
67
+
68
+ def sorted?
69
+ values = extract_values
70
+ sorted_values = @direction == :ascending ? values.sort : values.sort.reverse
71
+
72
+ values == sorted_values
73
+ end
74
+
75
+ def extract_values
76
+ @collection.map do |item|
77
+ value = item.is_a?(Hash) ? (item[@field] || item[@field.to_sym]) : item
78
+ normalize_for_comparison(value)
79
+ end
80
+ end
81
+
82
+ def normalize_for_comparison(value)
83
+ case value
84
+ when String
85
+ # Try to parse as date/time for proper comparison
86
+ begin
87
+ Time.parse(value)
88
+ rescue ArgumentError, TypeError
89
+ value
90
+ end
91
+ else
92
+ value
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module APIMatchers
4
+ module Collection
5
+ class HaveJsonSize < Base
6
+ def initialize(expected_size)
7
+ @expected_size = expected_size
8
+ @path = nil
9
+ end
10
+
11
+ def at_path(path)
12
+ @path = path
13
+ self
14
+ end
15
+
16
+ def failure_message
17
+ if @collection.nil?
18
+ "expected JSON at '#{@path || 'root'}' to be a collection with size #{@expected_size}, " \
19
+ "but path was not found or value was not a collection"
20
+ else
21
+ "expected JSON collection at '#{@path || 'root'}' to have size #{@expected_size}. " \
22
+ "Got size: #{@collection.size}"
23
+ end
24
+ end
25
+
26
+ def failure_message_when_negated
27
+ "expected JSON collection at '#{@path || 'root'}' NOT to have size #{@expected_size}, " \
28
+ "but it did"
29
+ end
30
+
31
+ def description
32
+ desc = "have JSON size #{@expected_size}"
33
+ desc += " at path '#{@path}'" if @path
34
+ desc
35
+ end
36
+
37
+ protected
38
+
39
+ def perform_match
40
+ value = json_at_path(@path)
41
+ unless value.is_a?(Array) || value.is_a?(Hash)
42
+ @collection = nil
43
+ return false
44
+ end
45
+
46
+ @collection = value
47
+ @collection.size == @expected_size
48
+ rescue ::APIMatchers::Core::Exceptions::PathNotFound
49
+ @collection = nil
50
+ false
51
+ end
52
+ end
53
+ end
54
+ end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module APIMatchers
2
4
  module Core
3
5
  module Exceptions
@@ -6,6 +8,9 @@ module APIMatchers
6
8
 
7
9
  class InvalidJSON < StandardError
8
10
  end
11
+
12
+ class PathNotFound < StandardError
13
+ end
9
14
  end
10
15
  end
11
16
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module APIMatchers
2
4
  module Core
3
5
  class FindInJSON
@@ -7,41 +9,68 @@ module APIMatchers
7
9
  @json = json
8
10
  end
9
11
 
10
- def find(options={})
12
+ def find(options = {})
11
13
  expected_key = options.fetch(:node).to_s
12
- expected_value = options[:value]
13
-
14
- @json.each do |key, value|
15
- if key == expected_key
16
- unless expected_value.nil?
17
- if expected_value.is_a? DateTime or expected_value.is_a? Date
18
- expected_value = expected_value.to_s
19
- elsif expected_value.is_a? Time
20
- expected_value = expected_value.to_datetime.to_s
21
- end
22
- end
23
- return value if value == expected_value or expected_value.nil?
24
- end
14
+ expected_value = ValueNormalizer.normalize(options[:value])
25
15
 
26
- # do we have more to recurse through?
27
- keep_going = nil
28
- if value.is_a? Hash or value.is_a? Array
29
- keep_going = value # hash or array, keep going
30
- elsif value.nil? and key.is_a? Hash
31
- keep_going = key # the array was passed in and now in the key, keep going
32
- end
16
+ result = search(json, expected_key, expected_value)
17
+ return result[:value] if result[:found]
18
+
19
+ raise ::APIMatchers::Core::Exceptions::KeyNotFound, "key '#{expected_key}' was not found"
20
+ end
33
21
 
34
- if keep_going
35
- begin
36
- # ignore nodes where the key doesn't match
37
- return FindInJSON.new(keep_going).find(node: expected_key, value: expected_value)
38
- rescue ::APIMatchers::Core::Exceptions::KeyNotFound
22
+ def available_keys
23
+ collect_keys(json)
24
+ end
25
+
26
+ private
27
+
28
+ def search(data, expected_key, expected_value)
29
+ case data
30
+ when Hash
31
+ search_in_hash(data, expected_key, expected_value)
32
+ when Array
33
+ search_in_array(data, expected_key, expected_value)
34
+ else
35
+ { found: false }
36
+ end
37
+ end
38
+
39
+ def search_in_hash(hash, expected_key, expected_value)
40
+ hash.each do |key, value|
41
+ if key.to_s == expected_key
42
+ if expected_value.nil? || value == expected_value
43
+ return { found: true, value: value }
39
44
  end
40
45
  end
41
46
 
47
+ result = search(value, expected_key, expected_value)
48
+ return result if result[:found]
49
+ end
50
+
51
+ { found: false }
52
+ end
53
+
54
+ def search_in_array(array, expected_key, expected_value)
55
+ array.each do |element|
56
+ result = search(element, expected_key, expected_value)
57
+ return result if result[:found]
58
+ end
59
+
60
+ { found: false }
61
+ end
62
+
63
+ def collect_keys(data, keys = [])
64
+ case data
65
+ when Hash
66
+ data.each do |key, value|
67
+ keys << key.to_s
68
+ collect_keys(value, keys)
69
+ end
70
+ when Array
71
+ data.each { |element| collect_keys(element, keys) }
42
72
  end
43
- # we did not find the requested key
44
- raise ::APIMatchers::Core::Exceptions::KeyNotFound.new("key was not found")
73
+ keys.uniq
45
74
  end
46
75
  end
47
76
  end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module APIMatchers
4
+ module Core
5
+ module HTTPStatusCodes
6
+ SYMBOL_TO_STATUS_CODE = {
7
+ # 1xx Informational
8
+ continue: 100,
9
+ switching_protocols: 101,
10
+ processing: 102,
11
+ early_hints: 103,
12
+
13
+ # 2xx Success
14
+ ok: 200,
15
+ created: 201,
16
+ accepted: 202,
17
+ non_authoritative_information: 203,
18
+ no_content: 204,
19
+ reset_content: 205,
20
+ partial_content: 206,
21
+ multi_status: 207,
22
+ already_reported: 208,
23
+ im_used: 226,
24
+
25
+ # 3xx Redirection
26
+ multiple_choices: 300,
27
+ moved_permanently: 301,
28
+ found: 302,
29
+ see_other: 303,
30
+ not_modified: 304,
31
+ use_proxy: 305,
32
+ temporary_redirect: 307,
33
+ permanent_redirect: 308,
34
+
35
+ # 4xx Client Error
36
+ bad_request: 400,
37
+ unauthorized: 401,
38
+ payment_required: 402,
39
+ forbidden: 403,
40
+ not_found: 404,
41
+ method_not_allowed: 405,
42
+ not_acceptable: 406,
43
+ proxy_authentication_required: 407,
44
+ request_timeout: 408,
45
+ conflict: 409,
46
+ gone: 410,
47
+ length_required: 411,
48
+ precondition_failed: 412,
49
+ payload_too_large: 413,
50
+ uri_too_long: 414,
51
+ unsupported_media_type: 415,
52
+ range_not_satisfiable: 416,
53
+ expectation_failed: 417,
54
+ im_a_teapot: 418,
55
+ misdirected_request: 421,
56
+ unprocessable_entity: 422,
57
+ locked: 423,
58
+ failed_dependency: 424,
59
+ too_early: 425,
60
+ upgrade_required: 426,
61
+ precondition_required: 428,
62
+ too_many_requests: 429,
63
+ request_header_fields_too_large: 431,
64
+ unavailable_for_legal_reasons: 451,
65
+
66
+ # 5xx Server Error
67
+ internal_server_error: 500,
68
+ not_implemented: 501,
69
+ bad_gateway: 502,
70
+ service_unavailable: 503,
71
+ gateway_timeout: 504,
72
+ http_version_not_supported: 505,
73
+ variant_also_negotiates: 506,
74
+ insufficient_storage: 507,
75
+ loop_detected: 508,
76
+ not_extended: 510,
77
+ network_authentication_required: 511
78
+ }.freeze
79
+
80
+ STATUS_CODE_TO_SYMBOL = SYMBOL_TO_STATUS_CODE.invert.freeze
81
+
82
+ # Status code ranges
83
+ INFORMATIONAL_RANGE = (100...200).freeze
84
+ SUCCESSFUL_RANGE = (200...300).freeze
85
+ REDIRECT_RANGE = (300...400).freeze
86
+ CLIENT_ERROR_RANGE = (400...500).freeze
87
+ SERVER_ERROR_RANGE = (500...600).freeze
88
+
89
+ def self.symbol_to_code(symbol)
90
+ SYMBOL_TO_STATUS_CODE[symbol.to_sym]
91
+ end
92
+
93
+ def self.code_to_symbol(code)
94
+ STATUS_CODE_TO_SYMBOL[code.to_i]
95
+ end
96
+
97
+ def self.informational?(code)
98
+ INFORMATIONAL_RANGE.include?(code.to_i)
99
+ end
100
+
101
+ def self.successful?(code)
102
+ SUCCESSFUL_RANGE.include?(code.to_i)
103
+ end
104
+
105
+ def self.redirect?(code)
106
+ REDIRECT_RANGE.include?(code.to_i)
107
+ end
108
+
109
+ def self.client_error?(code)
110
+ CLIENT_ERROR_RANGE.include?(code.to_i)
111
+ end
112
+
113
+ def self.server_error?(code)
114
+ SERVER_ERROR_RANGE.include?(code.to_i)
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module APIMatchers
4
+ module Core
5
+ class JsonPathFinder
6
+ attr_reader :json
7
+
8
+ def initialize(json)
9
+ @json = json
10
+ end
11
+
12
+ # Navigate JSON using dot-notation paths
13
+ # Example: "user.address.city" navigates to json["user"]["address"]["city"]
14
+ # Supports array indexing: "users.0.name" or "users[0].name"
15
+ def find(path)
16
+ return json if path.nil? || path.empty?
17
+
18
+ segments = parse_path(path)
19
+ navigate(json, segments, path)
20
+ end
21
+
22
+ # Check if a path exists in the JSON
23
+ def path_exists?(path)
24
+ find(path)
25
+ true
26
+ rescue ::APIMatchers::Core::Exceptions::PathNotFound
27
+ false
28
+ end
29
+
30
+ private
31
+
32
+ def parse_path(path)
33
+ # Handle both "items.0.name" and "items[0].name" syntax
34
+ path.to_s.gsub(/\[(\d+)\]/, '.\1').split('.')
35
+ end
36
+
37
+ def navigate(data, segments, full_path)
38
+ return data if segments.empty?
39
+
40
+ segment = segments.first
41
+ remaining = segments[1..]
42
+
43
+ value = access_segment(data, segment, full_path)
44
+ navigate(value, remaining, full_path)
45
+ end
46
+
47
+ def access_segment(data, segment, full_path)
48
+ case data
49
+ when Hash
50
+ access_hash(data, segment, full_path)
51
+ when Array
52
+ access_array(data, segment, full_path)
53
+ else
54
+ raise ::APIMatchers::Core::Exceptions::PathNotFound,
55
+ "Cannot navigate path '#{full_path}': '#{segment}' is not accessible on #{data.class}"
56
+ end
57
+ end
58
+
59
+ def access_hash(hash, key, full_path)
60
+ # Try both string and symbol keys
61
+ if hash.key?(key)
62
+ hash[key]
63
+ elsif hash.key?(key.to_sym)
64
+ hash[key.to_sym]
65
+ else
66
+ raise ::APIMatchers::Core::Exceptions::PathNotFound,
67
+ "Path '#{full_path}' not found: key '#{key}' does not exist"
68
+ end
69
+ end
70
+
71
+ def access_array(array, index_str, full_path)
72
+ unless index_str.match?(/\A\d+\z/)
73
+ raise ::APIMatchers::Core::Exceptions::PathNotFound,
74
+ "Path '#{full_path}' not found: '#{index_str}' is not a valid array index"
75
+ end
76
+
77
+ index = index_str.to_i
78
+ if index >= array.length
79
+ raise ::APIMatchers::Core::Exceptions::PathNotFound,
80
+ "Path '#{full_path}' not found: index #{index} out of bounds (array size: #{array.length})"
81
+ end
82
+
83
+ array[index]
84
+ end
85
+ end
86
+ end
87
+ end
@@ -1,10 +1,12 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module APIMatchers
2
4
  module Core
3
5
  module Parser
4
6
  def json
5
7
  JSON.parse(response_body)
6
- rescue JSON::ParserError => exception
7
- raise ::APIMatchers::InvalidJSON.new("Invalid JSON: '#{response_body}'")
8
+ rescue JSON::ParserError
9
+ raise ::APIMatchers::InvalidJSON, "Invalid JSON: '#{response_body}'"
8
10
  end
9
11
  end
10
12
  end