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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 93914e31c974ea40630915bca0148c886b24608cca438d627f58b7ae83a3c4b5
4
- data.tar.gz: d31e2d01339e37c1d432dc8b36333ba8e707d2e2ebff5637c7ebbce94093ba9b
3
+ metadata.gz: 47e2dfd9b5b01a159c9d81dffc650b0a807d6491669100f3e4c0939b9012eb3e
4
+ data.tar.gz: 0b5f6d62d08a5139fa60ce2d2a193d92949193767a08378f5212e576bc762f50
5
5
  SHA512:
6
- metadata.gz: 908e78cda54bbd8dc33be4001429e6d9ca36e0111fcd8da1aa54bded587a27225828663bcbf19c2a130c4c1d634af3f68d8d7eab270dbb2d5769459861b7d5cc
7
- data.tar.gz: 8aed0d1c0d77ce8381169b1ed4babb90ed9f02b9b9749fe12f0f7f7724d6bc73cd9fb4fbb2ad46d75b2eeb2254c29fd7b1ea72fc3642b8ac92d2f9bd0269bdc3
6
+ metadata.gz: ca75926bef46af5c64380a992281242afada213a4e4975ef41f0e1e8c525395c41a88618fd04accec24613cb13b107a8a0ae04b25a4e32cb4ded5464e6093760
7
+ data.tar.gz: f9762615e58277d690c7cdc7b7426dac3818e3f1fe7f12eb6e3e753d9cf0e19d366a7eae198911b5f33d7911ef72ded6826dabe63dd5b66bde19f98c7954fd87
data/.gitignore CHANGED
@@ -67,3 +67,6 @@ yarn-debug.log*
67
67
  /storage/*
68
68
  !/storage/.keep
69
69
  /public/uploads
70
+
71
+ # Local planning notes
72
+ /docs/plans/
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
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- rspec-rest (0.1.0)
4
+ rspec-rest (0.3.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:
@@ -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,25 +109,54 @@ 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 "/", 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(expect_json_contract(:post_summary))
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
- - `expect_json_contract(name)`
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 `expect_json_contract`.
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(expect_json_contract(:post_summary))
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
- "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
@@ -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
- it("#{method.to_s.upcase} #{path}") do
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 current_resource_path
113
- stack = @rest_resource_stack || []
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
- first_had_leading_slash = stack.first.to_s.start_with?("/")
117
- normalized_segments = stack.map do |segment|
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
- path = normalized_segments.join("/")
122
- first_had_leading_slash && !path.empty? ? "/#{path}" : path
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
- if block
53
- instance_exec(selected, &block)
54
- next selected
55
- end
43
+ private
56
44
 
57
- next selected if expected.nil?
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
- if expected.respond_to?(:matches?)
60
- expect(selected).to expected
61
- else
62
- expect(selected).to eq(expected)
63
- end
51
+ return value if expected.nil?
64
52
 
65
- selected
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
- private
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| %(-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
@@ -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
@@ -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
- segments = [base_path, resource_path, endpoint_path].compact.map(&:to_s)
127
- normalized = segments.map { |segment| segment.gsub(%r{\A/+|/+\z}, "") }.reject(&:empty?)
128
- "/#{normalized.join('/')}"
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)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module RSpec
4
4
  module Rest
5
- VERSION = "0.1.0"
5
+ VERSION = "0.3.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.1.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-07 00:00:00.000000000 Z
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