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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 47e2dfd9b5b01a159c9d81dffc650b0a807d6491669100f3e4c0939b9012eb3e
4
- data.tar.gz: 0b5f6d62d08a5139fa60ce2d2a193d92949193767a08378f5212e576bc762f50
3
+ metadata.gz: 3fef8eafbf21f5d0f307fa44423fa4549ad602de47671f6bec67257121e66263
4
+ data.tar.gz: fbea7383a57565b80277d093d66c4e1bd80a24b3343bf1118f86699ed04e88f8
5
5
  SHA512:
6
- metadata.gz: ca75926bef46af5c64380a992281242afada213a4e4975ef41f0e1e8c525395c41a88618fd04accec24613cb13b107a8a0ae04b25a4e32cb4ded5464e6093760
7
- data.tar.gz: f9762615e58277d690c7cdc7b7426dac3818e3f1fe7f12eb6e3e753d9cf0e19d366a7eae198911b5f33d7911ef72ded6826dabe63dd5b66bde19f98c7954fd87
6
+ metadata.gz: 00a1efa6d45045ac68fb5df02fadc8acae07465c9e1a82e7561da4ecdb135a8557123eb9c31a70fa1b61bff95c676e6095a21d8fba77c48881f9a1908831437b
7
+ data.tar.gz: 12e146f01dd7b46a1ffa2a014c7392f66af342d594b07c5171f56bb63e36232fa45ec35dc353bbb2e44c9e589c10628d85d7903bd43b7993a32e618722da2805
data/.gitignore CHANGED
@@ -70,3 +70,6 @@ yarn-debug.log*
70
70
 
71
71
  # Local planning notes
72
72
  /docs/plans/
73
+
74
+ # Built gem artifacts
75
+ /rspec-rest-*.gem
data/CHANGELOG.md CHANGED
@@ -7,7 +7,31 @@ Semantic Versioning.
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
- No changes yet.
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
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- rspec-rest (0.3.0)
4
+ rspec-rest (0.5.0)
5
5
  rack-test (~> 2.1)
6
6
 
7
7
  GEM
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 description form: `get(path, description: "...") { ... }`
210
- - legacy positional form `get(path, "description")` is deprecated and will be removed in `1.0`.
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 descriptions.
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 == :expect_json_contract ? "expect_json_contract" : "contract(: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
@@ -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 |path, positional_description = nil, description: nil, &block|
145
- description_options = resolve_verb_description_options(method, positional_description, description)
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(:emit_positional_description_warning, method, description_options)
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)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module RSpec
4
4
  module Rest
5
- VERSION = "0.3.0"
5
+ VERSION = "0.5.0"
6
6
  end
7
7
  end
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.3.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-10 00:00:00.000000000 Z
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