rspec-rest 0.1.0 → 0.3.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 +52 -0
- data/Gemfile.lock +1 -1
- data/README.md +156 -24
- data/lib/rspec/rest/contract_expectations.rb +26 -5
- data/lib/rspec/rest/deprecation.rb +52 -0
- data/lib/rspec/rest/dsl.rb +84 -11
- data/lib/rspec/rest/expectations.rb +19 -28
- data/lib/rspec/rest/formatters/request_recorder.rb +40 -1
- data/lib/rspec/rest/json_item_expectations.rb +52 -0
- data/lib/rspec/rest/path_composer.rb +15 -0
- data/lib/rspec/rest/session.rb +6 -3
- data/lib/rspec/rest/version.rb +1 -1
- data/lib/rspec/rest.rb +1 -0
- 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: 47e2dfd9b5b01a159c9d81dffc650b0a807d6491669100f3e4c0939b9012eb3e
|
|
4
|
+
data.tar.gz: 0b5f6d62d08a5139fa60ce2d2a193d92949193767a08378f5212e576bc762f50
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ca75926bef46af5c64380a992281242afada213a4e4975ef41f0e1e8c525395c41a88618fd04accec24613cb13b107a8a0ae04b25a4e32cb4ded5464e6093760
|
|
7
|
+
data.tar.gz: f9762615e58277d690c7cdc7b7426dac3818e3f1fe7f12eb6e3e753d9cf0e19d366a7eae198911b5f33d7911ef72ded6826dabe63dd5b66bde19f98c7954fd87
|
data/.gitignore
CHANGED
data/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,58 @@ Semantic Versioning.
|
|
|
9
9
|
|
|
10
10
|
No changes yet.
|
|
11
11
|
|
|
12
|
+
## [0.3.0] - 2026-03-10
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
- Contract lookup helper for matcher composition in examples:
|
|
16
|
+
- `contract(:name)`
|
|
17
|
+
- Internal deprecation utility for gem APIs:
|
|
18
|
+
- `RSpec::Rest::Deprecation.warn(key:, message:)`
|
|
19
|
+
- once-per-key warning emission in RSpec output.
|
|
20
|
+
- Keyword request description support on verb DSL calls:
|
|
21
|
+
- `get(path, description: "...") { ... }` (and same for other verbs).
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
- Failure-time `curl` output now uses an auth token environment placeholder for redacted auth-like headers, improving copy/paste usability:
|
|
25
|
+
- `Authorization: Bearer $API_AUTH_TOKEN`
|
|
26
|
+
- Redacted auth scheme prefixes are preserved in `curl` output for `Authorization` and `Proxy-Authorization` (for example `Basic`, `Digest`).
|
|
27
|
+
- README examples now prefer:
|
|
28
|
+
- `contract(:name)` over nested `expect_json_contract(...)`
|
|
29
|
+
- keyword request descriptions (`description:`).
|
|
30
|
+
- Added README RuboCop compatibility guidance for:
|
|
31
|
+
- `Rails/HttpPositionalArguments`
|
|
32
|
+
- `RSpec/EmptyExampleGroup`.
|
|
33
|
+
|
|
34
|
+
### Deprecated
|
|
35
|
+
- `expect_json_contract(name)` is deprecated and scheduled for removal in `1.0`.
|
|
36
|
+
Use `contract(:name)` for contract lookup in examples.
|
|
37
|
+
- Positional verb descriptions are deprecated and scheduled for removal in `1.0`:
|
|
38
|
+
- `get(path, "description") { ... }`
|
|
39
|
+
Use keyword descriptions instead:
|
|
40
|
+
- `get(path, description: "...") { ... }`
|
|
41
|
+
|
|
42
|
+
## [0.2.0] - 2026-03-08
|
|
43
|
+
|
|
44
|
+
### Added
|
|
45
|
+
- JSON array item expectation helpers:
|
|
46
|
+
- `expect_json_first(expected = nil, &block)`
|
|
47
|
+
- `expect_json_item(index, expected = nil, &block)`
|
|
48
|
+
- `expect_json_last(expected = nil, &block)`
|
|
49
|
+
- Optional behavior descriptions on verb DSL calls:
|
|
50
|
+
- `get(path, description = nil) { ... }` (and same for other verbs)
|
|
51
|
+
- Full-route example naming support in DSL output (composed from `base_path` + resource path + endpoint path).
|
|
52
|
+
- Shared path composition utility used by both request execution and example naming.
|
|
53
|
+
|
|
54
|
+
### Changed
|
|
55
|
+
- README examples and expectation helper docs updated for:
|
|
56
|
+
- Ruby-style JSON item helpers
|
|
57
|
+
- Optional verb descriptions and full-route example names
|
|
58
|
+
|
|
59
|
+
### Fixed
|
|
60
|
+
- `expect_json_item` now validates index type and reports non-integer indexes via actionable expectation failures.
|
|
61
|
+
- JSON value assertion semantics are centralized across `expect_json`, `expect_json_at`, and JSON item helpers to reduce drift.
|
|
62
|
+
- Unknown/invalid JSON item and contract expectation failures continue to include enriched request/response/curl diagnostics.
|
|
63
|
+
|
|
12
64
|
## [0.1.0] - 2026-03-07
|
|
13
65
|
|
|
14
66
|
### Added
|
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:
|
|
@@ -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,25 +109,54 @@ 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 "/", 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
154
|
|
|
126
155
|
get "/" do
|
|
127
|
-
query page: 1
|
|
128
|
-
|
|
156
|
+
query page: 1, per_page: 10
|
|
129
157
|
expect_status 200
|
|
130
|
-
expect_json array_of(
|
|
131
|
-
expect_json_at "$[0].id", posts.first.id
|
|
132
|
-
expect_json_at "$[0].author.id", 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
162
|
get "/{id}" do
|
|
@@ -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,6 +206,9 @@ Supported config:
|
|
|
188
206
|
|
|
189
207
|
- `resource "/users" do ... end`
|
|
190
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`.
|
|
211
|
+
It is deprecated to avoid `Rails/HttpPositionalArguments` false-positives in RuboCop.
|
|
191
212
|
|
|
192
213
|
Resource paths are composable and support placeholders:
|
|
193
214
|
|
|
@@ -202,6 +223,59 @@ resource "/users" do
|
|
|
202
223
|
end
|
|
203
224
|
```
|
|
204
225
|
|
|
226
|
+
Example with an explicit behavior name:
|
|
227
|
+
|
|
228
|
+
```ruby
|
|
229
|
+
resource "/users" do
|
|
230
|
+
get "/", description: "returns public users for authenticated client" do
|
|
231
|
+
expect_status 200
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
RSpec example output uses the composed full route (including `base_path`) and appends the optional description, for example:
|
|
237
|
+
- `GET /v1/users - returns public users for authenticated client`
|
|
238
|
+
|
|
239
|
+
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.
|
|
240
|
+
|
|
241
|
+
## RuboCop Compatibility
|
|
242
|
+
|
|
243
|
+
`rspec-rest` verbs (`get`, `post`, etc.) define examples via DSL macros, and can trigger
|
|
244
|
+
false-positives in some RuboCop cops:
|
|
245
|
+
|
|
246
|
+
- `Rails/HttpPositionalArguments`: use keyword descriptions (`description:`), not positional descriptions.
|
|
247
|
+
- `RSpec/EmptyExampleGroup`: a `context` that only contains `resource` + verb DSL may be flagged as empty.
|
|
248
|
+
|
|
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 "/", 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
|
+
|
|
205
279
|
## Shared Request Presets
|
|
206
280
|
|
|
207
281
|
Define shared request defaults at group/resource scope:
|
|
@@ -286,8 +360,12 @@ Available expectation helpers:
|
|
|
286
360
|
- `expect_status(code)`
|
|
287
361
|
- `expect_header(key, value_or_regex)`
|
|
288
362
|
- `expect_json(expected = nil, &block)`
|
|
289
|
-
- `
|
|
363
|
+
- `contract(name)` (lookup helper for reusable JSON contracts)
|
|
364
|
+
- `expect_json_contract(name)` (deprecated; use `contract(name)`)
|
|
290
365
|
- `expect_json_at(selector, expected = nil, &block)`
|
|
366
|
+
- `expect_json_first(expected = nil, &block)`
|
|
367
|
+
- `expect_json_item(index, expected = nil, &block)`
|
|
368
|
+
- `expect_json_last(expected = nil, &block)`
|
|
291
369
|
- `expect_error(status:, message: nil, includes: nil, field: nil, key: "error")`
|
|
292
370
|
- `expect_page_size(size, selector: "$")`
|
|
293
371
|
- `expect_max_page_size(max, selector: "$")`
|
|
@@ -311,6 +389,18 @@ Available expectation helpers:
|
|
|
311
389
|
- block mode:
|
|
312
390
|
- `expect_json_at "$.items[0]" { |item| expect(item["id"]).to integer }`
|
|
313
391
|
|
|
392
|
+
For common array-item checks, use Ruby-style helpers instead of selector strings:
|
|
393
|
+
|
|
394
|
+
- `expect_json_first(...)`
|
|
395
|
+
- `expect_json_item(index, ...)`
|
|
396
|
+
- `expect_json_last(...)`
|
|
397
|
+
|
|
398
|
+
```ruby
|
|
399
|
+
expect_json_first hash_including("id" => integer)
|
|
400
|
+
expect_json_item 2, hash_including("name" => "Third")
|
|
401
|
+
expect_json_last { |item| expect(item["id"]).to integer }
|
|
402
|
+
```
|
|
403
|
+
|
|
314
404
|
`expect_error` is a convenience helper for common API error payload assertions:
|
|
315
405
|
|
|
316
406
|
```ruby
|
|
@@ -335,7 +425,7 @@ end
|
|
|
335
425
|
## Lightweight Contracts
|
|
336
426
|
|
|
337
427
|
A contract is a named, reusable JSON expectation (usually a response shape matcher).
|
|
338
|
-
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)`.
|
|
339
429
|
|
|
340
430
|
```ruby
|
|
341
431
|
contract :post_summary do
|
|
@@ -348,7 +438,7 @@ end
|
|
|
348
438
|
|
|
349
439
|
get "/" do
|
|
350
440
|
expect_status 200
|
|
351
|
-
expect_json array_of(
|
|
441
|
+
expect_json array_of(contract(:post_summary))
|
|
352
442
|
end
|
|
353
443
|
```
|
|
354
444
|
|
|
@@ -394,6 +484,48 @@ When an expectation fails, output includes:
|
|
|
394
484
|
Sensitive headers are redacted by default and can be customized via
|
|
395
485
|
`redact_headers`.
|
|
396
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
|
+
|
|
397
529
|
## Contributing
|
|
398
530
|
|
|
399
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
|
@@ -7,6 +7,7 @@ require_relative "class_level_presets"
|
|
|
7
7
|
require_relative "errors"
|
|
8
8
|
require_relative "expectations"
|
|
9
9
|
require_relative "json_selector"
|
|
10
|
+
require_relative "path_composer"
|
|
10
11
|
require_relative "request_builders"
|
|
11
12
|
require_relative "session"
|
|
12
13
|
module RSpec
|
|
@@ -55,9 +56,72 @@ module RSpec
|
|
|
55
56
|
end
|
|
56
57
|
end
|
|
57
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
|
+
|
|
58
120
|
module ClassMethods
|
|
59
121
|
include ClassLevelContracts
|
|
60
122
|
include ClassLevelPresets
|
|
123
|
+
include RouteNamingSupport
|
|
124
|
+
include DescriptionArgumentSupport
|
|
61
125
|
|
|
62
126
|
def api(&)
|
|
63
127
|
builder = ApiConfigBuilder.new(rest_config)
|
|
@@ -77,10 +141,17 @@ module RSpec
|
|
|
77
141
|
end
|
|
78
142
|
|
|
79
143
|
HTTP_METHODS.each do |method|
|
|
80
|
-
define_method(method) do |path, &block|
|
|
144
|
+
define_method(method) do |path, positional_description = nil, description: nil, &block|
|
|
145
|
+
description_options = resolve_verb_description_options(method, positional_description, description)
|
|
81
146
|
resource_path = current_resource_path
|
|
82
147
|
request_presets = deep_dup_presets(current_request_presets)
|
|
83
|
-
|
|
148
|
+
example_name = build_example_name(
|
|
149
|
+
method: method,
|
|
150
|
+
path: path,
|
|
151
|
+
resource_path: resource_path,
|
|
152
|
+
description: description_options[:description]
|
|
153
|
+
)
|
|
154
|
+
it(example_name) do
|
|
84
155
|
start_rest_request(
|
|
85
156
|
method: method,
|
|
86
157
|
path: path,
|
|
@@ -88,6 +159,7 @@ module RSpec
|
|
|
88
159
|
presets: request_presets
|
|
89
160
|
)
|
|
90
161
|
instance_eval(&block) if block
|
|
162
|
+
self.class.send(:emit_positional_description_warning, method, description_options)
|
|
91
163
|
execute_rest_request_if_pending
|
|
92
164
|
end
|
|
93
165
|
end
|
|
@@ -109,17 +181,18 @@ module RSpec
|
|
|
109
181
|
|
|
110
182
|
private
|
|
111
183
|
|
|
112
|
-
def
|
|
113
|
-
|
|
114
|
-
return nil if stack.empty?
|
|
184
|
+
def emit_positional_description_warning(method, description_options)
|
|
185
|
+
return unless description_options[:using_positional_description]
|
|
115
186
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
segment.to_s.sub(%r{\A/+}, "").sub(%r{/+\z}, "")
|
|
119
|
-
end.reject(&:empty?)
|
|
187
|
+
send(:warn_on_deprecated_positional_description, method)
|
|
188
|
+
end
|
|
120
189
|
|
|
121
|
-
|
|
122
|
-
|
|
190
|
+
def resolve_verb_description_options(method, positional_description, description)
|
|
191
|
+
resolve_description_options(
|
|
192
|
+
method: method,
|
|
193
|
+
positional_description: positional_description,
|
|
194
|
+
keyword_description: description
|
|
195
|
+
)
|
|
123
196
|
end
|
|
124
197
|
end
|
|
125
198
|
|
|
@@ -5,6 +5,7 @@ require_relative "formatters/request_recorder"
|
|
|
5
5
|
require_relative "error_expectations"
|
|
6
6
|
require_relative "contract_expectations"
|
|
7
7
|
require_relative "header_expectations"
|
|
8
|
+
require_relative "json_item_expectations"
|
|
8
9
|
require_relative "json_selector"
|
|
9
10
|
require_relative "json_type_helpers"
|
|
10
11
|
require_relative "pagination_expectations"
|
|
@@ -15,6 +16,7 @@ module RSpec
|
|
|
15
16
|
include ContractExpectations
|
|
16
17
|
include ErrorExpectations
|
|
17
18
|
include HeaderExpectations
|
|
19
|
+
include JsonItemExpectations
|
|
18
20
|
include JsonTypeHelpers
|
|
19
21
|
include PaginationExpectations
|
|
20
22
|
|
|
@@ -27,46 +29,35 @@ module RSpec
|
|
|
27
29
|
def expect_json(expected = nil, &block)
|
|
28
30
|
with_request_dump_on_failure do
|
|
29
31
|
parsed = rest_response.json
|
|
30
|
-
|
|
31
|
-
if block
|
|
32
|
-
instance_exec(parsed, &block)
|
|
33
|
-
next parsed
|
|
34
|
-
end
|
|
35
|
-
|
|
36
|
-
next parsed if expected.nil?
|
|
37
|
-
|
|
38
|
-
if expected.respond_to?(:matches?)
|
|
39
|
-
expect(parsed).to expected
|
|
40
|
-
else
|
|
41
|
-
expect(parsed).to eq(expected)
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
parsed
|
|
32
|
+
evaluate_json_value(parsed, expected, &block)
|
|
45
33
|
end
|
|
46
34
|
end
|
|
47
35
|
|
|
48
36
|
def expect_json_at(selector, expected = nil, &block)
|
|
49
37
|
with_request_dump_on_failure do
|
|
50
38
|
selected = JsonSelector.extract(rest_response.json, selector)
|
|
39
|
+
evaluate_json_value(selected, expected, &block)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
51
42
|
|
|
52
|
-
|
|
53
|
-
instance_exec(selected, &block)
|
|
54
|
-
next selected
|
|
55
|
-
end
|
|
43
|
+
private
|
|
56
44
|
|
|
57
|
-
|
|
45
|
+
def evaluate_json_value(value, expected = nil, &block)
|
|
46
|
+
if block
|
|
47
|
+
instance_exec(value, &block)
|
|
48
|
+
return value
|
|
49
|
+
end
|
|
58
50
|
|
|
59
|
-
|
|
60
|
-
expect(selected).to expected
|
|
61
|
-
else
|
|
62
|
-
expect(selected).to eq(expected)
|
|
63
|
-
end
|
|
51
|
+
return value if expected.nil?
|
|
64
52
|
|
|
65
|
-
|
|
53
|
+
if expected.respond_to?(:matches?)
|
|
54
|
+
expect(value).to expected
|
|
55
|
+
else
|
|
56
|
+
expect(value).to eq(expected)
|
|
66
57
|
end
|
|
67
|
-
end
|
|
68
58
|
|
|
69
|
-
|
|
59
|
+
value
|
|
60
|
+
end
|
|
70
61
|
|
|
71
62
|
def with_request_dump_on_failure
|
|
72
63
|
yield
|
|
@@ -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
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpec
|
|
4
|
+
module Rest
|
|
5
|
+
module JsonItemExpectations
|
|
6
|
+
def expect_json_item(index, expected = nil, &block)
|
|
7
|
+
with_request_dump_on_failure do
|
|
8
|
+
payload = json_array_payload
|
|
9
|
+
normalized_index = normalize_json_item_index(index)
|
|
10
|
+
item = payload.fetch(normalized_index)
|
|
11
|
+
evaluate_json_value(item, expected, &block)
|
|
12
|
+
rescue IndexError
|
|
13
|
+
raise ::RSpec::Expectations::ExpectationNotMetError,
|
|
14
|
+
"Index #{index.inspect} is out of bounds for JSON array of size #{payload.size}."
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def expect_json_first(expected = nil, &)
|
|
19
|
+
expect_json_item(0, expected, &)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def expect_json_last(expected = nil, &block)
|
|
23
|
+
with_request_dump_on_failure do
|
|
24
|
+
payload = json_array_payload
|
|
25
|
+
if payload.empty?
|
|
26
|
+
raise ::RSpec::Expectations::ExpectationNotMetError,
|
|
27
|
+
"Cannot select last item from an empty JSON array."
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
evaluate_json_value(payload.last, expected, &block)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def json_array_payload
|
|
37
|
+
payload = rest_response.json
|
|
38
|
+
return payload if payload.is_a?(Array)
|
|
39
|
+
|
|
40
|
+
raise ::RSpec::Expectations::ExpectationNotMetError,
|
|
41
|
+
"Expected JSON payload to be an Array, got #{payload.class}."
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def normalize_json_item_index(index)
|
|
45
|
+
return index if index.is_a?(Integer)
|
|
46
|
+
|
|
47
|
+
raise ::RSpec::Expectations::ExpectationNotMetError,
|
|
48
|
+
"Expected JSON item index to be an Integer, got #{index.inspect} (#{index.class})."
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpec
|
|
4
|
+
module Rest
|
|
5
|
+
module PathComposer
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def compose(base_path:, resource_path:, endpoint_path:)
|
|
9
|
+
segments = [base_path, resource_path, endpoint_path].compact.map(&:to_s)
|
|
10
|
+
normalized = segments.map { |segment| segment.gsub(%r{\A/+|/+\z}, "") }.reject(&:empty?)
|
|
11
|
+
"/#{normalized.join('/')}"
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
data/lib/rspec/rest/session.rb
CHANGED
|
@@ -4,6 +4,7 @@ require "json"
|
|
|
4
4
|
require "rack/test"
|
|
5
5
|
require "rack/utils"
|
|
6
6
|
require_relative "errors"
|
|
7
|
+
require_relative "path_composer"
|
|
7
8
|
require_relative "response"
|
|
8
9
|
|
|
9
10
|
module RSpec
|
|
@@ -123,9 +124,11 @@ module RSpec
|
|
|
123
124
|
end
|
|
124
125
|
|
|
125
126
|
def build_path(base_path, resource_path, endpoint_path)
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
127
|
+
PathComposer.compose(
|
|
128
|
+
base_path: base_path,
|
|
129
|
+
resource_path: resource_path,
|
|
130
|
+
endpoint_path: endpoint_path
|
|
131
|
+
)
|
|
129
132
|
end
|
|
130
133
|
|
|
131
134
|
def build_url(base_url, path)
|
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.3.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-10 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
|
|
@@ -119,9 +120,11 @@ files:
|
|
|
119
120
|
- lib/rspec/rest/formatters/request_dump.rb
|
|
120
121
|
- lib/rspec/rest/formatters/request_recorder.rb
|
|
121
122
|
- lib/rspec/rest/header_expectations.rb
|
|
123
|
+
- lib/rspec/rest/json_item_expectations.rb
|
|
122
124
|
- lib/rspec/rest/json_selector.rb
|
|
123
125
|
- lib/rspec/rest/json_type_helpers.rb
|
|
124
126
|
- lib/rspec/rest/pagination_expectations.rb
|
|
127
|
+
- lib/rspec/rest/path_composer.rb
|
|
125
128
|
- lib/rspec/rest/request_builders.rb
|
|
126
129
|
- lib/rspec/rest/response.rb
|
|
127
130
|
- lib/rspec/rest/session.rb
|