rspec-rest 0.3.0 → 0.5.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 +4 -4
- data/.gitignore +3 -0
- data/CHANGELOG.md +25 -1
- data/Gemfile.lock +1 -1
- data/README.md +41 -21
- data/lib/rspec/rest/body_expectations.rb +29 -0
- data/lib/rspec/rest/contract_expectations.rb +49 -1
- data/lib/rspec/rest/contract_overrides_builder.rb +83 -0
- data/lib/rspec/rest/contract_with_matcher.rb +34 -0
- data/lib/rspec/rest/dsl.rb +67 -5
- data/lib/rspec/rest/expectations.rb +22 -1
- data/lib/rspec/rest/version.rb +1 -1
- metadata +5 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3fef8eafbf21f5d0f307fa44423fa4549ad602de47671f6bec67257121e66263
|
|
4
|
+
data.tar.gz: fbea7383a57565b80277d093d66c4e1bd80a24b3343bf1118f86699ed04e88f8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 00a1efa6d45045ac68fb5df02fadc8acae07465c9e1a82e7561da4ecdb135a8557123eb9c31a70fa1b61bff95c676e6095a21d8fba77c48881f9a1908831437b
|
|
7
|
+
data.tar.gz: 12e146f01dd7b46a1ffa2a014c7392f66af342d594b07c5171f56bb63e36232fa45ec35dc353bbb2e44c9e589c10628d85d7903bd43b7993a32e618722da2805
|
data/.gitignore
CHANGED
data/CHANGELOG.md
CHANGED
|
@@ -7,7 +7,31 @@ Semantic Versioning.
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
## [0.5.0] - 2026-03-12
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- `expect_json_at` now supports top-level shorthand selectors:
|
|
14
|
+
- Symbol keys (for example `:message`)
|
|
15
|
+
- plain String keys (for example `"message"`)
|
|
16
|
+
Full JSONPath selectors (for example `"$.items[0].id"`) remain supported.
|
|
17
|
+
- `contract_with(:name, overrides)` for contract matcher composition with specific
|
|
18
|
+
value assertions while preserving the base contract shape/type expectations.
|
|
19
|
+
- Raw body expectation helpers for non-JSON/text responses:
|
|
20
|
+
- `expect_body_includes(fragment)`
|
|
21
|
+
- `expect_body_matches(pattern)`
|
|
22
|
+
|
|
23
|
+
## [0.4.0] - 2026-03-11
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
- Verb DSL now supports keyword request paths:
|
|
27
|
+
- `get path: "/users", description: "..." do ... end`
|
|
28
|
+
- same keyword `path:` support for `post`, `put`, `patch`, and `delete`.
|
|
29
|
+
|
|
30
|
+
### Deprecated
|
|
31
|
+
- Positional verb path arguments are deprecated and scheduled for removal in `1.0`:
|
|
32
|
+
- `get "/users", description: "..." do ... end`
|
|
33
|
+
Use keyword paths instead:
|
|
34
|
+
- `get path: "/users", description: "..." do ... end`
|
|
11
35
|
|
|
12
36
|
## [0.3.0] - 2026-03-10
|
|
13
37
|
|
data/Gemfile.lock
CHANGED
data/README.md
CHANGED
|
@@ -48,13 +48,13 @@ RSpec.describe "Users API" do
|
|
|
48
48
|
end
|
|
49
49
|
|
|
50
50
|
resource "/users" do
|
|
51
|
-
get "/" do
|
|
51
|
+
get path: "/" do
|
|
52
52
|
expect_status 200
|
|
53
53
|
expect_header "Content-Type", "application/json"
|
|
54
54
|
expect_json array_of(hash_including("id" => integer, "email" => string))
|
|
55
55
|
end
|
|
56
56
|
|
|
57
|
-
post "/" do
|
|
57
|
+
post path: "/" do
|
|
58
58
|
json "email" => "carl@example.com", "name" => "Carl"
|
|
59
59
|
expect_status 201
|
|
60
60
|
capture :user_id, "$.id"
|
|
@@ -112,7 +112,7 @@ RSpec.describe "Posts API" do
|
|
|
112
112
|
resource "/posts" do
|
|
113
113
|
with_auth auth_token
|
|
114
114
|
|
|
115
|
-
get "/", description: "returns posts page 1" do
|
|
115
|
+
get path: "/", description: "returns posts page 1" do
|
|
116
116
|
query page: 1, per_page: 10
|
|
117
117
|
|
|
118
118
|
expect_status 200
|
|
@@ -152,14 +152,14 @@ RSpec.describe "Posts API" do
|
|
|
152
152
|
resource "/posts" do
|
|
153
153
|
with_auth auth_token
|
|
154
154
|
|
|
155
|
-
get "/" do
|
|
155
|
+
get path: "/" do
|
|
156
156
|
query page: 1, per_page: 10
|
|
157
157
|
expect_status 200
|
|
158
158
|
expect_json array_of(contract(:post_summary))
|
|
159
159
|
expect_page_size 10
|
|
160
160
|
end
|
|
161
161
|
|
|
162
|
-
get "/{id}" do
|
|
162
|
+
get path: "/{id}" do
|
|
163
163
|
path_params id: 999_999
|
|
164
164
|
expect_error status: 404, message: "Post not found"
|
|
165
165
|
end
|
|
@@ -168,7 +168,7 @@ RSpec.describe "Posts API" do
|
|
|
168
168
|
resource "/uploads" do
|
|
169
169
|
with_auth auth_token
|
|
170
170
|
|
|
171
|
-
post "/" do
|
|
171
|
+
post path: "/" do
|
|
172
172
|
multipart!
|
|
173
173
|
file :file, Rails.root.join("spec/fixtures/files/sample_upload.txt"), content_type: "text/plain"
|
|
174
174
|
expect_status 201
|
|
@@ -206,16 +206,17 @@ Supported config:
|
|
|
206
206
|
|
|
207
207
|
- `resource "/users" do ... end`
|
|
208
208
|
- `get`, `post`, `put`, `patch`, `delete`
|
|
209
|
-
- preferred
|
|
210
|
-
- legacy positional form `get(
|
|
209
|
+
- preferred form: `get(path: "/users", description: "...") { ... }`
|
|
210
|
+
- legacy positional path form `get("/users", description: "...")` is deprecated and will be removed in `1.0`.
|
|
211
211
|
It is deprecated to avoid `Rails/HttpPositionalArguments` false-positives in RuboCop.
|
|
212
|
+
- legacy positional description form `get "/users", "description"` is deprecated and will be removed in `1.0`.
|
|
212
213
|
|
|
213
214
|
Resource paths are composable and support placeholders:
|
|
214
215
|
|
|
215
216
|
```ruby
|
|
216
217
|
resource "/users" do
|
|
217
218
|
resource "/{id}/posts" do
|
|
218
|
-
get "/" do
|
|
219
|
+
get path: "/" do
|
|
219
220
|
path_params id: 1
|
|
220
221
|
expect_status 404
|
|
221
222
|
end
|
|
@@ -227,7 +228,7 @@ Example with an explicit behavior name:
|
|
|
227
228
|
|
|
228
229
|
```ruby
|
|
229
230
|
resource "/users" do
|
|
230
|
-
get "/", description: "returns public users for authenticated client" do
|
|
231
|
+
get path: "/", description: "returns public users for authenticated client" do
|
|
231
232
|
expect_status 200
|
|
232
233
|
end
|
|
233
234
|
end
|
|
@@ -243,9 +244,8 @@ Note: Example names (including `base_path`) are composed when the verb macro is
|
|
|
243
244
|
`rspec-rest` verbs (`get`, `post`, etc.) define examples via DSL macros, and can trigger
|
|
244
245
|
false-positives in some RuboCop cops:
|
|
245
246
|
|
|
246
|
-
- `Rails/HttpPositionalArguments`: use keyword descriptions (`description:`), not positional
|
|
247
|
+
- `Rails/HttpPositionalArguments`: use keyword paths (`path:`) and keyword descriptions (`description:`), not positional arguments.
|
|
247
248
|
- `RSpec/EmptyExampleGroup`: a `context` that only contains `resource` + verb DSL may be flagged as empty.
|
|
248
|
-
|
|
249
249
|
Recommended mitigation is scoped configuration for files that use `rspec-rest`:
|
|
250
250
|
|
|
251
251
|
```yaml
|
|
@@ -261,7 +261,7 @@ If you only have a few affected groups, use an inline disable around the DSL gro
|
|
|
261
261
|
# rubocop:disable RSpec/EmptyExampleGroup
|
|
262
262
|
context "when authenticated" do
|
|
263
263
|
resource "/posts" do
|
|
264
|
-
get "/", description: "returns posts" do
|
|
264
|
+
get path: "/", description: "returns posts" do
|
|
265
265
|
expect_status 200
|
|
266
266
|
end
|
|
267
267
|
end
|
|
@@ -303,12 +303,12 @@ resource "/posts" do
|
|
|
303
303
|
with_auth ENV.fetch("API_TOKEN", "token-123")
|
|
304
304
|
with_headers "X-Client" => "mobile"
|
|
305
305
|
|
|
306
|
-
get "/" do
|
|
306
|
+
get path: "/" do
|
|
307
307
|
query page: 2
|
|
308
308
|
expect_status 200
|
|
309
309
|
end
|
|
310
310
|
|
|
311
|
-
get "/admin" do
|
|
311
|
+
get path: "/admin" do
|
|
312
312
|
header "X-Client", "internal-tool" # request-level override
|
|
313
313
|
query locale: "fr" # request-level override
|
|
314
314
|
expect_status 200
|
|
@@ -333,7 +333,7 @@ Inside verb blocks:
|
|
|
333
333
|
Example:
|
|
334
334
|
|
|
335
335
|
```ruby
|
|
336
|
-
post "/" do
|
|
336
|
+
post path: "/" do
|
|
337
337
|
headers "X-Trace-Id" => "abc-123"
|
|
338
338
|
bearer "token-123"
|
|
339
339
|
query include_details: "true"
|
|
@@ -345,7 +345,7 @@ end
|
|
|
345
345
|
Multipart upload example:
|
|
346
346
|
|
|
347
347
|
```ruby
|
|
348
|
-
post "/uploads" do
|
|
348
|
+
post path: "/uploads" do
|
|
349
349
|
multipart!
|
|
350
350
|
file :file, Rails.root.join("spec/fixtures/files/sample_upload.txt"), content_type: "text/plain"
|
|
351
351
|
expect_status 201
|
|
@@ -361,8 +361,11 @@ Available expectation helpers:
|
|
|
361
361
|
- `expect_header(key, value_or_regex)`
|
|
362
362
|
- `expect_json(expected = nil, &block)`
|
|
363
363
|
- `contract(name)` (lookup helper for reusable JSON contracts)
|
|
364
|
+
- `contract_with(name, overrides)` (contract lookup with value overrides)
|
|
364
365
|
- `expect_json_contract(name)` (deprecated; use `contract(name)`)
|
|
365
366
|
- `expect_json_at(selector, expected = nil, &block)`
|
|
367
|
+
- `expect_body_includes(fragment)`
|
|
368
|
+
- `expect_body_matches(pattern)` (`String` or `Regexp`)
|
|
366
369
|
- `expect_json_first(expected = nil, &block)`
|
|
367
370
|
- `expect_json_item(index, expected = nil, &block)`
|
|
368
371
|
- `expect_json_last(expected = nil, &block)`
|
|
@@ -388,6 +391,9 @@ Available expectation helpers:
|
|
|
388
391
|
- `expect_json_at "$.user.email", "jane@example.com"`
|
|
389
392
|
- block mode:
|
|
390
393
|
- `expect_json_at "$.items[0]" { |item| expect(item["id"]).to integer }`
|
|
394
|
+
- top-level shorthand:
|
|
395
|
+
- `expect_json_at :message, "Not found"`
|
|
396
|
+
- `expect_json_at "message", "Not found"`
|
|
391
397
|
|
|
392
398
|
For common array-item checks, use Ruby-style helpers instead of selector strings:
|
|
393
399
|
|
|
@@ -401,10 +407,20 @@ expect_json_item 2, hash_including("name" => "Third")
|
|
|
401
407
|
expect_json_last { |item| expect(item["id"]).to integer }
|
|
402
408
|
```
|
|
403
409
|
|
|
410
|
+
Raw body assertions for text/non-JSON endpoints:
|
|
411
|
+
|
|
412
|
+
```ruby
|
|
413
|
+
get path: "/bad_json" do
|
|
414
|
+
expect_status 200
|
|
415
|
+
expect_body_includes "not json"
|
|
416
|
+
expect_body_matches(/this is not json/)
|
|
417
|
+
end
|
|
418
|
+
```
|
|
419
|
+
|
|
404
420
|
`expect_error` is a convenience helper for common API error payload assertions:
|
|
405
421
|
|
|
406
422
|
```ruby
|
|
407
|
-
get "/{id}" do
|
|
423
|
+
get path: "/{id}" do
|
|
408
424
|
path_params id: 999
|
|
409
425
|
expect_error status: 404, message: "Post not found"
|
|
410
426
|
end
|
|
@@ -413,7 +429,7 @@ end
|
|
|
413
429
|
Pagination helpers:
|
|
414
430
|
|
|
415
431
|
```ruby
|
|
416
|
-
get "/" do
|
|
432
|
+
get path: "/" do
|
|
417
433
|
query page: 2, per_page: 10
|
|
418
434
|
expect_status 200
|
|
419
435
|
expect_page_size 10
|
|
@@ -436,12 +452,16 @@ contract :post_summary do
|
|
|
436
452
|
)
|
|
437
453
|
end
|
|
438
454
|
|
|
439
|
-
get "/" do
|
|
455
|
+
get path: "/" do
|
|
440
456
|
expect_status 200
|
|
441
457
|
expect_json array_of(contract(:post_summary))
|
|
458
|
+
expect_json array_of(contract_with(:post_summary, id: 1, title: "My Title", author: { id: 1 }))
|
|
442
459
|
end
|
|
443
460
|
```
|
|
444
461
|
|
|
462
|
+
Use `contract_with` when you want the base contract shape/types plus specific values
|
|
463
|
+
for selected keys. Override keys must exist in the contract definition.
|
|
464
|
+
|
|
445
465
|
JSON type helpers:
|
|
446
466
|
|
|
447
467
|
- `integer`
|
|
@@ -465,7 +485,7 @@ Selector syntax (minimal JSON selector):
|
|
|
465
485
|
Example:
|
|
466
486
|
|
|
467
487
|
```ruby
|
|
468
|
-
post "/" do
|
|
488
|
+
post path: "/" do
|
|
469
489
|
json "email" => "flow@example.com", "name" => "Flow"
|
|
470
490
|
expect_status 201
|
|
471
491
|
capture :user_id, "$.id"
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpec
|
|
4
|
+
module Rest
|
|
5
|
+
module BodyExpectations
|
|
6
|
+
def expect_body_includes(fragment)
|
|
7
|
+
with_request_dump_on_failure do
|
|
8
|
+
case fragment
|
|
9
|
+
when String
|
|
10
|
+
expect(rest_response.body).to include(fragment)
|
|
11
|
+
else
|
|
12
|
+
raise ArgumentError, "expect_body_includes requires a String fragment, got #{fragment.class}"
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def expect_body_matches(pattern)
|
|
18
|
+
with_request_dump_on_failure do
|
|
19
|
+
case pattern
|
|
20
|
+
when String, Regexp
|
|
21
|
+
expect(rest_response.body).to match(pattern)
|
|
22
|
+
else
|
|
23
|
+
raise ArgumentError, "expect_body_matches requires a String or Regexp pattern, got #{pattern.class}"
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "contract_matcher"
|
|
4
|
+
require_relative "contract_overrides_builder"
|
|
5
|
+
require_relative "contract_with_matcher"
|
|
4
6
|
|
|
5
7
|
module RSpec
|
|
6
8
|
module Rest
|
|
@@ -25,6 +27,45 @@ module RSpec
|
|
|
25
27
|
contract_matcher_for(name, lookup_name: :expect_json_contract)
|
|
26
28
|
end
|
|
27
29
|
|
|
30
|
+
def contract_with(name, overrides = nil, **keyword_overrides)
|
|
31
|
+
overrides_builder = ContractOverridesBuilder.new(context: self)
|
|
32
|
+
resolved_overrides = overrides_builder.normalize!(overrides, keyword_overrides)
|
|
33
|
+
contract_name = normalize_contract_name(name)
|
|
34
|
+
return unknown_contract_matcher(name_error_message(name, lookup_name: :contract_with)) if contract_name.nil?
|
|
35
|
+
|
|
36
|
+
definition = self.class.rest_contract_definition(contract_name)
|
|
37
|
+
if definition.nil?
|
|
38
|
+
available = self.class.send(:rest_contracts).keys.map(&:inspect).sort
|
|
39
|
+
message = "Unknown contract #{contract_name.inspect}. Available contracts: [#{available.join(', ')}]"
|
|
40
|
+
return unknown_contract_matcher(message)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
definition_for_matcher = definition
|
|
44
|
+
|
|
45
|
+
if resolved_overrides.any?
|
|
46
|
+
base_contract_value = instance_exec(&definition)
|
|
47
|
+
key_tree = overrides_builder.key_tree_for(base_contract_value)
|
|
48
|
+
if key_tree.nil?
|
|
49
|
+
return unknown_contract_matcher(
|
|
50
|
+
"Contract #{contract_name.inspect} does not declare hash keys, so overrides cannot be applied."
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
validation_error = overrides_builder.validate_keys(key_tree, resolved_overrides)
|
|
55
|
+
return unknown_contract_matcher(validation_error) unless validation_error.nil?
|
|
56
|
+
|
|
57
|
+
# Reuse the already-evaluated contract value inside the matcher so the
|
|
58
|
+
# contract definition is executed at most once per `contract_with` call.
|
|
59
|
+
definition_for_matcher = proc { base_contract_value }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
ContractWithMatcher.new(
|
|
63
|
+
name: contract_name,
|
|
64
|
+
contract_matcher: ContractMatcher.new(name: contract_name, definition: definition_for_matcher, context: self),
|
|
65
|
+
overrides_matcher: overrides_builder.build_matcher(resolved_overrides)
|
|
66
|
+
)
|
|
67
|
+
end
|
|
68
|
+
|
|
28
69
|
private
|
|
29
70
|
|
|
30
71
|
def contract_matcher_for(name, lookup_name:)
|
|
@@ -50,7 +91,14 @@ module RSpec
|
|
|
50
91
|
end
|
|
51
92
|
|
|
52
93
|
def name_error_message(name, lookup_name:)
|
|
53
|
-
lookup_hint = lookup_name
|
|
94
|
+
lookup_hint = case lookup_name
|
|
95
|
+
when :expect_json_contract
|
|
96
|
+
"expect_json_contract"
|
|
97
|
+
when :contract_with
|
|
98
|
+
"contract_with(:name, ...)"
|
|
99
|
+
else
|
|
100
|
+
"contract(:name)"
|
|
101
|
+
end
|
|
54
102
|
"Invalid contract name #{name.inspect} (#{name.class}). " \
|
|
55
103
|
"#{lookup_hint} requires a contract name that responds to #to_sym."
|
|
56
104
|
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpec
|
|
4
|
+
module Rest
|
|
5
|
+
class ContractOverridesBuilder
|
|
6
|
+
def initialize(context:)
|
|
7
|
+
@context = context
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def normalize!(overrides, keyword_overrides)
|
|
11
|
+
unless keyword_overrides.empty?
|
|
12
|
+
raise ArgumentError, "contract_with received both positional Hash and keyword overrides" unless overrides.nil?
|
|
13
|
+
|
|
14
|
+
return keyword_overrides
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
return {} if overrides.nil?
|
|
18
|
+
return overrides if overrides.is_a?(Hash)
|
|
19
|
+
|
|
20
|
+
raise ArgumentError,
|
|
21
|
+
"contract_with requires overrides to be a Hash or keyword arguments, got #{overrides.class}"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def key_tree_for(contract_value)
|
|
25
|
+
hash = extract_contract_hash(contract_value)
|
|
26
|
+
return nil if hash.nil?
|
|
27
|
+
|
|
28
|
+
hash.each_with_object({}) do |(key, nested), memo|
|
|
29
|
+
memo[key.to_s] = key_tree_for(nested)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def validate_keys(key_tree, overrides, path = [])
|
|
34
|
+
overrides.each do |key, value|
|
|
35
|
+
key_name = key.to_s
|
|
36
|
+
unless key_tree.key?(key_name)
|
|
37
|
+
available_keys = key_tree.keys.sort
|
|
38
|
+
location = path.empty? ? "contract root" : "$.#{path.join('.')}"
|
|
39
|
+
return "Unknown override key #{key_name.inspect} at #{location}. " \
|
|
40
|
+
"Available keys: [#{available_keys.map(&:inspect).join(', ')}]"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
next unless value.is_a?(Hash)
|
|
44
|
+
|
|
45
|
+
nested_tree = key_tree[key_name]
|
|
46
|
+
if nested_tree.nil?
|
|
47
|
+
location = (path + [key_name]).join(".")
|
|
48
|
+
return "Override key #{key_name.inspect} at $.#{location} does not support nested overrides."
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
nested_error = validate_keys(nested_tree, value, path + [key_name])
|
|
52
|
+
return nested_error unless nested_error.nil?
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
nil
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def build_matcher(overrides)
|
|
59
|
+
@context.hash_including(
|
|
60
|
+
overrides.each_with_object({}) do |(key, value), memo|
|
|
61
|
+
memo[key.to_s] = value.is_a?(Hash) ? build_matcher(value) : value
|
|
62
|
+
end
|
|
63
|
+
)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def extract_contract_hash(value)
|
|
69
|
+
return value if value.is_a?(Hash)
|
|
70
|
+
|
|
71
|
+
return nil unless value.respond_to?(:matcher_name) && value.respond_to?(:expecteds)
|
|
72
|
+
|
|
73
|
+
matcher_name = value.matcher_name
|
|
74
|
+
return nil unless matcher_name == :a_hash_including
|
|
75
|
+
|
|
76
|
+
expecteds = value.expecteds
|
|
77
|
+
return nil unless expecteds.is_a?(Array) && expecteds.first.is_a?(Hash)
|
|
78
|
+
|
|
79
|
+
expecteds.first
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpec
|
|
4
|
+
module Rest
|
|
5
|
+
class ContractWithMatcher
|
|
6
|
+
def initialize(name:, contract_matcher:, overrides_matcher:)
|
|
7
|
+
@name = name
|
|
8
|
+
@contract_matcher = contract_matcher
|
|
9
|
+
@overrides_matcher = overrides_matcher
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def matches?(actual)
|
|
13
|
+
@contract_matched = @contract_matcher.matches?(actual)
|
|
14
|
+
return false unless @contract_matched
|
|
15
|
+
|
|
16
|
+
@overrides_matcher.matches?(actual)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def failure_message
|
|
20
|
+
return @contract_matcher.failure_message unless @contract_matched
|
|
21
|
+
|
|
22
|
+
"Contract #{@name.inspect} override assertion failed: #{@overrides_matcher.failure_message}"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def failure_message_when_negated
|
|
26
|
+
"Expected value not to match contract #{@name.inspect} with overrides"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def description
|
|
30
|
+
"match contract #{@name.inspect} with overrides"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
data/lib/rspec/rest/dsl.rb
CHANGED
|
@@ -117,11 +117,44 @@ module RSpec
|
|
|
117
117
|
end
|
|
118
118
|
end
|
|
119
119
|
|
|
120
|
+
module PathArgumentSupport
|
|
121
|
+
private
|
|
122
|
+
|
|
123
|
+
def warn_on_deprecated_positional_path(_method)
|
|
124
|
+
Deprecation.warn(
|
|
125
|
+
key: :verb_positional_path,
|
|
126
|
+
message: "Positional request paths (for example: get(\"/users\")) are deprecated and will be " \
|
|
127
|
+
"removed in 1.0. Use keyword paths instead (for example: get(path: \"/users\")). " \
|
|
128
|
+
"This avoids RuboCop Rails/HttpPositionalArguments false-positives."
|
|
129
|
+
)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def resolve_path_options(method:, positional_path:, keyword_path:)
|
|
133
|
+
if !positional_path.nil? && !keyword_path.nil?
|
|
134
|
+
raise ArgumentError,
|
|
135
|
+
"#{method}(...) received both positional and keyword paths. Use only `path:`."
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
effective_path = keyword_path.nil? ? positional_path : keyword_path
|
|
139
|
+
if effective_path.nil?
|
|
140
|
+
raise ArgumentError,
|
|
141
|
+
"#{method}(...) requires a request path. Pass it as `path:` (preferred) " \
|
|
142
|
+
"or as the first positional argument."
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
{
|
|
146
|
+
path: effective_path,
|
|
147
|
+
using_positional_path: !positional_path.nil? && keyword_path.nil?
|
|
148
|
+
}
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
120
152
|
module ClassMethods
|
|
121
153
|
include ClassLevelContracts
|
|
122
154
|
include ClassLevelPresets
|
|
123
155
|
include RouteNamingSupport
|
|
124
156
|
include DescriptionArgumentSupport
|
|
157
|
+
include PathArgumentSupport
|
|
125
158
|
|
|
126
159
|
def api(&)
|
|
127
160
|
builder = ApiConfigBuilder.new(rest_config)
|
|
@@ -141,25 +174,28 @@ module RSpec
|
|
|
141
174
|
end
|
|
142
175
|
|
|
143
176
|
HTTP_METHODS.each do |method|
|
|
144
|
-
define_method(method) do |
|
|
145
|
-
|
|
177
|
+
define_method(method) do |positional_path = nil, positional_description = nil, path: nil,
|
|
178
|
+
description: nil, &block|
|
|
179
|
+
path_options, description_options = resolve_verb_options(
|
|
180
|
+
method, positional_path, path, positional_description, description
|
|
181
|
+
)
|
|
146
182
|
resource_path = current_resource_path
|
|
147
183
|
request_presets = deep_dup_presets(current_request_presets)
|
|
148
184
|
example_name = build_example_name(
|
|
149
185
|
method: method,
|
|
150
|
-
path: path,
|
|
186
|
+
path: path_options[:path],
|
|
151
187
|
resource_path: resource_path,
|
|
152
188
|
description: description_options[:description]
|
|
153
189
|
)
|
|
154
190
|
it(example_name) do
|
|
155
191
|
start_rest_request(
|
|
156
192
|
method: method,
|
|
157
|
-
path: path,
|
|
193
|
+
path: path_options[:path],
|
|
158
194
|
resource_path: resource_path,
|
|
159
195
|
presets: request_presets
|
|
160
196
|
)
|
|
161
197
|
instance_eval(&block) if block
|
|
162
|
-
self.class.send(:
|
|
198
|
+
self.class.send(:emit_verb_deprecation_warnings, method, path_options, description_options)
|
|
163
199
|
execute_rest_request_if_pending
|
|
164
200
|
end
|
|
165
201
|
end
|
|
@@ -181,12 +217,31 @@ module RSpec
|
|
|
181
217
|
|
|
182
218
|
private
|
|
183
219
|
|
|
220
|
+
def emit_verb_deprecation_warnings(method, path_options, description_options)
|
|
221
|
+
emit_positional_path_warning(method, path_options)
|
|
222
|
+
emit_positional_description_warning(method, description_options)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def emit_positional_path_warning(method, path_options)
|
|
226
|
+
return unless path_options[:using_positional_path]
|
|
227
|
+
|
|
228
|
+
send(:warn_on_deprecated_positional_path, method)
|
|
229
|
+
end
|
|
230
|
+
|
|
184
231
|
def emit_positional_description_warning(method, description_options)
|
|
185
232
|
return unless description_options[:using_positional_description]
|
|
186
233
|
|
|
187
234
|
send(:warn_on_deprecated_positional_description, method)
|
|
188
235
|
end
|
|
189
236
|
|
|
237
|
+
def resolve_verb_path_options(method, positional_path, path)
|
|
238
|
+
resolve_path_options(
|
|
239
|
+
method: method,
|
|
240
|
+
positional_path: positional_path,
|
|
241
|
+
keyword_path: path
|
|
242
|
+
)
|
|
243
|
+
end
|
|
244
|
+
|
|
190
245
|
def resolve_verb_description_options(method, positional_description, description)
|
|
191
246
|
resolve_description_options(
|
|
192
247
|
method: method,
|
|
@@ -194,6 +249,13 @@ module RSpec
|
|
|
194
249
|
keyword_description: description
|
|
195
250
|
)
|
|
196
251
|
end
|
|
252
|
+
|
|
253
|
+
def resolve_verb_options(method, positional_path, path, positional_description, description)
|
|
254
|
+
[
|
|
255
|
+
resolve_verb_path_options(method, positional_path, path),
|
|
256
|
+
resolve_verb_description_options(method, positional_description, description)
|
|
257
|
+
]
|
|
258
|
+
end
|
|
197
259
|
end
|
|
198
260
|
|
|
199
261
|
module InstanceMethods
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "formatters/request_dump"
|
|
4
4
|
require_relative "formatters/request_recorder"
|
|
5
|
+
require_relative "body_expectations"
|
|
5
6
|
require_relative "error_expectations"
|
|
6
7
|
require_relative "contract_expectations"
|
|
7
8
|
require_relative "header_expectations"
|
|
@@ -14,6 +15,7 @@ module RSpec
|
|
|
14
15
|
module Rest
|
|
15
16
|
module Expectations
|
|
16
17
|
include ContractExpectations
|
|
18
|
+
include BodyExpectations
|
|
17
19
|
include ErrorExpectations
|
|
18
20
|
include HeaderExpectations
|
|
19
21
|
include JsonItemExpectations
|
|
@@ -35,13 +37,32 @@ module RSpec
|
|
|
35
37
|
|
|
36
38
|
def expect_json_at(selector, expected = nil, &block)
|
|
37
39
|
with_request_dump_on_failure do
|
|
38
|
-
selected = JsonSelector.extract(rest_response.json, selector)
|
|
40
|
+
selected = JsonSelector.extract(rest_response.json, normalize_json_selector(selector))
|
|
39
41
|
evaluate_json_value(selected, expected, &block)
|
|
40
42
|
end
|
|
41
43
|
end
|
|
42
44
|
|
|
43
45
|
private
|
|
44
46
|
|
|
47
|
+
def normalize_json_selector(selector)
|
|
48
|
+
case selector
|
|
49
|
+
when Symbol
|
|
50
|
+
"$.#{selector}"
|
|
51
|
+
when String
|
|
52
|
+
return selector if selector.start_with?("$")
|
|
53
|
+
return "$.#{selector}" if selector.match?(/\A[a-zA-Z_][a-zA-Z0-9_]*\z/)
|
|
54
|
+
|
|
55
|
+
raise InvalidJsonSelectorError,
|
|
56
|
+
"Invalid selector #{selector.inspect}. Top-level shorthand accepts Symbol or simple String " \
|
|
57
|
+
"keys (for example :message or \"message\"). Use JSONPath for nested selectors " \
|
|
58
|
+
"(for example \"$.items[0].id\")."
|
|
59
|
+
else
|
|
60
|
+
raise InvalidJsonSelectorError,
|
|
61
|
+
"Invalid selector #{selector.inspect}. Selector must be a Symbol, a String top-level key, " \
|
|
62
|
+
"or a JSONPath String starting with '$'."
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
45
66
|
def evaluate_json_value(value, expected = nil, &block)
|
|
46
67
|
if block
|
|
47
68
|
instance_exec(value, &block)
|
data/lib/rspec/rest/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rspec-rest
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Carl
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-03-
|
|
11
|
+
date: 2026-03-12 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rack-test
|
|
@@ -105,12 +105,15 @@ files:
|
|
|
105
105
|
- Rakefile
|
|
106
106
|
- exe/rspec-rest
|
|
107
107
|
- lib/rspec/rest.rb
|
|
108
|
+
- lib/rspec/rest/body_expectations.rb
|
|
108
109
|
- lib/rspec/rest/captures.rb
|
|
109
110
|
- lib/rspec/rest/class_level_contracts.rb
|
|
110
111
|
- lib/rspec/rest/class_level_presets.rb
|
|
111
112
|
- lib/rspec/rest/config.rb
|
|
112
113
|
- lib/rspec/rest/contract_expectations.rb
|
|
113
114
|
- lib/rspec/rest/contract_matcher.rb
|
|
115
|
+
- lib/rspec/rest/contract_overrides_builder.rb
|
|
116
|
+
- lib/rspec/rest/contract_with_matcher.rb
|
|
114
117
|
- lib/rspec/rest/deprecation.rb
|
|
115
118
|
- lib/rspec/rest/dsl.rb
|
|
116
119
|
- lib/rspec/rest/error_expectations.rb
|