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.
- checksums.yaml +5 -5
- data/.github/workflows/ci.yml +30 -0
- data/.gitignore +1 -0
- data/Gemfile +1 -1
- data/History.markdown +62 -50
- 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 +152 -47
- data/.rvmrc.example +0 -1
- data/.travis.yml +0 -12
- data/Gemfile.lock +0 -46
- 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
|
@@ -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 :
|
|
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
|
-
|
|
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
|
|
28
|
-
@actual.send(
|
|
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
|
-
|
|
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,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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
26
|
-
|
|
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
|
data/lib/api_matchers/version.rb
CHANGED