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 +4 -4
- data/.gitignore +3 -0
- data/CHANGELOG.md +42 -1
- data/Gemfile.lock +1 -1
- data/README.md +142 -40
- data/lib/rspec/rest/contract_expectations.rb +26 -5
- data/lib/rspec/rest/deprecation.rb +52 -0
- data/lib/rspec/rest/dsl.rb +136 -25
- data/lib/rspec/rest/formatters/request_recorder.rb +40 -1
- data/lib/rspec/rest/version.rb +1 -1
- data/lib/rspec/rest.rb +1 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e72f8991e5d53bdb5bd0ccfa7524a42fcf5c99afead0caba45bb8876ef66bf8c
|
|
4
|
+
data.tar.gz: c33cdc064fc0d12c76f42f036b1490732ecbd6aaa1c6c8213bfaef6260e99d03
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 240876e213f49ac41ddc68015bd1d91744df3af41454e53f4cd561a10042f839544fae0ac7537dd3eee68f4c76727e9f1ff07136b8a1e060fcbc068e401aca69
|
|
7
|
+
data.tar.gz: d9c09b903fe84ef94c2290633b1770fd0d05263a909ffa4a9d2aeec6c8aa26e8a7e26dbff6622073d6bf99b8d24b988898a5040a12b7dc4b19a09981c78b02fd
|
data/.gitignore
CHANGED
data/CHANGELOG.md
CHANGED
|
@@ -7,7 +7,48 @@ Semantic Versioning.
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
-
|
|
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
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
|
|
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
|
-
|
|
117
|
-
|
|
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(
|
|
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
|
-
-
|
|
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
|
-
- `
|
|
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 `
|
|
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(
|
|
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
|
-
"
|
|
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
|
data/lib/rspec/rest/dsl.rb
CHANGED
|
@@ -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 |
|
|
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
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
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
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
146
|
-
|
|
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|
|
|
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
|
data/lib/rspec/rest/version.rb
CHANGED
data/lib/rspec/rest.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.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-
|
|
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
|