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,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module APIMatchers
|
|
4
|
+
module HTTPStatus
|
|
5
|
+
class BeSuccessful < Base
|
|
6
|
+
protected
|
|
7
|
+
|
|
8
|
+
def expected_status_match?
|
|
9
|
+
::APIMatchers::Core::HTTPStatusCodes.successful?(actual_status)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def expectation_description
|
|
13
|
+
"be successful (2xx)"
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module APIMatchers
|
|
4
|
+
module HTTPStatus
|
|
5
|
+
class BeUnauthorized < Base
|
|
6
|
+
protected
|
|
7
|
+
|
|
8
|
+
def expected_status_match?
|
|
9
|
+
actual_status.to_i == 401
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def expectation_description
|
|
13
|
+
"be unauthorized (401)"
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module APIMatchers
|
|
4
|
+
module HTTPStatus
|
|
5
|
+
class BeUnprocessable < Base
|
|
6
|
+
protected
|
|
7
|
+
|
|
8
|
+
def expected_status_match?
|
|
9
|
+
actual_status.to_i == 422
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def expectation_description
|
|
13
|
+
"be unprocessable (422)"
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module APIMatchers
|
|
4
|
+
module HTTPStatus
|
|
5
|
+
class HaveHttpStatus < Base
|
|
6
|
+
def initialize(expected)
|
|
7
|
+
@expected = expected
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
protected
|
|
11
|
+
|
|
12
|
+
def expected_status_match?
|
|
13
|
+
actual_status.to_i == expected_code
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def expectation_description
|
|
17
|
+
if @expected.is_a?(Symbol)
|
|
18
|
+
"have HTTP status #{expected_code} (#{@expected})"
|
|
19
|
+
else
|
|
20
|
+
"have HTTP status #{expected_code}"
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def expected_code
|
|
27
|
+
case @expected
|
|
28
|
+
when Symbol
|
|
29
|
+
::APIMatchers::Core::HTTPStatusCodes.symbol_to_code(@expected) ||
|
|
30
|
+
raise(ArgumentError, "Unknown status code symbol: #{@expected}")
|
|
31
|
+
when Integer
|
|
32
|
+
@expected
|
|
33
|
+
else
|
|
34
|
+
raise ArgumentError, "Expected status must be a Symbol or Integer, got: #{@expected.class}"
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module APIMatchers
|
|
6
|
+
module JsonApi
|
|
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_api_data
|
|
58
|
+
parsed_json['data'] || parsed_json[:data]
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def json_api_errors
|
|
62
|
+
parsed_json['errors'] || parsed_json[:errors]
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def json_api_meta
|
|
66
|
+
parsed_json['meta'] || parsed_json[:meta]
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def json_api_included
|
|
70
|
+
parsed_json['included'] || parsed_json[:included]
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def json_api_links
|
|
74
|
+
parsed_json['links'] || parsed_json[:links]
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def truncate_json(json)
|
|
78
|
+
str = json.to_json
|
|
79
|
+
str.length > 200 ? "#{str[0..200]}..." : str
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module APIMatchers
|
|
4
|
+
module JsonApi
|
|
5
|
+
class BeJsonApiCompliant < Base
|
|
6
|
+
REQUIRED_TOP_LEVEL_MEMBERS = %w[data errors meta].freeze
|
|
7
|
+
VALID_TOP_LEVEL_MEMBERS = %w[data errors meta links included jsonapi].freeze
|
|
8
|
+
REQUIRED_RESOURCE_MEMBERS = %w[id type].freeze
|
|
9
|
+
|
|
10
|
+
def failure_message
|
|
11
|
+
"expected response to be JSON:API compliant. Violations: #{@violations.join('; ')}. " \
|
|
12
|
+
"Got: #{truncate_json(parsed_json)}"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def failure_message_when_negated
|
|
16
|
+
"expected response NOT to be JSON:API compliant, but it was"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def description
|
|
20
|
+
"be JSON:API compliant"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
protected
|
|
24
|
+
|
|
25
|
+
def perform_match
|
|
26
|
+
@violations = []
|
|
27
|
+
validate_top_level_structure
|
|
28
|
+
return @violations.empty? unless parsed_json.is_a?(Hash)
|
|
29
|
+
|
|
30
|
+
validate_data_member if has_data?
|
|
31
|
+
validate_errors_member if has_errors?
|
|
32
|
+
|
|
33
|
+
@violations.empty?
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def validate_top_level_structure
|
|
39
|
+
unless parsed_json.is_a?(Hash)
|
|
40
|
+
@violations << "top-level must be an object"
|
|
41
|
+
return
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Must have at least one of: data, errors, or meta
|
|
45
|
+
keys = parsed_json.keys.map(&:to_s)
|
|
46
|
+
unless (keys & REQUIRED_TOP_LEVEL_MEMBERS).any?
|
|
47
|
+
@violations << "must contain at least one of: data, errors, or meta"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# data and errors must not coexist
|
|
51
|
+
if has_data? && has_errors?
|
|
52
|
+
@violations << "data and errors must not coexist"
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def validate_data_member
|
|
57
|
+
data = json_api_data
|
|
58
|
+
|
|
59
|
+
case data
|
|
60
|
+
when Hash
|
|
61
|
+
validate_resource_object(data)
|
|
62
|
+
when Array
|
|
63
|
+
data.each_with_index do |resource, index|
|
|
64
|
+
validate_resource_object(resource, "data[#{index}]")
|
|
65
|
+
end
|
|
66
|
+
when nil
|
|
67
|
+
# null is valid for data
|
|
68
|
+
else
|
|
69
|
+
@violations << "data must be null, an object, or an array"
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def validate_resource_object(resource, path = "data")
|
|
74
|
+
unless resource.is_a?(Hash)
|
|
75
|
+
@violations << "#{path} must be an object"
|
|
76
|
+
return
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
keys = resource.keys.map(&:to_s)
|
|
80
|
+
|
|
81
|
+
# Must have id and type (unless it's a new resource being created)
|
|
82
|
+
unless keys.include?('type')
|
|
83
|
+
@violations << "#{path} must contain 'type'"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# id must be a string
|
|
87
|
+
if resource.key?('id') || resource.key?(:id)
|
|
88
|
+
id = resource['id'] || resource[:id]
|
|
89
|
+
unless id.is_a?(String) || id.is_a?(Integer)
|
|
90
|
+
@violations << "#{path}.id must be a string or integer"
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# type must be a string
|
|
95
|
+
type = resource['type'] || resource[:type]
|
|
96
|
+
if type && !type.is_a?(String)
|
|
97
|
+
@violations << "#{path}.type must be a string"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Validate attributes
|
|
101
|
+
attributes = resource['attributes'] || resource[:attributes]
|
|
102
|
+
if attributes && !attributes.is_a?(Hash)
|
|
103
|
+
@violations << "#{path}.attributes must be an object"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Validate relationships
|
|
107
|
+
relationships = resource['relationships'] || resource[:relationships]
|
|
108
|
+
if relationships
|
|
109
|
+
validate_relationships(relationships, path)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def validate_relationships(relationships, path)
|
|
114
|
+
unless relationships.is_a?(Hash)
|
|
115
|
+
@violations << "#{path}.relationships must be an object"
|
|
116
|
+
return
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
relationships.each do |name, rel|
|
|
120
|
+
rel_path = "#{path}.relationships.#{name}"
|
|
121
|
+
unless rel.is_a?(Hash)
|
|
122
|
+
@violations << "#{rel_path} must be an object"
|
|
123
|
+
next
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Must have at least one of: links, data, or meta
|
|
127
|
+
rel_keys = rel.keys.map(&:to_s)
|
|
128
|
+
unless (rel_keys & %w[links data meta]).any?
|
|
129
|
+
@violations << "#{rel_path} must contain links, data, or meta"
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def validate_errors_member
|
|
135
|
+
errors = json_api_errors
|
|
136
|
+
|
|
137
|
+
unless errors.is_a?(Array)
|
|
138
|
+
@violations << "errors must be an array"
|
|
139
|
+
return
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
errors.each_with_index do |error, index|
|
|
143
|
+
unless error.is_a?(Hash)
|
|
144
|
+
@violations << "errors[#{index}] must be an object"
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def has_data?
|
|
150
|
+
parsed_json.key?('data') || parsed_json.key?(:data)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def has_errors?
|
|
154
|
+
parsed_json.key?('errors') || parsed_json.key?(:errors)
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module APIMatchers
|
|
4
|
+
module JsonApi
|
|
5
|
+
class HaveJsonApiAttributes < Base
|
|
6
|
+
def initialize(*attributes)
|
|
7
|
+
@expected_attributes = attributes.flatten.map(&:to_s)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def failure_message
|
|
11
|
+
data = json_api_data
|
|
12
|
+
attrs = extract_attributes(data)
|
|
13
|
+
|
|
14
|
+
if attrs.nil?
|
|
15
|
+
"expected JSON:API data to have attributes #{@expected_attributes.inspect}, " \
|
|
16
|
+
"but no attributes were found. Got: #{truncate_json(parsed_json)}"
|
|
17
|
+
else
|
|
18
|
+
missing = @expected_attributes - attrs.keys.map(&:to_s)
|
|
19
|
+
"expected JSON:API data to have attributes #{@expected_attributes.inspect}. " \
|
|
20
|
+
"Missing: #{missing.inspect}. Available: #{attrs.keys.map(&:to_s).inspect}"
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def failure_message_when_negated
|
|
25
|
+
"expected JSON:API data NOT to have attributes #{@expected_attributes.inspect}, " \
|
|
26
|
+
"but all were present"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def description
|
|
30
|
+
"have JSON:API attributes #{@expected_attributes.inspect}"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
protected
|
|
34
|
+
|
|
35
|
+
def perform_match
|
|
36
|
+
data = json_api_data
|
|
37
|
+
return false unless data
|
|
38
|
+
|
|
39
|
+
attrs = extract_attributes(data)
|
|
40
|
+
return false unless attrs.is_a?(Hash)
|
|
41
|
+
|
|
42
|
+
available_attrs = attrs.keys.map(&:to_s)
|
|
43
|
+
(@expected_attributes - available_attrs).empty?
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def extract_attributes(data)
|
|
49
|
+
case data
|
|
50
|
+
when Hash
|
|
51
|
+
data['attributes'] || data[:attributes]
|
|
52
|
+
when Array
|
|
53
|
+
# For collections, check the first item
|
|
54
|
+
first = data.first
|
|
55
|
+
first['attributes'] || first[:attributes] if first.is_a?(Hash)
|
|
56
|
+
else
|
|
57
|
+
nil
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module APIMatchers
|
|
4
|
+
module JsonApi
|
|
5
|
+
class HaveJsonApiData < Base
|
|
6
|
+
def initialize
|
|
7
|
+
@expected_type = nil
|
|
8
|
+
@expected_id = nil
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def of_type(type)
|
|
12
|
+
@expected_type = type.to_s
|
|
13
|
+
self
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def with_id(id)
|
|
17
|
+
@expected_id = id.to_s
|
|
18
|
+
self
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def failure_message
|
|
22
|
+
data = json_api_data
|
|
23
|
+
|
|
24
|
+
if data.nil? && !parsed_json.key?('data') && !parsed_json.key?(:data)
|
|
25
|
+
"expected response to have JSON:API data. Got: #{truncate_json(parsed_json)}"
|
|
26
|
+
elsif @expected_type && !type_matches?(data)
|
|
27
|
+
"expected JSON:API data to have type '#{@expected_type}'. " \
|
|
28
|
+
"Got type: '#{extract_type(data)}'"
|
|
29
|
+
elsif @expected_id && !id_matches?(data)
|
|
30
|
+
"expected JSON:API data to have id '#{@expected_id}'. " \
|
|
31
|
+
"Got id: '#{extract_id(data)}'"
|
|
32
|
+
else
|
|
33
|
+
"expected response to have JSON:API data"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def failure_message_when_negated
|
|
38
|
+
if @expected_type || @expected_id
|
|
39
|
+
"expected JSON:API data NOT to have " \
|
|
40
|
+
"#{[@expected_type && "type '#{@expected_type}'", @expected_id && "id '#{@expected_id}'"].compact.join(' and ')}, " \
|
|
41
|
+
"but it did"
|
|
42
|
+
else
|
|
43
|
+
"expected response NOT to have JSON:API data, but it did"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def description
|
|
48
|
+
desc = "have JSON:API data"
|
|
49
|
+
desc += " of type '#{@expected_type}'" if @expected_type
|
|
50
|
+
desc += " with id '#{@expected_id}'" if @expected_id
|
|
51
|
+
desc
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
protected
|
|
55
|
+
|
|
56
|
+
def perform_match
|
|
57
|
+
data = json_api_data
|
|
58
|
+
|
|
59
|
+
# data: null is valid JSON:API
|
|
60
|
+
return true if data.nil? && (parsed_json.key?('data') || parsed_json.key?(:data))
|
|
61
|
+
|
|
62
|
+
return false unless data
|
|
63
|
+
|
|
64
|
+
if @expected_type && !type_matches?(data)
|
|
65
|
+
return false
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
if @expected_id && !id_matches?(data)
|
|
69
|
+
return false
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
true
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def type_matches?(data)
|
|
78
|
+
case data
|
|
79
|
+
when Hash
|
|
80
|
+
extract_type(data) == @expected_type
|
|
81
|
+
when Array
|
|
82
|
+
data.all? { |d| extract_type(d) == @expected_type }
|
|
83
|
+
else
|
|
84
|
+
false
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def id_matches?(data)
|
|
89
|
+
case data
|
|
90
|
+
when Hash
|
|
91
|
+
extract_id(data) == @expected_id
|
|
92
|
+
when Array
|
|
93
|
+
data.any? { |d| extract_id(d) == @expected_id }
|
|
94
|
+
else
|
|
95
|
+
false
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def extract_type(resource)
|
|
100
|
+
return nil unless resource.is_a?(Hash)
|
|
101
|
+
(resource['type'] || resource[:type])&.to_s
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def extract_id(resource)
|
|
105
|
+
return nil unless resource.is_a?(Hash)
|
|
106
|
+
(resource['id'] || resource[:id])&.to_s
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module APIMatchers
|
|
4
|
+
module JsonApi
|
|
5
|
+
class HaveJsonApiRelationships < Base
|
|
6
|
+
def initialize(*relationships)
|
|
7
|
+
@expected_relationships = relationships.flatten.map(&:to_s)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def failure_message
|
|
11
|
+
data = json_api_data
|
|
12
|
+
rels = extract_relationships(data)
|
|
13
|
+
|
|
14
|
+
if rels.nil?
|
|
15
|
+
"expected JSON:API data to have relationships #{@expected_relationships.inspect}, " \
|
|
16
|
+
"but no relationships were found. Got: #{truncate_json(parsed_json)}"
|
|
17
|
+
else
|
|
18
|
+
missing = @expected_relationships - rels.keys.map(&:to_s)
|
|
19
|
+
"expected JSON:API data to have relationships #{@expected_relationships.inspect}. " \
|
|
20
|
+
"Missing: #{missing.inspect}. Available: #{rels.keys.map(&:to_s).inspect}"
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def failure_message_when_negated
|
|
25
|
+
"expected JSON:API data NOT to have relationships #{@expected_relationships.inspect}, " \
|
|
26
|
+
"but all were present"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def description
|
|
30
|
+
"have JSON:API relationships #{@expected_relationships.inspect}"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
protected
|
|
34
|
+
|
|
35
|
+
def perform_match
|
|
36
|
+
data = json_api_data
|
|
37
|
+
return false unless data
|
|
38
|
+
|
|
39
|
+
rels = extract_relationships(data)
|
|
40
|
+
return false unless rels.is_a?(Hash)
|
|
41
|
+
|
|
42
|
+
available_rels = rels.keys.map(&:to_s)
|
|
43
|
+
(@expected_relationships - available_rels).empty?
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def extract_relationships(data)
|
|
49
|
+
case data
|
|
50
|
+
when Hash
|
|
51
|
+
data['relationships'] || data[:relationships]
|
|
52
|
+
when Array
|
|
53
|
+
# For collections, check the first item
|
|
54
|
+
first = data.first
|
|
55
|
+
first['relationships'] || first[:relationships] if first.is_a?(Hash)
|
|
56
|
+
else
|
|
57
|
+
nil
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module APIMatchers
|
|
6
|
+
module JsonStructure
|
|
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,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module APIMatchers
|
|
4
|
+
module JsonStructure
|
|
5
|
+
class HaveJsonKeys < Base
|
|
6
|
+
def initialize(*keys)
|
|
7
|
+
@expected_keys = keys.flatten.map(&:to_s)
|
|
8
|
+
@path = nil
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def at_path(path)
|
|
12
|
+
@path = path
|
|
13
|
+
self
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def failure_message
|
|
17
|
+
"expected JSON to have keys: #{@expected_keys.inspect}. " \
|
|
18
|
+
"Missing: #{missing_keys.inspect}. " \
|
|
19
|
+
"Got keys: #{actual_keys.inspect}"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def failure_message_when_negated
|
|
23
|
+
"expected JSON NOT to have keys: #{@expected_keys.inspect}, but all were present"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def description
|
|
27
|
+
desc = "have JSON keys #{@expected_keys.inspect}"
|
|
28
|
+
desc += " at path '#{@path}'" if @path
|
|
29
|
+
desc
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
protected
|
|
33
|
+
|
|
34
|
+
def perform_match
|
|
35
|
+
@json_data = json_at_path(@path)
|
|
36
|
+
return false unless @json_data.is_a?(Hash)
|
|
37
|
+
|
|
38
|
+
missing_keys.empty?
|
|
39
|
+
rescue ::APIMatchers::Core::Exceptions::PathNotFound
|
|
40
|
+
@json_data = nil
|
|
41
|
+
false
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def actual_keys
|
|
47
|
+
@json_data.is_a?(Hash) ? @json_data.keys.map(&:to_s) : []
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def missing_keys
|
|
51
|
+
@expected_keys - actual_keys
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|