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.
- checksums.yaml +5 -5
- data/.github/workflows/ci.yml +30 -0
- data/.gitignore +1 -1
- data/Gemfile +1 -1
- data/History.markdown +62 -46
- data/README.markdown +1070 -114
- data/TODO.markdown +39 -3
- data/api_matchers.gemspec +13 -6
- data/lib/api_matchers/collection/base.rb +65 -0
- data/lib/api_matchers/collection/be_sorted_by.rb +97 -0
- data/lib/api_matchers/collection/have_json_size.rb +54 -0
- data/lib/api_matchers/core/exceptions.rb +5 -0
- data/lib/api_matchers/core/find_in_json.rb +57 -28
- data/lib/api_matchers/core/http_status_codes.rb +118 -0
- data/lib/api_matchers/core/json_path_finder.rb +87 -0
- data/lib/api_matchers/core/parser.rb +4 -2
- data/lib/api_matchers/core/rspec_matchers.rb +130 -37
- data/lib/api_matchers/core/setup.rb +49 -23
- data/lib/api_matchers/core/value_normalizer.rb +26 -0
- data/lib/api_matchers/error_response/base.rb +77 -0
- data/lib/api_matchers/error_response/have_error.rb +69 -0
- data/lib/api_matchers/error_response/have_error_on.rb +173 -0
- data/lib/api_matchers/hateoas/base.rb +77 -0
- data/lib/api_matchers/hateoas/have_link.rb +102 -0
- data/lib/api_matchers/headers/base.rb +7 -7
- data/lib/api_matchers/headers/be_json.rb +2 -4
- data/lib/api_matchers/headers/be_xml.rb +2 -4
- data/lib/api_matchers/headers/have_cache_control.rb +90 -0
- data/lib/api_matchers/headers/have_cors_headers.rb +102 -0
- data/lib/api_matchers/headers/have_header.rb +102 -0
- data/lib/api_matchers/http_status/base.rb +48 -0
- data/lib/api_matchers/http_status/be_client_error.rb +17 -0
- data/lib/api_matchers/http_status/be_forbidden.rb +17 -0
- data/lib/api_matchers/http_status/be_no_content.rb +17 -0
- data/lib/api_matchers/http_status/be_not_found.rb +17 -0
- data/lib/api_matchers/http_status/be_redirect.rb +17 -0
- data/lib/api_matchers/http_status/be_server_error.rb +17 -0
- data/lib/api_matchers/http_status/be_successful.rb +17 -0
- data/lib/api_matchers/http_status/be_unauthorized.rb +17 -0
- data/lib/api_matchers/http_status/be_unprocessable.rb +17 -0
- data/lib/api_matchers/http_status/have_http_status.rb +39 -0
- data/lib/api_matchers/json_api/base.rb +83 -0
- data/lib/api_matchers/json_api/be_json_api_compliant.rb +158 -0
- data/lib/api_matchers/json_api/have_json_api_attributes.rb +62 -0
- data/lib/api_matchers/json_api/have_json_api_data.rb +110 -0
- data/lib/api_matchers/json_api/have_json_api_relationships.rb +62 -0
- data/lib/api_matchers/json_structure/base.rb +65 -0
- data/lib/api_matchers/json_structure/have_json_keys.rb +55 -0
- data/lib/api_matchers/json_structure/have_json_type.rb +72 -0
- data/lib/api_matchers/pagination/base.rb +73 -0
- data/lib/api_matchers/pagination/be_paginated.rb +73 -0
- data/lib/api_matchers/pagination/have_pagination_links.rb +74 -0
- data/lib/api_matchers/pagination/have_total_count.rb +77 -0
- data/lib/api_matchers/response_body/base.rb +10 -9
- data/lib/api_matchers/response_body/have_json.rb +2 -4
- data/lib/api_matchers/response_body/have_json_node.rb +45 -1
- data/lib/api_matchers/response_body/have_node.rb +2 -0
- data/lib/api_matchers/response_body/have_xml_node.rb +13 -14
- data/lib/api_matchers/response_body/match_json_schema.rb +89 -0
- data/lib/api_matchers/version.rb +3 -1
- data/lib/api_matchers.rb +75 -14
- data/spec/api_matchers/collection/be_sorted_by_spec.rb +110 -0
- data/spec/api_matchers/collection/have_json_size_spec.rb +101 -0
- data/spec/api_matchers/error_response/have_error_on_spec.rb +123 -0
- data/spec/api_matchers/error_response/have_error_spec.rb +108 -0
- data/spec/api_matchers/hateoas/have_link_spec.rb +105 -0
- data/spec/api_matchers/headers/base_spec.rb +8 -3
- data/spec/api_matchers/headers/be_json_spec.rb +1 -1
- data/spec/api_matchers/headers/be_xml_spec.rb +1 -1
- data/spec/api_matchers/headers/have_cache_control_spec.rb +102 -0
- data/spec/api_matchers/headers/have_cors_headers_spec.rb +74 -0
- data/spec/api_matchers/headers/have_header_spec.rb +88 -0
- data/spec/api_matchers/http_status/be_client_error_spec.rb +53 -0
- data/spec/api_matchers/http_status/be_forbidden_spec.rb +33 -0
- data/spec/api_matchers/http_status/be_no_content_spec.rb +33 -0
- data/spec/api_matchers/http_status/be_not_found_spec.rb +39 -0
- data/spec/api_matchers/http_status/be_redirect_spec.rb +55 -0
- data/spec/api_matchers/http_status/be_server_error_spec.rb +49 -0
- data/spec/api_matchers/http_status/be_successful_spec.rb +78 -0
- data/spec/api_matchers/http_status/be_unauthorized_spec.rb +33 -0
- data/spec/api_matchers/http_status/be_unprocessable_spec.rb +39 -0
- data/spec/api_matchers/http_status/have_http_status_spec.rb +81 -0
- data/spec/api_matchers/json_api/be_json_api_compliant_spec.rb +109 -0
- data/spec/api_matchers/json_api/have_json_api_attributes_spec.rb +61 -0
- data/spec/api_matchers/json_api/have_json_api_data_spec.rb +95 -0
- data/spec/api_matchers/json_api/have_json_api_relationships_spec.rb +61 -0
- data/spec/api_matchers/json_structure/have_json_keys_spec.rb +81 -0
- data/spec/api_matchers/json_structure/have_json_type_spec.rb +134 -0
- data/spec/api_matchers/pagination/be_paginated_spec.rb +95 -0
- data/spec/api_matchers/pagination/have_pagination_links_spec.rb +80 -0
- data/spec/api_matchers/pagination/have_total_count_spec.rb +85 -0
- data/spec/api_matchers/response_body/base_spec.rb +15 -7
- data/spec/api_matchers/response_body/have_json_node_spec.rb +57 -0
- data/spec/api_matchers/response_body/match_json_schema_spec.rb +86 -0
- metadata +154 -48
- data/.rvmrc.example +0 -1
- data/.travis.yml +0 -12
- data/lib/api_matchers/http_status_code/base.rb +0 -32
- data/lib/api_matchers/http_status_code/be_bad_request.rb +0 -25
- data/lib/api_matchers/http_status_code/be_forbidden.rb +0 -21
- data/lib/api_matchers/http_status_code/be_internal_server_error.rb +0 -25
- data/lib/api_matchers/http_status_code/be_not_found.rb +0 -25
- data/lib/api_matchers/http_status_code/be_ok.rb +0 -25
- data/lib/api_matchers/http_status_code/be_unauthorized.rb +0 -25
- data/lib/api_matchers/http_status_code/be_unprocessable_entity.rb +0 -25
- data/lib/api_matchers/http_status_code/create_resource.rb +0 -25
- data/spec/api_matchers/http_status_code/base_spec.rb +0 -12
- data/spec/api_matchers/http_status_code/be_bad_request_spec.rb +0 -49
- data/spec/api_matchers/http_status_code/be_forbidden_spec.rb +0 -49
- data/spec/api_matchers/http_status_code/be_internal_server_error_spec.rb +0 -49
- data/spec/api_matchers/http_status_code/be_not_found_spec.rb +0 -49
- data/spec/api_matchers/http_status_code/be_ok_spec.rb +0 -49
- data/spec/api_matchers/http_status_code/be_unauthorized_spec.rb +0 -49
- data/spec/api_matchers/http_status_code/be_unprocessable_entity_spec.rb +0 -27
- data/spec/api_matchers/http_status_code/create_resource_spec.rb +0 -49
data/TODO.markdown
CHANGED
|
@@ -1,3 +1,39 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
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
|
-
#
|
|
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
|
|
8
|
-
gem.summary = %q{Collection of RSpec matchers for
|
|
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.
|
|
19
|
-
|
|
20
|
-
gem.add_dependency '
|
|
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
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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
|
|
7
|
-
raise ::APIMatchers::InvalidJSON
|
|
8
|
+
rescue JSON::ParserError
|
|
9
|
+
raise ::APIMatchers::InvalidJSON, "Invalid JSON: '#{response_body}'"
|
|
8
10
|
end
|
|
9
11
|
end
|
|
10
12
|
end
|