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/README.markdown CHANGED
@@ -1,269 +1,1225 @@
1
- # API Matchers [![Build Status](https://travis-ci.org/tomas-stefano/api_matchers.png?branch=master)](https://travis-ci.org/tomas-stefano/api_matchers)
1
+ # API Matchers [![CI](https://github.com/tomas-stefano/api_matchers/actions/workflows/ci.yml/badge.svg)](https://github.com/tomas-stefano/api_matchers/actions/workflows/ci.yml) [![Gem Version](https://badge.fury.io/rb/api_matchers.svg)](https://badge.fury.io/rb/api_matchers)
2
2
 
3
3
  Collection of RSpec matchers for your API.
4
4
 
5
- ## Response Body Matchers
5
+ ## Table of Contents
6
+
7
+ - [Requirements](#requirements)
8
+ - [Installation](#installation)
9
+ - [Quick Start](#quick-start)
10
+ - [Matchers](#matchers)
11
+ - [Response Body Matchers](#response-body-matchers)
12
+ - [have_json_node](#have_json_node)
13
+ - [have_xml_node](#have_xml_node)
14
+ - [have_node](#have_node)
15
+ - [have_json](#have_json)
16
+ - [match_json_schema](#match_json_schema)
17
+ - [HTTP Status Matchers](#http-status-matchers)
18
+ - [have_http_status](#have_http_status)
19
+ - [be_successful](#be_successful)
20
+ - [be_redirect](#be_redirect)
21
+ - [be_client_error / be_server_error](#be_client_error--be_server_error)
22
+ - [Specific Status Matchers](#specific-status-matchers)
23
+ - [JSON Structure Matchers](#json-structure-matchers)
24
+ - [have_json_keys](#have_json_keys)
25
+ - [have_json_type](#have_json_type)
26
+ - [Collection Matchers](#collection-matchers)
27
+ - [have_json_size](#have_json_size)
28
+ - [be_sorted_by](#be_sorted_by)
29
+ - [Header Matchers](#header-matchers)
30
+ - [be_json / be_xml](#be_json--be_xml)
31
+ - [have_header](#have_header)
32
+ - [have_cors_headers](#have_cors_headers)
33
+ - [have_cache_control](#have_cache_control)
34
+ - [Pagination Matchers](#pagination-matchers)
35
+ - [be_paginated](#be_paginated)
36
+ - [have_pagination_links](#have_pagination_links)
37
+ - [have_total_count](#have_total_count)
38
+ - [Error Response Matchers](#error-response-matchers)
39
+ - [have_error / have_errors](#have_error--have_errors)
40
+ - [have_error_on](#have_error_on)
41
+ - [JSON:API Matchers](#jsonapi-matchers)
42
+ - [be_json_api_compliant](#be_json_api_compliant)
43
+ - [have_json_api_data](#have_json_api_data)
44
+ - [have_json_api_attributes](#have_json_api_attributes)
45
+ - [have_json_api_relationships](#have_json_api_relationships)
46
+ - [HATEOAS Matchers](#hateoas-matchers)
47
+ - [have_link](#have_link)
48
+ - [Configuration](#configuration)
49
+ - [Upgrading from 0.x to 1.0](#upgrading-from-0x-to-10)
50
+ - [Contributing](#contributing)
51
+
52
+ ## Requirements
53
+
54
+ - Ruby 3.1+
55
+ - RSpec 3.12+
56
+
57
+ ## Installation
58
+
59
+ Add to your Gemfile:
6
60
 
7
- * `have_node`
8
- * `have_json_node`
9
- * `have_xml_node`
10
- * `have_json`
61
+ ```ruby
62
+ group :test do
63
+ gem 'api_matchers'
64
+
65
+ # Optional: for JSON Schema validation
66
+ gem 'json_schemer'
67
+ end
68
+ ```
11
69
 
12
- ## Response Status Matchers
70
+ Or install manually:
13
71
 
14
- * `be_ok`
15
- * `create_resource`
16
- * `be_a_bad_request`
17
- * `be_unauthorized`
18
- * `be_forbidden`
19
- * `be_internal_server_error`
20
- * `be_not_found`
72
+ ```bash
73
+ gem install api_matchers
74
+ ```
21
75
 
22
- ## Other Matchers
76
+ ## Quick Start
23
77
 
24
- * `be_in_xml`
25
- * `be_in_json`
78
+ Include the matchers in your RSpec configuration:
26
79
 
27
- ## Install
80
+ ```ruby
81
+ # spec/spec_helper.rb or spec/rails_helper.rb
82
+ RSpec.configure do |config|
83
+ config.include APIMatchers::RSpecMatchers
84
+ end
85
+ ```
28
86
 
29
- Include the gem to your test group in you Gemfile:
87
+ Then use them in your specs:
30
88
 
31
89
  ```ruby
32
- group :test do
33
- gem 'api_matchers'
34
- # other gems
90
+ RSpec.describe "Users API" do
91
+ it "returns user data" do
92
+ get "/api/users/1"
93
+
94
+ expect(response.body).to have_json_node(:id).with(1)
95
+ expect(response.body).to have_json_node(:name).with("John")
96
+ expect(response.body).to have_json_node(:email).including_text("@example.com")
97
+ end
35
98
  end
36
99
  ```
37
100
 
38
- Or install it manually: `gem install api_matchers`.
101
+ ## Matchers
39
102
 
40
- ## Usage
103
+ ### Response Body Matchers
41
104
 
42
- ### Including in RSpec
105
+ ### have_json_node
43
106
 
44
- To include all this matchers you need to include the APIMatchers::RSpecMatchers module:
107
+ Verifies the presence of a node in JSON, with optional value matching.
108
+
109
+ #### Basic Usage
45
110
 
46
111
  ```ruby
47
- RSpec.configure do |config|
48
- config.include APIMatchers::RSpecMatchers
49
- end
112
+ json = '{"user": {"id": 1, "name": "John", "active": true}}'
113
+
114
+ # Check node exists
115
+ expect(json).to have_json_node(:user)
116
+ expect(json).to have_json_node(:id) # Works with nested nodes too
117
+
118
+ # Check node exists with specific value
119
+ expect(json).to have_json_node(:id).with(1)
120
+ expect(json).to have_json_node(:name).with("John")
121
+ expect(json).to have_json_node(:active).with(true)
122
+
123
+ # Check node does NOT exist
124
+ expect(json).not_to have_json_node(:admin)
125
+
126
+ # Check node exists but with different value
127
+ expect(json).not_to have_json_node(:id).with(999)
50
128
  ```
51
129
 
52
- ### Have Node Matcher
130
+ #### Deeply Nested JSON
53
131
 
54
- The have_node matcher parse the actual and see if have the expcted node with the expected value.
55
- **The default that have_node will parse is JSON.**
132
+ ```ruby
133
+ json = '{
134
+ "response": {
135
+ "data": {
136
+ "transaction": {
137
+ "id": 12345,
138
+ "status": "completed",
139
+ "payment": {
140
+ "method": "credit_card",
141
+ "amount": 99.99
142
+ }
143
+ }
144
+ }
145
+ }
146
+ }'
147
+
148
+ # All these work - it searches recursively
149
+ expect(json).to have_json_node(:transaction)
150
+ expect(json).to have_json_node(:id).with(12345)
151
+ expect(json).to have_json_node(:status).with("completed")
152
+ expect(json).to have_json_node(:method).with("credit_card")
153
+ expect(json).to have_json_node(:amount).with(99.99)
154
+ ```
56
155
 
57
- You can verify if node exists:
156
+ #### Different Value Types
58
157
 
59
158
  ```ruby
60
- expect('{ "transaction": { "id": 54, "status": "paid" } }').to have_node(:transaction)
159
+ # Strings
160
+ expect('{"name": "Alice"}').to have_json_node(:name).with("Alice")
161
+
162
+ # Integers
163
+ expect('{"count": 42}').to have_json_node(:count).with(42)
164
+
165
+ # Floats
166
+ expect('{"price": 19.99}').to have_json_node(:price).with(19.99)
167
+
168
+ # Booleans
169
+ expect('{"enabled": true}').to have_json_node(:enabled).with(true)
170
+ expect('{"disabled": false}').to have_json_node(:disabled).with(false)
171
+
172
+ # Null
173
+ expect('{"deleted_at": null}').to have_json_node(:deleted_at).with(nil)
174
+
175
+ # Empty string
176
+ expect('{"nickname": ""}').to have_json_node(:nickname).with("")
177
+
178
+ # Empty array
179
+ expect('{"items": []}').to have_json_node(:items).with([])
180
+
181
+ # Empty object
182
+ expect('{"metadata": {}}').to have_json_node(:metadata).with({})
61
183
  ```
62
184
 
63
- Or if node exist with a value:
185
+ #### Partial Text Matching
186
+
187
+ Use `including_text` to check if a node's value contains specific text:
64
188
 
65
189
  ```ruby
66
- expect('{ "transaction": { "id": 54, "status": "paid" } }').to have_node(:id).with(54)
190
+ json = '{"error": "Validation failed: Email is invalid, Name is too short"}'
191
+
192
+ expect(json).to have_json_node(:error).including_text("Email is invalid")
193
+ expect(json).to have_json_node(:error).including_text("Validation failed")
194
+
195
+ # Useful for URLs
196
+ json = '{"avatar_url": "https://cdn.example.com/users/123/avatar.png"}'
197
+ expect(json).to have_json_node(:avatar_url).including_text("cdn.example.com")
198
+ expect(json).to have_json_node(:avatar_url).including_text("/users/123/")
199
+
200
+ # Useful for timestamps (partial match)
201
+ json = '{"created_at": "2024-01-15T10:30:00Z"}'
202
+ expect(json).to have_json_node(:created_at).including_text("2024-01-15")
203
+
204
+ # Useful for generated IDs or tokens
205
+ json = '{"session_id": "sess_abc123xyz789"}'
206
+ expect(json).to have_json_node(:session_id).including_text("sess_")
207
+
208
+ # Negation
209
+ expect(json).not_to have_json_node(:error).including_text("Server error")
67
210
  ```
68
211
 
212
+ #### Array Matching
213
+
214
+ ##### `including` - Check if array contains an element
215
+
69
216
  ```ruby
70
- expect('{ "error": "not_authorized" }').to have_node(:error).with('not_authorized')
217
+ json = '{"users": [{"name": "Alice", "role": "admin"}, {"name": "Bob", "role": "user"}]}'
218
+
219
+ # Match by single attribute
220
+ expect(json).to have_json_node(:users).including(name: "Alice")
221
+
222
+ # Match by multiple attributes
223
+ expect(json).to have_json_node(:users).including(name: "Alice", role: "admin")
224
+
225
+ # Negation - array does NOT contain element
226
+ expect(json).not_to have_json_node(:users).including(name: "Charlie")
227
+ expect(json).not_to have_json_node(:users).including(role: "superadmin")
228
+
229
+ # Works with simple arrays too
230
+ json = '{"tags": ["ruby", "rails", "api"]}'
231
+ expect(json).to have_json_node(:tags).including("ruby")
232
+ expect(json).not_to have_json_node(:tags).including("python")
233
+
234
+ # Works with numbers
235
+ json = '{"scores": [85, 92, 78, 95]}'
236
+ expect(json).to have_json_node(:scores).including(92)
237
+
238
+ # Practical API example - check if a specific item exists in results
239
+ json = '{
240
+ "products": [
241
+ {"id": 1, "name": "Laptop", "in_stock": true},
242
+ {"id": 2, "name": "Phone", "in_stock": false},
243
+ {"id": 3, "name": "Tablet", "in_stock": true}
244
+ ]
245
+ }'
246
+ expect(json).to have_json_node(:products).including(id: 2, name: "Phone")
247
+ expect(json).to have_json_node(:products).including(in_stock: true)
71
248
  ```
72
249
 
250
+ ##### `including_all` - Check if array contains all specified elements
251
+
73
252
  ```ruby
74
- expect('{"parcels":1 }').to have_node(:parcels).with(1)
253
+ json = '{"permissions": [{"action": "read"}, {"action": "write"}, {"action": "delete"}]}'
254
+
255
+ # All must be present
256
+ expect(json).to have_json_node(:permissions).including_all([
257
+ {action: "read"},
258
+ {action: "write"}
259
+ ])
260
+
261
+ # Fails if any element is missing
262
+ expect(json).not_to have_json_node(:permissions).including_all([
263
+ {action: "read"},
264
+ {action: "execute"} # This doesn't exist
265
+ ])
266
+
267
+ # Simple values
268
+ json = '{"ids": [1, 2, 3, 4, 5]}'
269
+ expect(json).to have_json_node(:ids).including_all([1, 3, 5])
270
+ expect(json).not_to have_json_node(:ids).including_all([1, 6, 7])
271
+
272
+ # Strings
273
+ json = '{"features": ["dark_mode", "notifications", "export", "api_access"]}'
274
+ expect(json).to have_json_node(:features).including_all(["dark_mode", "api_access"])
275
+
276
+ # Practical example - verify required fields in response
277
+ json = '{
278
+ "user": {
279
+ "roles": [
280
+ {"name": "viewer", "level": 1},
281
+ {"name": "editor", "level": 2},
282
+ {"name": "admin", "level": 3}
283
+ ]
284
+ }
285
+ }'
286
+ expect(json).to have_json_node(:roles).including_all([
287
+ {name: "viewer"},
288
+ {name: "admin"}
289
+ ])
290
+
291
+ # Verify order doesn't matter
292
+ json = '{"steps": ["init", "validate", "process", "complete"]}'
293
+ expect(json).to have_json_node(:steps).including_all(["complete", "init"]) # Different order, still passes
75
294
  ```
76
295
 
77
- To see the json node and see if include a text, you can do this:
296
+ #### Date and Time Values
297
+
298
+ The matcher automatically handles Date, DateTime, and Time comparisons:
78
299
 
79
300
  ```ruby
80
- expect('{"error": "Transaction error: Name cant be blank"}').to have_node(:error).including_text("Transaction error")
301
+ json = '{"created_at": "2024-01-15", "updated_at": "2024-01-15T10:30:00+00:00"}'
302
+
303
+ expect(json).to have_json_node(:created_at).with(Date.parse("2024-01-15"))
304
+ expect(json).to have_json_node(:updated_at).with(DateTime.parse("2024-01-15T10:30:00+00:00"))
81
305
  ```
82
306
 
83
- You can verify boolean values too:
307
+ #### Null Values
84
308
 
85
309
  ```ruby
86
- expect('{"creditcard":true}').to have_node(:creditcard).with(true)
310
+ json = '{"middle_name": null}'
311
+
312
+ expect(json).to have_json_node(:middle_name) # Node exists (even if null)
313
+ expect(json).to have_json_node(:middle_name).with(nil) # Explicitly check for null
87
314
  ```
88
315
 
89
- ### HAVE NODE Matcher Configuration
316
+ ### have_xml_node
90
317
 
91
- You can configure if you want xml (JSON is the default):
318
+ Same API as `have_json_node`, but for XML:
92
319
 
93
320
  ```ruby
94
- APIMatchers.setup do |config|
95
- config.content_type = :xml
96
- end
321
+ xml = '<user><id>1</id><name>John</name></user>'
322
+
323
+ expect(xml).to have_xml_node(:id).with("1")
324
+ expect(xml).to have_xml_node(:name).with("John")
325
+ expect(xml).to have_xml_node(:name).including_text("Jo")
326
+ expect(xml).not_to have_xml_node(:email)
97
327
  ```
98
328
 
329
+ #### Nested XML
330
+
99
331
  ```ruby
100
- expect('<transaction><id>200</id><status>paid</status></transaction>').to have_node(:status)
332
+ xml = '
333
+ <response>
334
+ <status>success</status>
335
+ <data>
336
+ <user>
337
+ <id>123</id>
338
+ <profile>
339
+ <first_name>John</first_name>
340
+ <last_name>Doe</last_name>
341
+ </profile>
342
+ </user>
343
+ </data>
344
+ </response>
345
+ '
346
+
347
+ expect(xml).to have_xml_node(:status).with("success")
348
+ expect(xml).to have_xml_node(:id).with("123")
349
+ expect(xml).to have_xml_node(:first_name).with("John")
350
+ expect(xml).to have_xml_node(:last_name).with("Doe")
101
351
  ```
102
352
 
103
- Using the `with` method:
353
+ #### XML with Attributes
104
354
 
105
355
  ```ruby
106
- expect('<transaction><id>200</id><status>paid</status></transaction>').to have_node(:status).with('paid')
356
+ xml = '<product id="456" status="active"><name>Widget</name><price currency="USD">29.99</price></product>'
357
+
358
+ # Check element content
359
+ expect(xml).to have_xml_node(:name).with("Widget")
360
+ expect(xml).to have_xml_node(:price).with("29.99")
361
+
362
+ # Check element exists
363
+ expect(xml).to have_xml_node(:product)
364
+ expect(xml).to have_xml_node(:price)
107
365
  ```
108
366
 
109
- Or you can use the `have_xml_node` matcher:
367
+ #### Partial Text in XML
110
368
 
111
369
  ```ruby
112
- expect("<error>Transaction error: Name can't be blank</error>").to have_xml_node(:error).with("Transaction error: Name can't be blank")
370
+ xml = '<error><message>Validation failed: Email format is invalid</message></error>'
371
+
372
+ expect(xml).to have_xml_node(:message).including_text("Validation failed")
373
+ expect(xml).to have_xml_node(:message).including_text("Email format")
374
+ expect(xml).not_to have_xml_node(:message).including_text("Server error")
113
375
  ```
114
376
 
115
- To see the xml node and see if include a text, you can do this:
377
+ #### SOAP Response Example
116
378
 
117
379
  ```ruby
118
- expect("<error>Transaction error: Name can't be blank</error>").to have_xml_node(:error).including_text("Transaction error")
380
+ soap_response = '
381
+ <soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
382
+ <soap:Body>
383
+ <GetUserResponse>
384
+ <User>
385
+ <Id>42</Id>
386
+ <Username>johndoe</Username>
387
+ <Email>john@example.com</Email>
388
+ </User>
389
+ </GetUserResponse>
390
+ </soap:Body>
391
+ </soap:Envelope>
392
+ '
393
+
394
+ expect(soap_response).to have_xml_node(:Id).with("42")
395
+ expect(soap_response).to have_xml_node(:Username).with("johndoe")
396
+ expect(soap_response).to have_xml_node(:Email).including_text("@example.com")
119
397
  ```
120
398
 
121
- **If you work with xml and json in the same API, check the have_json_node and have_xml_node matchers.**
399
+ ### have_node
122
400
 
123
- You can configure the name of the method and then you will be able to use *without* the **#body** method, for example:
401
+ A generic matcher that works with either JSON or XML based on configuration:
124
402
 
125
403
  ```ruby
404
+ # Default is JSON
405
+ expect('{"name": "John"}').to have_node(:name).with("John")
406
+
407
+ # Configure for XML
126
408
  APIMatchers.setup do |config|
127
- config.response_body_method = :body
409
+ config.have_node_matcher = :xml
128
410
  end
129
411
 
130
- expect(response).to have_node(:foo).with('bar')
412
+ expect('<name>John</name>').to have_node(:name).with("John")
131
413
  ```
132
414
 
133
- Instead of:
415
+ **Tip:** If your API uses both JSON and XML, use `have_json_node` and `have_xml_node` explicitly for clarity.
416
+
417
+ ### have_json
418
+
419
+ Compare entire JSON structures for exact equality:
134
420
 
135
421
  ```ruby
136
- expect(response.body).to have_node(:foo)
422
+ # Arrays
423
+ expect('["foo", "bar", "baz"]').to have_json(["foo", "bar", "baz"])
424
+ expect('[1, 2, 3]').to have_json([1, 2, 3])
425
+
426
+ # Objects
427
+ expect('{"a": 1, "b": 2}').to have_json({"a" => 1, "b" => 2})
428
+
429
+ # Nested structures
430
+ expect('{"user": {"name": "John", "age": 30}}').to have_json({
431
+ "user" => {
432
+ "name" => "John",
433
+ "age" => 30
434
+ }
435
+ })
436
+
437
+ # Order matters for arrays
438
+ expect('["a", "b", "c"]').to have_json(["a", "b", "c"])
439
+ expect('["a", "b", "c"]').not_to have_json(["c", "b", "a"])
440
+
441
+ # Negation
442
+ expect('{"status": "ok"}').not_to have_json({"status" => "error"})
443
+
444
+ # Useful for API responses with predictable structure
445
+ expect(response.body).to have_json({
446
+ "success" => true,
447
+ "data" => []
448
+ })
137
449
  ```
138
450
 
139
- ### Have JSON Node Matcher
451
+ ### match_json_schema
452
+
453
+ Validate JSON against a [JSON Schema](https://json-schema.org/). Requires the `json_schemer` gem.
454
+
455
+ #### Basic Usage
140
456
 
141
457
  ```ruby
142
- expect('{ "transaction": { "id": 54, "status": "paid" } }').to have_json_node(:id).with(54)
458
+ schema = {
459
+ type: "object",
460
+ required: ["id", "name"],
461
+ properties: {
462
+ id: { type: "integer" },
463
+ name: { type: "string", minLength: 1 },
464
+ email: { type: "string", format: "email" }
465
+ }
466
+ }
467
+
468
+ # Valid JSON
469
+ expect('{"id": 1, "name": "John"}').to match_json_schema(schema)
470
+
471
+ # Invalid JSON - missing required field
472
+ expect('{"id": 1}').not_to match_json_schema(schema)
473
+
474
+ # Invalid JSON - wrong type
475
+ expect('{"id": "not-an-integer", "name": "John"}').not_to match_json_schema(schema)
143
476
  ```
144
477
 
145
- ### Have XML Node Matcher
478
+ #### Complex Schemas
146
479
 
147
480
  ```ruby
148
- expect("<product><name>gateway</name></product>").to have_xml_node(:name).with('gateway')
481
+ schema = {
482
+ type: "object",
483
+ required: ["data"],
484
+ properties: {
485
+ data: {
486
+ type: "array",
487
+ items: {
488
+ type: "object",
489
+ required: ["id", "type"],
490
+ properties: {
491
+ id: { type: "integer" },
492
+ type: { type: "string", enum: ["user", "admin"] },
493
+ attributes: {
494
+ type: "object",
495
+ properties: {
496
+ name: { type: "string" },
497
+ created_at: { type: "string", format: "date-time" }
498
+ }
499
+ }
500
+ }
501
+ }
502
+ },
503
+ meta: {
504
+ type: "object",
505
+ properties: {
506
+ total: { type: "integer" },
507
+ page: { type: "integer" }
508
+ }
509
+ }
510
+ }
511
+ }
512
+
513
+ json = {
514
+ data: [
515
+ { id: 1, type: "user", attributes: { name: "John" } },
516
+ { id: 2, type: "admin", attributes: { name: "Jane" } }
517
+ ],
518
+ meta: { total: 2, page: 1 }
519
+ }.to_json
520
+
521
+ expect(json).to match_json_schema(schema)
149
522
  ```
150
523
 
151
- ### Have JSON Matcher
524
+ #### Schema as JSON String
525
+
526
+ ```ruby
527
+ schema_json = '{"type": "object", "required": ["id"]}'
528
+ expect('{"id": 1}').to match_json_schema(schema_json)
529
+ ```
152
530
 
153
- Sometimes, you want to compare the entire JSON structure:
531
+ #### Array Validation
154
532
 
155
533
  ```ruby
156
- expect("['Foo', 'Bar', 'Baz']").to have_json(['Foo', 'Bar', 'Baz'])
534
+ # Array of specific type
535
+ schema = {
536
+ type: "object",
537
+ properties: {
538
+ tags: {
539
+ type: "array",
540
+ items: { type: "string" },
541
+ minItems: 1,
542
+ uniqueItems: true
543
+ }
544
+ }
545
+ }
546
+
547
+ expect('{"tags": ["ruby", "rails"]}').to match_json_schema(schema)
548
+ expect('{"tags": []}').not_to match_json_schema(schema) # minItems: 1
549
+ expect('{"tags": ["ruby", "ruby"]}').not_to match_json_schema(schema) # uniqueItems
550
+
551
+ # Array with mixed object types
552
+ schema = {
553
+ type: "array",
554
+ items: {
555
+ type: "object",
556
+ required: ["type"],
557
+ properties: {
558
+ type: { type: "string", enum: ["text", "image", "video"] },
559
+ url: { type: "string", format: "uri" }
560
+ }
561
+ }
562
+ }
563
+
564
+ json = '[{"type": "text"}, {"type": "image", "url": "https://example.com/img.png"}]'
565
+ expect(json).to match_json_schema(schema)
157
566
  ```
158
567
 
159
- ### Create Resource Matcher
568
+ #### String Formats
569
+
570
+ ```ruby
571
+ schema = {
572
+ type: "object",
573
+ properties: {
574
+ email: { type: "string", format: "email" },
575
+ website: { type: "string", format: "uri" },
576
+ uuid: { type: "string", format: "uuid" },
577
+ date: { type: "string", format: "date" },
578
+ datetime: { type: "string", format: "date-time" },
579
+ ipv4: { type: "string", format: "ipv4" }
580
+ }
581
+ }
582
+
583
+ json = '{
584
+ "email": "user@example.com",
585
+ "website": "https://example.com",
586
+ "uuid": "550e8400-e29b-41d4-a716-446655440000",
587
+ "date": "2024-01-15",
588
+ "datetime": "2024-01-15T10:30:00Z",
589
+ "ipv4": "192.168.1.1"
590
+ }'
591
+ expect(json).to match_json_schema(schema)
592
+ ```
160
593
 
161
- This matchers see the HTTP STATUS CODE is equal to 201.
594
+ #### Numeric Constraints
162
595
 
163
596
  ```ruby
164
- expect(response.status).to create_resource
597
+ schema = {
598
+ type: "object",
599
+ properties: {
600
+ age: { type: "integer", minimum: 0, maximum: 150 },
601
+ price: { type: "number", minimum: 0, exclusiveMinimum: true },
602
+ quantity: { type: "integer", multipleOf: 5 },
603
+ rating: { type: "number", minimum: 1, maximum: 5 }
604
+ }
605
+ }
606
+
607
+ expect('{"age": 25, "price": 9.99, "quantity": 10, "rating": 4.5}').to match_json_schema(schema)
608
+ expect('{"age": -1}').not_to match_json_schema(schema) # minimum: 0
609
+ expect('{"price": 0}').not_to match_json_schema(schema) # exclusiveMinimum
165
610
  ```
166
611
 
167
- ### BAD REQUEST Matcher
612
+ #### Conditional Validation
168
613
 
169
- This BAD REQUEST is a matcher that see if the HTTP STATUS code is equal to 400.
614
+ ```ruby
615
+ schema = {
616
+ type: "object",
617
+ properties: {
618
+ type: { type: "string", enum: ["personal", "business"] },
619
+ company_name: { type: "string" }
620
+ },
621
+ if: {
622
+ properties: { type: { const: "business" } }
623
+ },
624
+ then: {
625
+ required: ["company_name"]
626
+ }
627
+ }
628
+
629
+ expect('{"type": "personal"}').to match_json_schema(schema)
630
+ expect('{"type": "business", "company_name": "Acme Inc"}').to match_json_schema(schema)
631
+ expect('{"type": "business"}').not_to match_json_schema(schema) # missing company_name
632
+ ```
633
+
634
+ #### Pattern Matching
170
635
 
171
636
  ```ruby
172
- expect(response.status).to be_a_bad_request
173
- expect(response.status).to be_bad_request
637
+ schema = {
638
+ type: "object",
639
+ properties: {
640
+ phone: { type: "string", pattern: "^\\+?[1-9]\\d{1,14}$" },
641
+ slug: { type: "string", pattern: "^[a-z0-9-]+$" },
642
+ hex_color: { type: "string", pattern: "^#[0-9A-Fa-f]{6}$" }
643
+ }
644
+ }
645
+
646
+ expect('{"phone": "+14155551234", "slug": "my-post", "hex_color": "#FF5733"}').to match_json_schema(schema)
647
+ ```
648
+
649
+ #### Error Messages
650
+
651
+ When validation fails, you get detailed error messages:
652
+
653
+ ```
654
+ Expected JSON to match schema.
655
+ Errors:
656
+ - value at `/name` is not a string at name
657
+ - object at root is missing required properties: email
658
+
659
+ Response: {"id":1,"name":123}
174
660
  ```
175
661
 
176
- ### UNAUTHORIZED Matcher
662
+ ### HTTP Status Matchers
663
+
664
+ ### have_http_status
177
665
 
178
- This UNAUTHORIZED is a matcher that see if the HTTP STATUS code is equal to 401.
666
+ Check for specific HTTP status codes using either numeric codes or symbolic names:
179
667
 
180
668
  ```ruby
181
- expect(response.status).to be_unauthorized
182
- expect(response.body).to have_node(:message).with('Invalid Credentials')
669
+ # Using numeric codes
670
+ expect(response).to have_http_status(200)
671
+ expect(response).to have_http_status(201)
672
+ expect(response).to have_http_status(404)
673
+
674
+ # Using symbolic names (Rails-style)
675
+ expect(response).to have_http_status(:ok)
676
+ expect(response).to have_http_status(:created)
677
+ expect(response).to have_http_status(:not_found)
678
+ expect(response).to have_http_status(:unprocessable_entity)
679
+
680
+ # Negation
681
+ expect(response).not_to have_http_status(:ok)
183
682
  ```
184
683
 
185
- ### FORBIDDEN Matcher
684
+ ### be_successful
186
685
 
187
- This is a matcher to see if the HTTP STATUS code is equal to 403.
686
+ Check if the response status is in the 2xx range:
188
687
 
189
688
  ```ruby
190
- expect(response.status).to be_forbidden
689
+ expect(response).to be_successful # 200-299
690
+ expect(response).to be_success # alias
691
+
692
+ # Negation
693
+ expect(response).not_to be_successful
191
694
  ```
192
695
 
193
- ### INTERNAL SERVER ERROR Matcher
696
+ ### be_redirect
194
697
 
195
- This INTERNAL SERVER Error is a matcher that see if the HTTP STATUS code is equal to 500.
698
+ Check if the response status is in the 3xx range:
196
699
 
197
700
  ```ruby
198
- expect(response.status).to be_internal_server_error
199
- expect(response.body).to have_node(:message).with('An Internal Error Occurs in our precious app. :S')
701
+ expect(response).to be_redirect # 300-399
702
+ expect(response).to be_redirection # alias
200
703
  ```
201
704
 
202
- ### HTTP STATUS CODE Configuration
705
+ ### be_client_error / be_server_error
203
706
 
204
- You can configure the name method to call the http status code:
707
+ Check for client (4xx) or server (5xx) errors:
205
708
 
206
709
  ```ruby
207
- APIMatchers.setup do |config|
208
- config.http_status_method = :status
209
- end
710
+ expect(response).to be_client_error # 400-499
711
+ expect(response).to be_server_error # 500-599
210
712
  ```
211
713
 
212
- Then you can use without call the **#status** method:
714
+ ### Specific Status Matchers
715
+
716
+ Convenience matchers for common status codes:
213
717
 
214
718
  ```ruby
215
- expect(response).to create_resource
719
+ expect(response).to be_not_found # 404
720
+ expect(response).to be_unauthorized # 401
721
+ expect(response).to be_forbidden # 403
722
+ expect(response).to be_unprocessable # 422
723
+ expect(response).to be_unprocessable_entity # alias for 422
724
+ expect(response).to be_no_content # 204
216
725
  ```
217
726
 
218
- This configurations affects this matchers:
727
+ ### JSON Structure Matchers
219
728
 
220
- * `be_ok`
221
- * `create_resource`
222
- * `be_a_bad_request`
223
- * `be_internal_server_error`
224
- * `be_unauthorized`
225
- * `be_forbidden`
226
- * `be_not_found`
729
+ ### have_json_keys
227
730
 
228
- ### Be in XML Matcher
731
+ Verify that JSON contains specific keys at the root or at a given path:
229
732
 
230
- This is a matcher that see if the content type is xml:
733
+ ```ruby
734
+ json = '{"id": 1, "name": "John", "email": "john@example.com"}'
735
+
736
+ # Check for multiple keys
737
+ expect(json).to have_json_keys(:id, :name, :email)
738
+ expect(json).to have_json_keys(:id, :name) # subset is OK
739
+
740
+ # At a specific path
741
+ json = '{"user": {"id": 1, "name": "John"}}'
742
+ expect(json).to have_json_keys(:id, :name).at_path("user")
743
+
744
+ # Negation
745
+ expect(json).not_to have_json_keys(:password, :token)
746
+ ```
747
+
748
+ ### have_json_type
749
+
750
+ Verify the type of a JSON value at a given path:
231
751
 
232
752
  ```ruby
233
- expect(response.headers['Content-Type']).to be_in_xml
753
+ json = '{"id": 1, "name": "John", "active": true, "tags": ["ruby"]}'
754
+
755
+ expect(json).to have_json_type(Integer).at_path("id")
756
+ expect(json).to have_json_type(String).at_path("name")
757
+ expect(json).to have_json_type(:boolean).at_path("active") # true or false
758
+ expect(json).to have_json_type(Array).at_path("tags")
759
+ expect(json).to have_json_type(Hash).at_path("user")
760
+ expect(json).to have_json_type(NilClass).at_path("deleted_at")
761
+
762
+ # Numeric types
763
+ expect(json).to have_json_type(Numeric).at_path("price") # Integer or Float
764
+
765
+ # Nested paths
766
+ json = '{"user": {"profile": {"age": 30}}}'
767
+ expect(json).to have_json_type(Integer).at_path("user.profile.age")
234
768
  ```
235
769
 
236
- ### Be in JSON Matcher
770
+ ### Collection Matchers
237
771
 
238
- This is a matcher that see if the content type is in JSON:
772
+ ### have_json_size
773
+
774
+ Check the size of a JSON array or hash:
239
775
 
240
776
  ```ruby
777
+ json = '{"users": [{"id": 1}, {"id": 2}, {"id": 3}]}'
778
+
779
+ expect(json).to have_json_size(3).at_path("users")
780
+
781
+ # At root level
782
+ expect('[1, 2, 3, 4, 5]').to have_json_size(5)
783
+
784
+ # Hash size (number of keys)
785
+ expect('{"a": 1, "b": 2}').to have_json_size(2)
786
+
787
+ # Negation
788
+ expect(json).not_to have_json_size(10).at_path("users")
789
+ ```
790
+
791
+ ### be_sorted_by
792
+
793
+ Check if a JSON array is sorted by a specific field:
794
+
795
+ ```ruby
796
+ json = '[{"id": 1}, {"id": 2}, {"id": 3}]'
797
+
798
+ # Default ascending order
799
+ expect(json).to be_sorted_by(:id)
800
+ expect(json).to be_sorted_by(:id).ascending
801
+
802
+ # Descending order
803
+ json = '[{"id": 3}, {"id": 2}, {"id": 1}]'
804
+ expect(json).to be_sorted_by(:id).descending
805
+
806
+ # At a specific path
807
+ json = '{"users": [{"name": "Alice"}, {"name": "Bob"}, {"name": "Charlie"}]}'
808
+ expect(json).to be_sorted_by(:name).at_path("users")
809
+
810
+ # With dates
811
+ json = '[{"created_at": "2023-01-01"}, {"created_at": "2023-06-15"}]'
812
+ expect(json).to be_sorted_by(:created_at)
813
+ ```
814
+
815
+ ### Header Matchers
816
+
817
+ ### be_json / be_xml
818
+
819
+ Check the Content-Type header:
820
+
821
+ ```ruby
822
+ # Direct header value
823
+ expect("application/json; charset=utf-8").to be_json
824
+ expect("application/xml; charset=utf-8").to be_xml
825
+
826
+ # From response object
827
+ expect(response.headers['Content-Type']).to be_json
828
+ expect(response.headers['Content-Type']).to be_xml
829
+
830
+ # Negation
831
+ expect(response.headers['Content-Type']).not_to be_xml # when it's JSON
832
+ expect(response.headers['Content-Type']).not_to be_json # when it's XML
833
+
834
+ # Aliases available
241
835
  expect(response.headers['Content-Type']).to be_in_json
836
+ expect(response.headers['Content-Type']).to be_in_xml
837
+ expect(response.headers['Content-Type']).to be_a_json
838
+
839
+ # With configuration (see Configuration section), use response directly
840
+ expect(response).to be_json
841
+ expect(response).to be_xml
842
+ ```
843
+
844
+ ### have_header
845
+
846
+ Check for the presence of HTTP headers with optional value matching:
847
+
848
+ ```ruby
849
+ # Check header exists
850
+ expect(response).to have_header('X-Request-Id')
851
+ expect(response).to have_header('Content-Type')
852
+
853
+ # Check header with specific value
854
+ expect(response).to have_header('X-Request-Id').with_value('abc-123')
855
+ expect(response).to have_header('Content-Type').with_value('application/json')
856
+
857
+ # Check header matching a pattern
858
+ expect(response).to have_header('X-Request-Id').matching(/^[a-f0-9-]+$/)
859
+ expect(response).to have_header('Location').matching(/\/users\/\d+/)
860
+
861
+ # Negation
862
+ expect(response).not_to have_header('X-Internal-Only')
863
+ ```
864
+
865
+ ### have_cors_headers
866
+
867
+ Check for CORS (Cross-Origin Resource Sharing) headers:
868
+
869
+ ```ruby
870
+ # Basic check for Access-Control-Allow-Origin
871
+ expect(response).to have_cors_headers
872
+
873
+ # Check for specific origin
874
+ expect(response).to have_cors_headers.for_origin('https://example.com')
875
+
876
+ # Negation
877
+ expect(response).not_to have_cors_headers
878
+ ```
879
+
880
+ ### have_cache_control
881
+
882
+ Check Cache-Control header directives:
883
+
884
+ ```ruby
885
+ # Single directive
886
+ expect(response).to have_cache_control(:no_cache)
887
+ expect(response).to have_cache_control(:private)
888
+
889
+ # Multiple directives
890
+ expect(response).to have_cache_control(:private, :no_store)
891
+ expect(response).to have_cache_control(:public, 'max-age')
892
+
893
+ # Underscores are converted to dashes
894
+ expect(response).to have_cache_control(:must_revalidate) # checks must-revalidate
895
+
896
+ # Negation
897
+ expect(response).not_to have_cache_control(:public)
898
+ ```
899
+
900
+ ### Pagination Matchers
901
+
902
+ ### be_paginated
903
+
904
+ Check if a response contains pagination metadata:
905
+
906
+ ```ruby
907
+ json = '{"data": [], "meta": {"page": 1, "per_page": 10, "total": 100}}'
908
+ expect(json).to be_paginated
909
+
910
+ # Also works with links-style pagination
911
+ json = '{"data": [], "links": {"next": "/page/2", "prev": "/page/1"}}'
912
+ expect(json).to be_paginated
913
+
914
+ # Or root-level pagination keys
915
+ json = '{"items": [], "page": 1, "total_count": 50}'
916
+ expect(json).to be_paginated
917
+
918
+ # Negation
919
+ expect('{"data": []}').not_to be_paginated
920
+ ```
921
+
922
+ ### have_pagination_links
923
+
924
+ Check for specific pagination links:
925
+
926
+ ```ruby
927
+ json = '{"data": [], "links": {"next": "/page/2", "prev": "/page/1", "first": "/page/1", "last": "/page/10"}}'
928
+
929
+ expect(json).to have_pagination_links(:next, :prev)
930
+ expect(json).to have_pagination_links(:first, :last)
931
+ expect(json).to have_pagination_links(:next) # single link
932
+
933
+ # Negation
934
+ expect(json).not_to have_pagination_links(:next)
935
+ ```
936
+
937
+ ### have_total_count
938
+
939
+ Check for total count in pagination metadata:
940
+
941
+ ```ruby
942
+ json = '{"data": [], "meta": {"total": 100}}'
943
+ expect(json).to have_total_count(100)
944
+
945
+ # Works with various key names
946
+ json = '{"items": [], "total_count": 50}'
947
+ expect(json).to have_total_count(50)
948
+
949
+ # Negation
950
+ expect(json).not_to have_total_count(200)
951
+ ```
952
+
953
+ ### Error Response Matchers
954
+
955
+ ### have_error / have_errors
956
+
957
+ Check if a response contains error information:
958
+
959
+ ```ruby
960
+ # Array of errors
961
+ json = '{"errors": [{"message": "Name is required"}]}'
962
+ expect(json).to have_error
963
+ expect(json).to have_errors # alias
964
+
965
+ # Single error object
966
+ json = '{"error": "Something went wrong"}'
967
+ expect(json).to have_error
968
+
969
+ # Error message key
970
+ json = '{"message": "Resource not found"}'
971
+ expect(json).to have_error
972
+
973
+ # Negation
974
+ expect('{"data": {"id": 1}}').not_to have_error
242
975
  ```
243
976
 
244
- ### Headers Configuration
977
+ ### have_error_on
245
978
 
246
- You can configure the name method to call the headers and content type:
979
+ Check for errors on specific fields:
247
980
 
248
981
  ```ruby
982
+ # API-style errors (array of error objects)
983
+ json = '{"errors": [{"field": "email", "message": "is invalid"}]}'
984
+ expect(json).to have_error_on(:email)
985
+ expect(json).to have_error_on(:email).with_message("is invalid")
986
+
987
+ # Rails-style errors (hash with field keys)
988
+ json = '{"email": ["is invalid", "is already taken"]}'
989
+ expect(json).to have_error_on(:email)
990
+ expect(json).to have_error_on(:email).with_message("is invalid")
991
+
992
+ # Pattern matching
993
+ expect(json).to have_error_on(:email).matching(/invalid/i)
994
+
995
+ # Negation
996
+ expect(json).not_to have_error_on(:name)
997
+ ```
998
+
999
+ ### JSON:API Matchers
1000
+
1001
+ ### be_json_api_compliant
1002
+
1003
+ Validate that a response follows the [JSON:API specification](https://jsonapi.org/):
1004
+
1005
+ ```ruby
1006
+ json = '{"data": {"id": "1", "type": "users", "attributes": {"name": "John"}}}'
1007
+ expect(json).to be_json_api_compliant
1008
+
1009
+ # With errors
1010
+ json = '{"errors": [{"status": "404", "title": "Not Found"}]}'
1011
+ expect(json).to be_json_api_compliant
1012
+
1013
+ # With meta only
1014
+ json = '{"meta": {"total": 100}}'
1015
+ expect(json).to be_json_api_compliant
1016
+
1017
+ # Validates structure requirements:
1018
+ # - Must have data, errors, or meta
1019
+ # - data and errors cannot coexist
1020
+ # - Resources must have type
1021
+ # - etc.
1022
+ ```
1023
+
1024
+ ### have_json_api_data
1025
+
1026
+ Check for JSON:API data member with optional type and id matching:
1027
+
1028
+ ```ruby
1029
+ json = '{"data": {"id": "1", "type": "users", "attributes": {"name": "John"}}}'
1030
+
1031
+ expect(json).to have_json_api_data
1032
+ expect(json).to have_json_api_data.of_type("users")
1033
+ expect(json).to have_json_api_data.with_id("1")
1034
+ expect(json).to have_json_api_data.of_type("users").with_id("1")
1035
+
1036
+ # Works with arrays
1037
+ json = '{"data": [{"id": "1", "type": "users"}, {"id": "2", "type": "users"}]}'
1038
+ expect(json).to have_json_api_data.of_type("users")
1039
+ expect(json).to have_json_api_data.with_id("2")
1040
+ ```
1041
+
1042
+ ### have_json_api_attributes
1043
+
1044
+ Check for attributes in JSON:API data:
1045
+
1046
+ ```ruby
1047
+ json = '{"data": {"id": "1", "type": "users", "attributes": {"name": "John", "email": "john@example.com"}}}'
1048
+
1049
+ expect(json).to have_json_api_attributes(:name, :email)
1050
+ expect(json).to have_json_api_attributes(:name)
1051
+
1052
+ # Negation
1053
+ expect(json).not_to have_json_api_attributes(:password)
1054
+ ```
1055
+
1056
+ ### have_json_api_relationships
1057
+
1058
+ Check for relationships in JSON:API data:
1059
+
1060
+ ```ruby
1061
+ json = '{
1062
+ "data": {
1063
+ "id": "1",
1064
+ "type": "posts",
1065
+ "relationships": {
1066
+ "author": {"data": {"id": "1", "type": "users"}},
1067
+ "comments": {"data": []}
1068
+ }
1069
+ }
1070
+ }'
1071
+
1072
+ expect(json).to have_json_api_relationships(:author, :comments)
1073
+ expect(json).to have_json_api_relationships(:author)
1074
+
1075
+ # Negation
1076
+ expect(json).not_to have_json_api_relationships(:tags)
1077
+ ```
1078
+
1079
+ ### HATEOAS Matchers
1080
+
1081
+ ### have_link
1082
+
1083
+ Check for HATEOAS (Hypermedia as the Engine of Application State) links:
1084
+
1085
+ ```ruby
1086
+ # HAL-style links
1087
+ json = '{"_links": {"self": {"href": "/users/1"}, "posts": {"href": "/users/1/posts"}}}'
1088
+
1089
+ expect(json).to have_link(:self)
1090
+ expect(json).to have_link(:posts)
1091
+
1092
+ # With exact href match
1093
+ expect(json).to have_link(:self).with_href("/users/1")
1094
+
1095
+ # With pattern match
1096
+ expect(json).to have_link(:self).with_href(/\/users\/\d+/)
1097
+
1098
+ # Simple links format
1099
+ json = '{"links": {"self": "/users/1"}}'
1100
+ expect(json).to have_link(:self).with_href("/users/1")
1101
+
1102
+ # Negation
1103
+ expect(json).not_to have_link(:delete)
1104
+ ```
1105
+
1106
+ ## Configuration
1107
+
1108
+ Configure APIMatchers to work seamlessly with your test setup:
1109
+
1110
+ ```ruby
1111
+ # spec/spec_helper.rb or spec/support/api_matchers.rb
249
1112
  APIMatchers.setup do |config|
250
- config.header_method = :headers
1113
+ # Automatically extract body from response objects
1114
+ config.response_body_method = :body
1115
+
1116
+ # Configure header access for be_json/be_xml and header matchers
1117
+ config.header_method = :headers
251
1118
  config.header_content_type_key = 'Content-Type'
1119
+
1120
+ # Set default format for have_node matcher (:json or :xml)
1121
+ config.have_node_matcher = :json
1122
+
1123
+ # HTTP status extraction method (for status matchers)
1124
+ config.http_status_method = :status
1125
+
1126
+ # Pagination configuration
1127
+ config.pagination_meta_path = 'meta' # path to pagination metadata
1128
+ config.pagination_links_path = 'links' # path to pagination links
1129
+
1130
+ # Error response configuration
1131
+ config.errors_path = 'errors' # path to errors array
1132
+ config.error_message_key = 'message' # key for error message
1133
+ config.error_field_key = 'field' # key for error field name
1134
+
1135
+ # HATEOAS links configuration
1136
+ config.links_path = '_links' # path to HATEOAS links (HAL style)
1137
+ end
1138
+ ```
1139
+
1140
+ ### With Rails
1141
+
1142
+ ```ruby
1143
+ APIMatchers.setup do |config|
1144
+ config.response_body_method = :body
1145
+ config.header_method = :headers
1146
+ config.header_content_type_key = 'Content-Type'
1147
+ config.http_status_method = :status
1148
+ end
1149
+
1150
+ # Now you can use response directly:
1151
+ RSpec.describe "API", type: :request do
1152
+ it "returns JSON" do
1153
+ get "/api/users/1"
1154
+
1155
+ expect(response).to have_http_status(:ok)
1156
+ expect(response).to have_json_node(:id).with(1)
1157
+ expect(response).to be_json
1158
+ end
252
1159
  end
253
1160
  ```
254
1161
 
255
- And then you will be able to use without call the **#headers** calling the **#['Content-Type']** method:
1162
+ ### With HTTP Clients (HTTParty, Faraday, etc.)
256
1163
 
257
1164
  ```ruby
258
- expect(response).to be_in_json
259
- expect(response).to be_in_xml
1165
+ APIMatchers.setup do |config|
1166
+ config.response_body_method = :body
1167
+ config.header_method = :headers
1168
+ config.header_content_type_key = 'content-type' # Note: lowercase for some clients
1169
+ config.http_status_method = :code # or :status depending on client
1170
+ end
260
1171
  ```
261
1172
 
262
- ### Acknowlegments
1173
+ ## Upgrading from 0.x to 1.0
1174
+
1175
+ ### Breaking Changes
1176
+
1177
+ 1. **Ruby 3.1+ required** - Ruby 1.9, 2.x, and early 3.x versions are no longer supported.
1178
+
1179
+ 2. **HTTP Status Matchers Renamed** - The old status matchers have been replaced with new, more comprehensive ones:
1180
+
1181
+ | Old Matcher (0.x) | New Matcher (1.0) |
1182
+ |-------------------|-------------------|
1183
+ | `be_ok` | `have_http_status(:ok)` or `be_successful` |
1184
+ | `create_resource` | `have_http_status(:created)` |
1185
+ | `be_bad_request` | `have_http_status(:bad_request)` or `be_client_error` |
1186
+ | `be_unauthorized` | `be_unauthorized` (unchanged) |
1187
+ | `be_forbidden` | `be_forbidden` (unchanged) |
1188
+ | `be_not_found` | `be_not_found` (unchanged) |
1189
+ | `be_unprocessable_entity` | `be_unprocessable` or `be_unprocessable_entity` |
1190
+ | `be_internal_server_error` | `have_http_status(:internal_server_error)` or `be_server_error` |
263
1191
 
264
- * Special thanks to Daniel Konishi to contribute in the product that I extracted the matchers to this gem.
1192
+ ### New Features in 1.0
265
1193
 
266
- ### Contributors
1194
+ - **HTTP Status Matchers** - `have_http_status`, `be_successful`, `be_redirect`, `be_client_error`, `be_server_error`, and specific status matchers
1195
+ - **JSON Structure Matchers** - `have_json_keys`, `have_json_type`
1196
+ - **Collection Matchers** - `have_json_size`, `be_sorted_by`
1197
+ - **Header Matchers** - `have_header`, `have_cors_headers`, `have_cache_control`
1198
+ - **Pagination Matchers** - `be_paginated`, `have_pagination_links`, `have_total_count`
1199
+ - **Error Response Matchers** - `have_error`, `have_errors`, `have_error_on`
1200
+ - **JSON:API Matchers** - `be_json_api_compliant`, `have_json_api_data`, `have_json_api_attributes`, `have_json_api_relationships`
1201
+ - **HATEOAS Matchers** - `have_link`
1202
+ - **`including(attributes)`** - Check if a JSON array contains an element matching the given attributes
1203
+ - **`including_all(elements)`** - Check if a JSON array contains all specified elements
1204
+ - **`match_json_schema(schema)`** - Validate JSON against a JSON Schema (requires `json_schemer` gem)
1205
+
1206
+ ## Contributing
1207
+
1208
+ 1. Fork it
1209
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
1210
+ 3. Commit your changes (`git commit -am 'Add some feature'`)
1211
+ 4. Push to the branch (`git push origin my-new-feature`)
1212
+ 5. Create new Pull Request
1213
+
1214
+ ## Acknowledgements
1215
+
1216
+ * Special thanks to Daniel Konishi for contributing to the product from which I extracted the matchers for this gem.
1217
+
1218
+ ## Contributors
267
1219
 
268
1220
  * Stephen Orens
269
1221
  * Lucas Caton
1222
+
1223
+ ## License
1224
+
1225
+ MIT License. See [LICENSE](LICENSE) for details.