rspec-rest 0.2.0 → 0.4.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: 6d00a1ef08b51bc62ee1b737217eb5f441ee625ca3cbd848d0db1cbacff619f2
4
- data.tar.gz: 6507ec718a8f8a0ecf5de375e2efba9f3d686cc577258baf89a05d9ccc47d64d
3
+ metadata.gz: e72f8991e5d53bdb5bd0ccfa7524a42fcf5c99afead0caba45bb8876ef66bf8c
4
+ data.tar.gz: c33cdc064fc0d12c76f42f036b1490732ecbd6aaa1c6c8213bfaef6260e99d03
5
5
  SHA512:
6
- metadata.gz: 742d99b9ceecc699b920038a11476f304a657b1023599d819fc69fd17f19154387745ceffeabda7cc98b36ccf065ba6c164f2eb3c618c34822e68ecd9e4f50b6
7
- data.tar.gz: 6c80fa3a4b6f088172aa9b795ff8fc48e82cc7f64052bdf3b0028e3741983d6c73088bb72a29be44f9575da1ecc57c70b97d4a8f428b96f4385a5aded4805e81
6
+ metadata.gz: 240876e213f49ac41ddc68015bd1d91744df3af41454e53f4cd561a10042f839544fae0ac7537dd3eee68f4c76727e9f1ff07136b8a1e060fcbc068e401aca69
7
+ data.tar.gz: d9c09b903fe84ef94c2290633b1770fd0d05263a909ffa4a9d2aeec6c8aa26e8a7e26dbff6622073d6bf99b8d24b988898a5040a12b7dc4b19a09981c78b02fd
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,48 @@ Semantic Versioning.
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
- No changes yet.
10
+ ## [0.4.0] - 2026-03-11
11
+
12
+ ### Added
13
+ - Verb DSL now supports keyword request paths:
14
+ - `get path: "/users", description: "..." do ... end`
15
+ - same keyword `path:` support for `post`, `put`, `patch`, and `delete`.
16
+
17
+ ### Deprecated
18
+ - Positional verb path arguments are deprecated and scheduled for removal in `1.0`:
19
+ - `get "/users", description: "..." do ... end`
20
+ Use keyword paths instead:
21
+ - `get path: "/users", description: "..." do ... end`
22
+
23
+ ## [0.3.0] - 2026-03-10
24
+
25
+ ### Added
26
+ - Contract lookup helper for matcher composition in examples:
27
+ - `contract(:name)`
28
+ - Internal deprecation utility for gem APIs:
29
+ - `RSpec::Rest::Deprecation.warn(key:, message:)`
30
+ - once-per-key warning emission in RSpec output.
31
+ - Keyword request description support on verb DSL calls:
32
+ - `get(path, description: "...") { ... }` (and same for other verbs).
33
+
34
+ ### Changed
35
+ - Failure-time `curl` output now uses an auth token environment placeholder for redacted auth-like headers, improving copy/paste usability:
36
+ - `Authorization: Bearer $API_AUTH_TOKEN`
37
+ - Redacted auth scheme prefixes are preserved in `curl` output for `Authorization` and `Proxy-Authorization` (for example `Basic`, `Digest`).
38
+ - README examples now prefer:
39
+ - `contract(:name)` over nested `expect_json_contract(...)`
40
+ - keyword request descriptions (`description:`).
41
+ - Added README RuboCop compatibility guidance for:
42
+ - `Rails/HttpPositionalArguments`
43
+ - `RSpec/EmptyExampleGroup`.
44
+
45
+ ### Deprecated
46
+ - `expect_json_contract(name)` is deprecated and scheduled for removal in `1.0`.
47
+ Use `contract(:name)` for contract lookup in examples.
48
+ - Positional verb descriptions are deprecated and scheduled for removal in `1.0`:
49
+ - `get(path, "description") { ... }`
50
+ Use keyword descriptions instead:
51
+ - `get(path, description: "...") { ... }`
11
52
 
12
53
  ## [0.2.0] - 2026-03-08
13
54
 
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- rspec-rest (0.2.0)
4
+ rspec-rest (0.4.0)
5
5
  rack-test (~> 2.1)
6
6
 
7
7
  GEM
data/README.md CHANGED
@@ -11,10 +11,6 @@ It focuses on:
11
11
  - high-signal failure output with request/response context
12
12
  - auto-generated `curl` reproduction commands on failures
13
13
 
14
- ## Status
15
-
16
- The gem is pre-release and in active development toward `0.1.0`.
17
-
18
14
  ## Installation
19
15
 
20
16
  When published:
@@ -52,13 +48,13 @@ RSpec.describe "Users API" do
52
48
  end
53
49
 
54
50
  resource "/users" do
55
- get "/" do
51
+ get path: "/" do
56
52
  expect_status 200
57
53
  expect_header "Content-Type", "application/json"
58
54
  expect_json array_of(hash_including("id" => integer, "email" => string))
59
55
  end
60
56
 
61
- post "/" do
57
+ post path: "/" do
62
58
  json "email" => "carl@example.com", "name" => "Carl"
63
59
  expect_status 201
64
60
  capture :user_id, "$.id"
@@ -69,7 +65,7 @@ end
69
65
 
70
66
  ## Before and After (Rack::Test to rspec-rest)
71
67
 
72
- The example below shows the same behavior test written two ways.
68
+ The first two examples below test the same behavior in two styles.
73
69
 
74
70
  Before (`Rack::Test` + manual response parsing):
75
71
 
@@ -113,28 +109,57 @@ RSpec.describe "Posts API" do
113
109
  default_format :json
114
110
  end
115
111
 
116
- with_query locale: "en"
117
- with_headers "X-Tenant-Id" => "tenant-123"
112
+ resource "/posts" do
113
+ with_auth auth_token
114
+
115
+ get path: "/", description: "returns posts page 1" do
116
+ query page: 1, per_page: 10
117
+
118
+ expect_status 200
119
+ expect_json array_of(hash_including("id" => integer, "author" => hash_including("id" => integer)))
120
+ expect_json_first hash_including("id" => posts.first.id)
121
+ expect_json_item(0) { |item| expect(item["author"]["id"]).to eq(posts.first.author.id) }
122
+ end
123
+ end
124
+ end
125
+ ```
126
+
127
+ What improves in the apples-to-apples rewrite:
128
+
129
+ - Request setup is declarative (`api`, `resource`, `with_auth`, `query`).
130
+ - JSON assertions read closer to the business intent (`expect_json_first`, `expect_json_item`).
131
+ - Failure output includes request/response context and a reproducible `curl`.
132
+
133
+ Beyond the baseline rewrite, `rspec-rest` can also express additional API concerns
134
+ with concise helpers:
135
+
136
+ ```ruby
137
+ RSpec.describe "Posts API" do
138
+ include RSpec::Rest
139
+
140
+ let(:auth_token) { "test-token" }
141
+
142
+ api do
143
+ app MyApp::Base
144
+ base_path "/api/v1"
145
+ default_format :json
146
+ end
147
+
118
148
  contract :post_summary do
119
149
  hash_including("id" => integer, "author" => hash_including("id" => integer))
120
150
  end
121
151
 
122
152
  resource "/posts" do
123
153
  with_auth auth_token
124
- with_query per_page: 10
125
-
126
- get "/", "returns first page of posts for authenticated user" do
127
- query page: 1
128
154
 
155
+ get path: "/" do
156
+ query page: 1, per_page: 10
129
157
  expect_status 200
130
- expect_json array_of(expect_json_contract(:post_summary))
131
- expect_json_first hash_including("id" => posts.first.id)
132
- expect_json_item(0) { |item| expect(item["author"]["id"]).to eq(posts.first.author.id) }
158
+ expect_json array_of(contract(:post_summary))
133
159
  expect_page_size 10
134
- expect_max_page_size 20
135
160
  end
136
161
 
137
- get "/{id}" do
162
+ get path: "/{id}" do
138
163
  path_params id: 999_999
139
164
  expect_error status: 404, message: "Post not found"
140
165
  end
@@ -143,7 +168,7 @@ RSpec.describe "Posts API" do
143
168
  resource "/uploads" do
144
169
  with_auth auth_token
145
170
 
146
- post "/" do
171
+ post path: "/" do
147
172
  multipart!
148
173
  file :file, Rails.root.join("spec/fixtures/files/sample_upload.txt"), content_type: "text/plain"
149
174
  expect_status 201
@@ -153,13 +178,6 @@ RSpec.describe "Posts API" do
153
178
  end
154
179
  ```
155
180
 
156
- What improves:
157
-
158
- - Request setup is declarative (`api`, `resource`, shared presets, `query`, `multipart!`, `file`).
159
- - JSON expectations are concise and structure-aware (`expect_json`, `expect_json_at`).
160
- - Common API outcomes are one-liners (`expect_error`, pagination helpers).
161
- - Failures include request/response context plus a reproducible `curl`.
162
-
163
181
  ## API Config (`api`)
164
182
 
165
183
  `api` defines shared runtime configuration for a spec group.
@@ -188,14 +206,17 @@ Supported config:
188
206
 
189
207
  - `resource "/users" do ... end`
190
208
  - `get`, `post`, `put`, `patch`, `delete`
191
- - optional form: `get(path, description = nil) { ... }`
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
+ 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`.
192
213
 
193
214
  Resource paths are composable and support placeholders:
194
215
 
195
216
  ```ruby
196
217
  resource "/users" do
197
218
  resource "/{id}/posts" do
198
- get "/" do
219
+ get path: "/" do
199
220
  path_params id: 1
200
221
  expect_status 404
201
222
  end
@@ -207,7 +228,7 @@ Example with an explicit behavior name:
207
228
 
208
229
  ```ruby
209
230
  resource "/users" do
210
- get "/", "returns public users for authenticated client" do
231
+ get path: "/", description: "returns public users for authenticated client" do
211
232
  expect_status 200
212
233
  end
213
234
  end
@@ -217,6 +238,44 @@ RSpec example output uses the composed full route (including `base_path`) and ap
217
238
  - `GET /v1/users - returns public users for authenticated client`
218
239
 
219
240
  Note: Example names (including `base_path`) are composed when the verb macro is evaluated. To ensure the example names include `base_path`, declare your `api` (and its `base_path`) before defining `resource` blocks and their verbs. If you configure `api`/`base_path` afterward, requests will use the configured `base_path`, but the previously defined example names will not reflect it.
241
+
242
+ ## RuboCop Compatibility
243
+
244
+ `rspec-rest` verbs (`get`, `post`, etc.) define examples via DSL macros, and can trigger
245
+ false-positives in some RuboCop cops:
246
+
247
+ - `Rails/HttpPositionalArguments`: use keyword paths (`path:`) and keyword descriptions (`description:`), not positional arguments.
248
+ - `RSpec/EmptyExampleGroup`: a `context` that only contains `resource` + verb DSL may be flagged as empty.
249
+ Recommended mitigation is scoped configuration for files that use `rspec-rest`:
250
+
251
+ ```yaml
252
+ # .rubocop.yml
253
+ RSpec/EmptyExampleGroup:
254
+ Exclude:
255
+ - "spec/api/rest/**/*"
256
+ ```
257
+
258
+ If you only have a few affected groups, use an inline disable around the DSL group:
259
+
260
+ ```ruby
261
+ # rubocop:disable RSpec/EmptyExampleGroup
262
+ context "when authenticated" do
263
+ resource "/posts" do
264
+ get path: "/", description: "returns posts" do
265
+ expect_status 200
266
+ end
267
+ end
268
+ end
269
+ # rubocop:enable RSpec/EmptyExampleGroup
270
+ ```
271
+
272
+ Prefer scoped exclusions/disables over broad project-wide disables so non-DSL specs
273
+ keep full lint coverage.
274
+
275
+ We are also considering a dedicated RuboCop extension for `rspec-rest` (for example,
276
+ `rubocop-rspec-rest`) to reduce manual configuration over time. Until then, the
277
+ scoped patterns above are the recommended approach.
278
+
220
279
  ## Shared Request Presets
221
280
 
222
281
  Define shared request defaults at group/resource scope:
@@ -244,12 +303,12 @@ resource "/posts" do
244
303
  with_auth ENV.fetch("API_TOKEN", "token-123")
245
304
  with_headers "X-Client" => "mobile"
246
305
 
247
- get "/" do
306
+ get path: "/" do
248
307
  query page: 2
249
308
  expect_status 200
250
309
  end
251
310
 
252
- get "/admin" do
311
+ get path: "/admin" do
253
312
  header "X-Client", "internal-tool" # request-level override
254
313
  query locale: "fr" # request-level override
255
314
  expect_status 200
@@ -274,7 +333,7 @@ Inside verb blocks:
274
333
  Example:
275
334
 
276
335
  ```ruby
277
- post "/" do
336
+ post path: "/" do
278
337
  headers "X-Trace-Id" => "abc-123"
279
338
  bearer "token-123"
280
339
  query include_details: "true"
@@ -286,7 +345,7 @@ end
286
345
  Multipart upload example:
287
346
 
288
347
  ```ruby
289
- post "/uploads" do
348
+ post path: "/uploads" do
290
349
  multipart!
291
350
  file :file, Rails.root.join("spec/fixtures/files/sample_upload.txt"), content_type: "text/plain"
292
351
  expect_status 201
@@ -301,7 +360,8 @@ Available expectation helpers:
301
360
  - `expect_status(code)`
302
361
  - `expect_header(key, value_or_regex)`
303
362
  - `expect_json(expected = nil, &block)`
304
- - `expect_json_contract(name)`
363
+ - `contract(name)` (lookup helper for reusable JSON contracts)
364
+ - `expect_json_contract(name)` (deprecated; use `contract(name)`)
305
365
  - `expect_json_at(selector, expected = nil, &block)`
306
366
  - `expect_json_first(expected = nil, &block)`
307
367
  - `expect_json_item(index, expected = nil, &block)`
@@ -344,7 +404,7 @@ expect_json_last { |item| expect(item["id"]).to integer }
344
404
  `expect_error` is a convenience helper for common API error payload assertions:
345
405
 
346
406
  ```ruby
347
- get "/{id}" do
407
+ get path: "/{id}" do
348
408
  path_params id: 999
349
409
  expect_error status: 404, message: "Post not found"
350
410
  end
@@ -353,7 +413,7 @@ end
353
413
  Pagination helpers:
354
414
 
355
415
  ```ruby
356
- get "/" do
416
+ get path: "/" do
357
417
  query page: 2, per_page: 10
358
418
  expect_status 200
359
419
  expect_page_size 10
@@ -365,7 +425,7 @@ end
365
425
  ## Lightweight Contracts
366
426
 
367
427
  A contract is a named, reusable JSON expectation (usually a response shape matcher).
368
- Define it once in your spec group, then apply it anywhere with `expect_json_contract`.
428
+ Define it once in your spec group, then apply it anywhere with `contract(:name)`.
369
429
 
370
430
  ```ruby
371
431
  contract :post_summary do
@@ -376,9 +436,9 @@ contract :post_summary do
376
436
  )
377
437
  end
378
438
 
379
- get "/" do
439
+ get path: "/" do
380
440
  expect_status 200
381
- expect_json array_of(expect_json_contract(:post_summary))
441
+ expect_json array_of(contract(:post_summary))
382
442
  end
383
443
  ```
384
444
 
@@ -405,7 +465,7 @@ Selector syntax (minimal JSON selector):
405
465
  Example:
406
466
 
407
467
  ```ruby
408
- post "/" do
468
+ post path: "/" do
409
469
  json "email" => "flow@example.com", "name" => "Flow"
410
470
  expect_status 201
411
471
  capture :user_id, "$.id"
@@ -424,6 +484,48 @@ When an expectation fails, output includes:
424
484
  Sensitive headers are redacted by default and can be customized via
425
485
  `redact_headers`.
426
486
 
487
+ Example (truncated):
488
+
489
+ ```text
490
+ expected: 201
491
+ got: 422
492
+
493
+ Request:
494
+ POST /api/v1/posts
495
+ Headers:
496
+ Accept: application/json
497
+ Authorization: [REDACTED]
498
+ Content-Type: application/json
499
+ Body:
500
+ {
501
+ "title": "",
502
+ "body": "Example"
503
+ }
504
+
505
+ Response:
506
+ Status: 422
507
+ Headers:
508
+ Content-Type: application/json
509
+ Body:
510
+ {
511
+ "error": "Validation failed",
512
+ "details": {
513
+ "title": ["can't be blank"]
514
+ }
515
+ }
516
+
517
+ Reproduce with:
518
+ curl -X POST 'http://localhost:3000/api/v1/posts' -H 'Accept: application/json' -H "Authorization: Bearer $API_AUTH_TOKEN" -H 'Content-Type: application/json' -d '{"title":"","body":"Example"}'
519
+ ```
520
+
521
+ Before running an authenticated command, set your token:
522
+
523
+ ```bash
524
+ export API_AUTH_TOKEN="your_token_here"
525
+ ```
526
+
527
+ Then paste the generated `curl` command directly in your terminal for fast manual debugging.
528
+
427
529
  ## Contributing
428
530
 
429
531
  Contributions are welcome.
@@ -5,9 +5,31 @@ require_relative "contract_matcher"
5
5
  module RSpec
6
6
  module Rest
7
7
  module ContractExpectations
8
+ def contract(name = nil, &)
9
+ if block_given?
10
+ raise ArgumentError,
11
+ "contract(:name) lookup does not accept a block in examples. " \
12
+ "Define contracts at the example group level with `contract(:name) { ... }`."
13
+ end
14
+
15
+ contract_matcher_for(name, lookup_name: :contract)
16
+ end
17
+
8
18
  def expect_json_contract(name)
19
+ Deprecation.warn(
20
+ key: :expect_json_contract,
21
+ message: "`expect_json_contract` is deprecated and will be removed in 1.0. " \
22
+ "Use `contract(:name)` instead."
23
+ )
24
+
25
+ contract_matcher_for(name, lookup_name: :expect_json_contract)
26
+ end
27
+
28
+ private
29
+
30
+ def contract_matcher_for(name, lookup_name:)
9
31
  contract_name = normalize_contract_name(name)
10
- return unknown_contract_matcher(name_error_message(name)) if contract_name.nil?
32
+ return unknown_contract_matcher(name_error_message(name, lookup_name: lookup_name)) if contract_name.nil?
11
33
 
12
34
  definition = self.class.rest_contract_definition(contract_name)
13
35
  return ContractMatcher.new(name: contract_name, definition: definition, context: self) unless definition.nil?
@@ -17,8 +39,6 @@ module RSpec
17
39
  unknown_contract_matcher(message)
18
40
  end
19
41
 
20
- private
21
-
22
42
  def normalize_contract_name(name)
23
43
  return nil if name.nil? || !name.respond_to?(:to_sym)
24
44
 
@@ -29,9 +49,10 @@ module RSpec
29
49
  UnknownContractMatcher.new(message)
30
50
  end
31
51
 
32
- def name_error_message(name)
52
+ def name_error_message(name, lookup_name:)
53
+ lookup_hint = lookup_name == :expect_json_contract ? "expect_json_contract" : "contract(:name)"
33
54
  "Invalid contract name #{name.inspect} (#{name.class}). " \
34
- "expect_json_contract requires a contract name that responds to #to_sym."
55
+ "#{lookup_hint} requires a contract name that responds to #to_sym."
35
56
  end
36
57
  end
37
58
  end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpec
4
+ module Rest
5
+ module Deprecation
6
+ module_function
7
+
8
+ def warn(key:, message:)
9
+ return if already_emitted?(key)
10
+
11
+ emit("DEPRECATION: #{message}")
12
+ end
13
+
14
+ def reset!
15
+ emitted_keys.clear
16
+ end
17
+
18
+ def already_emitted?(key)
19
+ normalized_key = key.to_s
20
+ return true if emitted_keys[normalized_key]
21
+
22
+ emitted_keys[normalized_key] = true
23
+ false
24
+ end
25
+ private_class_method :already_emitted?
26
+
27
+ def emitted_keys
28
+ @emitted_keys ||= {}
29
+ end
30
+ private_class_method :emitted_keys
31
+
32
+ def emit(message)
33
+ reporter = rspec_reporter
34
+ if reporter.respond_to?(:message)
35
+ reporter.message(message)
36
+ else
37
+ Kernel.warn(message)
38
+ end
39
+ end
40
+ private_class_method :emit
41
+
42
+ def rspec_reporter
43
+ return nil unless defined?(::RSpec) && ::RSpec.respond_to?(:configuration)
44
+
45
+ ::RSpec.configuration.reporter
46
+ rescue StandardError
47
+ nil
48
+ end
49
+ private_class_method :rspec_reporter
50
+ end
51
+ end
52
+ end
@@ -56,9 +56,105 @@ module RSpec
56
56
  end
57
57
  end
58
58
 
59
+ module RouteNamingSupport
60
+ private
61
+
62
+ def build_example_name(method:, path:, resource_path:, description:)
63
+ route = compose_route_for_example(resource_path: resource_path, endpoint_path: path)
64
+ base = "#{method.to_s.upcase} #{route}"
65
+ normalized_description = description.to_s.strip
66
+ return base if normalized_description.empty?
67
+
68
+ "#{base} - #{normalized_description}"
69
+ end
70
+
71
+ def compose_route_for_example(resource_path:, endpoint_path:)
72
+ PathComposer.compose(
73
+ base_path: rest_config.base_path,
74
+ resource_path: resource_path,
75
+ endpoint_path: endpoint_path
76
+ )
77
+ end
78
+
79
+ def current_resource_path
80
+ stack = @rest_resource_stack || []
81
+ return nil if stack.empty?
82
+
83
+ first_had_leading_slash = stack.first.to_s.start_with?("/")
84
+ normalized_segments = stack.map do |segment|
85
+ segment.to_s.sub(%r{\A/+}, "").sub(%r{/+\z}, "")
86
+ end.reject(&:empty?)
87
+
88
+ path = normalized_segments.join("/")
89
+ first_had_leading_slash && !path.empty? ? "/#{path}" : path
90
+ end
91
+ end
92
+
93
+ module DescriptionArgumentSupport
94
+ private
95
+
96
+ def warn_on_deprecated_positional_description(_method)
97
+ Deprecation.warn(
98
+ key: :verb_positional_description,
99
+ message: "Positional request descriptions (for example: get(path, description)) are deprecated and " \
100
+ "will be removed in 1.0. Use keyword descriptions " \
101
+ "(for example: get(path, description: \"...\")). " \
102
+ "This avoids RuboCop Rails/HttpPositionalArguments false-positives."
103
+ )
104
+ end
105
+
106
+ def resolve_description_options(method:, positional_description:, keyword_description:)
107
+ if !positional_description.nil? && !keyword_description.nil?
108
+ raise ArgumentError,
109
+ "#{method}(...) received both positional and keyword descriptions. " \
110
+ "Use only `description:`."
111
+ end
112
+
113
+ {
114
+ description: keyword_description.nil? ? positional_description : keyword_description,
115
+ using_positional_description: !positional_description.nil? && keyword_description.nil?
116
+ }
117
+ end
118
+ end
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
+
59
152
  module ClassMethods
60
153
  include ClassLevelContracts
61
154
  include ClassLevelPresets
155
+ include RouteNamingSupport
156
+ include DescriptionArgumentSupport
157
+ include PathArgumentSupport
62
158
 
63
159
  def api(&)
64
160
  builder = ApiConfigBuilder.new(rest_config)
@@ -78,23 +174,28 @@ module RSpec
78
174
  end
79
175
 
80
176
  HTTP_METHODS.each do |method|
81
- define_method(method) do |path, description = nil, &block|
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
+ )
82
182
  resource_path = current_resource_path
83
183
  request_presets = deep_dup_presets(current_request_presets)
84
184
  example_name = build_example_name(
85
185
  method: method,
86
- path: path,
186
+ path: path_options[:path],
87
187
  resource_path: resource_path,
88
- description: description
188
+ description: description_options[:description]
89
189
  )
90
190
  it(example_name) do
91
191
  start_rest_request(
92
192
  method: method,
93
- path: path,
193
+ path: path_options[:path],
94
194
  resource_path: resource_path,
95
195
  presets: request_presets
96
196
  )
97
197
  instance_eval(&block) if block
198
+ self.class.send(:emit_verb_deprecation_warnings, method, path_options, description_options)
98
199
  execute_rest_request_if_pending
99
200
  end
100
201
  end
@@ -116,34 +217,44 @@ module RSpec
116
217
 
117
218
  private
118
219
 
119
- def build_example_name(method:, path:, resource_path:, description:)
120
- route = compose_route_for_example(resource_path: resource_path, endpoint_path: path)
121
- base = "#{method.to_s.upcase} #{route}"
122
- normalized_description = description.to_s.strip
123
- return base if normalized_description.empty?
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
124
224
 
125
- "#{base} - #{normalized_description}"
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)
126
229
  end
127
230
 
128
- def compose_route_for_example(resource_path:, endpoint_path:)
129
- PathComposer.compose(
130
- base_path: rest_config.base_path,
131
- resource_path: resource_path,
132
- endpoint_path: endpoint_path
133
- )
231
+ def emit_positional_description_warning(method, description_options)
232
+ return unless description_options[:using_positional_description]
233
+
234
+ send(:warn_on_deprecated_positional_description, method)
134
235
  end
135
236
 
136
- def current_resource_path
137
- stack = @rest_resource_stack || []
138
- return nil if stack.empty?
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
139
244
 
140
- first_had_leading_slash = stack.first.to_s.start_with?("/")
141
- normalized_segments = stack.map do |segment|
142
- segment.to_s.sub(%r{\A/+}, "").sub(%r{/+\z}, "")
143
- end.reject(&:empty?)
245
+ def resolve_verb_description_options(method, positional_description, description)
246
+ resolve_description_options(
247
+ method: method,
248
+ positional_description: positional_description,
249
+ keyword_description: description
250
+ )
251
+ end
144
252
 
145
- path = normalized_segments.join("/")
146
- first_had_leading_slash && !path.empty? ? "/#{path}" : path
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
+ ]
147
258
  end
148
259
  end
149
260
 
@@ -10,6 +10,18 @@ module RSpec
10
10
  class RequestRecorder
11
11
  include Helpers
12
12
 
13
+ AUTH_TOKEN_ENV_VAR = "API_AUTH_TOKEN"
14
+ AUTH_HEADER_KEYS = %w[
15
+ authorization
16
+ proxy-authorization
17
+ x-api-key
18
+ x-auth-token
19
+ ].freeze
20
+ AUTH_SCHEME_HEADER_KEYS = %w[
21
+ authorization
22
+ proxy-authorization
23
+ ].freeze
24
+
13
25
  def initialize(last_request:, redacted_headers: nil)
14
26
  @last_request = last_request || {}
15
27
  @redacted_headers = normalize_redacted_headers(redacted_headers || Config::DEFAULT_REDACT_HEADERS)
@@ -40,7 +52,7 @@ module RSpec
40
52
  return nil if headers.nil? || headers.empty?
41
53
 
42
54
  headers.sort_by { |key, _| key.to_s.downcase }
43
- .map { |key, value| %(-H #{shell_escape("#{key}: #{redacted_value(key, value)}")}) }
55
+ .map { |key, value| format_header_option(key, redacted_value(key, value)) }
44
56
  .join(" ")
45
57
  end
46
58
 
@@ -63,13 +75,40 @@ module RSpec
63
75
 
64
76
  def redacted_value(key, value)
65
77
  return value unless @redacted_headers.include?(key.to_s.downcase)
78
+ return auth_header_placeholder(key, value) if auth_header?(key)
66
79
 
67
80
  "[REDACTED]"
68
81
  end
69
82
 
83
+ def auth_header?(key)
84
+ AUTH_HEADER_KEYS.include?(key.to_s.downcase)
85
+ end
86
+
87
+ def auth_header_placeholder(key, value)
88
+ value_string = value.to_s
89
+ if AUTH_SCHEME_HEADER_KEYS.include?(key.to_s.downcase)
90
+ scheme_match = value_string.match(/\A([A-Za-z][A-Za-z0-9._-]*)\s+/)
91
+ return "#{scheme_match[1]} $#{AUTH_TOKEN_ENV_VAR}" if scheme_match
92
+ end
93
+
94
+ "$#{AUTH_TOKEN_ENV_VAR}"
95
+ end
96
+
97
+ def format_header_option(key, value)
98
+ header = "#{key}: #{value}"
99
+ return %(-H #{shell_escape_with_env_expansion(header)}) if value.to_s.include?("$#{AUTH_TOKEN_ENV_VAR}")
100
+
101
+ %(-H #{shell_escape(header)})
102
+ end
103
+
70
104
  def shell_escape(value)
71
105
  "'#{value.to_s.gsub("'", %q('"'"'))}'"
72
106
  end
107
+
108
+ def shell_escape_with_env_expansion(value)
109
+ escaped = value.to_s.gsub("\\", "\\\\").gsub('"', '\"').gsub("`", "\\`")
110
+ "\"#{escaped}\""
111
+ end
73
112
  end
74
113
  end
75
114
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module RSpec
4
4
  module Rest
5
- VERSION = "0.2.0"
5
+ VERSION = "0.4.0"
6
6
  end
7
7
  end
data/lib/rspec/rest.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "rest/version"
4
4
  require_relative "rest/errors"
5
+ require_relative "rest/deprecation"
5
6
  require_relative "rest/config"
6
7
  require_relative "rest/response"
7
8
  require_relative "rest/session"
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.2.0
4
+ version: 0.4.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-08 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
@@ -111,6 +111,7 @@ files:
111
111
  - lib/rspec/rest/config.rb
112
112
  - lib/rspec/rest/contract_expectations.rb
113
113
  - lib/rspec/rest/contract_matcher.rb
114
+ - lib/rspec/rest/deprecation.rb
114
115
  - lib/rspec/rest/dsl.rb
115
116
  - lib/rspec/rest/error_expectations.rb
116
117
  - lib/rspec/rest/errors.rb