rspec-rest 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e72f8991e5d53bdb5bd0ccfa7524a42fcf5c99afead0caba45bb8876ef66bf8c
4
- data.tar.gz: c33cdc064fc0d12c76f42f036b1490732ecbd6aaa1c6c8213bfaef6260e99d03
3
+ metadata.gz: 3fef8eafbf21f5d0f307fa44423fa4549ad602de47671f6bec67257121e66263
4
+ data.tar.gz: fbea7383a57565b80277d093d66c4e1bd80a24b3343bf1118f86699ed04e88f8
5
5
  SHA512:
6
- metadata.gz: 240876e213f49ac41ddc68015bd1d91744df3af41454e53f4cd561a10042f839544fae0ac7537dd3eee68f4c76727e9f1ff07136b8a1e060fcbc068e401aca69
7
- data.tar.gz: d9c09b903fe84ef94c2290633b1770fd0d05263a909ffa4a9d2aeec6c8aa26e8a7e26dbff6622073d6bf99b8d24b988898a5040a12b7dc4b19a09981c78b02fd
6
+ metadata.gz: 00a1efa6d45045ac68fb5df02fadc8acae07465c9e1a82e7561da4ecdb135a8557123eb9c31a70fa1b61bff95c676e6095a21d8fba77c48881f9a1908831437b
7
+ data.tar.gz: 12e146f01dd7b46a1ffa2a014c7392f66af342d594b07c5171f56bb63e36232fa45ec35dc353bbb2e44c9e589c10628d85d7903bd43b7993a32e618722da2805
data/CHANGELOG.md CHANGED
@@ -7,6 +7,19 @@ Semantic Versioning.
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.5.0] - 2026-03-12
11
+
12
+ ### Added
13
+ - `expect_json_at` now supports top-level shorthand selectors:
14
+ - Symbol keys (for example `:message`)
15
+ - plain String keys (for example `"message"`)
16
+ Full JSONPath selectors (for example `"$.items[0].id"`) remain supported.
17
+ - `contract_with(:name, overrides)` for contract matcher composition with specific
18
+ value assertions while preserving the base contract shape/type expectations.
19
+ - Raw body expectation helpers for non-JSON/text responses:
20
+ - `expect_body_includes(fragment)`
21
+ - `expect_body_matches(pattern)`
22
+
10
23
  ## [0.4.0] - 2026-03-11
11
24
 
12
25
  ### Added
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- rspec-rest (0.4.0)
4
+ rspec-rest (0.5.0)
5
5
  rack-test (~> 2.1)
6
6
 
7
7
  GEM
data/README.md CHANGED
@@ -361,8 +361,11 @@ Available expectation helpers:
361
361
  - `expect_header(key, value_or_regex)`
362
362
  - `expect_json(expected = nil, &block)`
363
363
  - `contract(name)` (lookup helper for reusable JSON contracts)
364
+ - `contract_with(name, overrides)` (contract lookup with value overrides)
364
365
  - `expect_json_contract(name)` (deprecated; use `contract(name)`)
365
366
  - `expect_json_at(selector, expected = nil, &block)`
367
+ - `expect_body_includes(fragment)`
368
+ - `expect_body_matches(pattern)` (`String` or `Regexp`)
366
369
  - `expect_json_first(expected = nil, &block)`
367
370
  - `expect_json_item(index, expected = nil, &block)`
368
371
  - `expect_json_last(expected = nil, &block)`
@@ -388,6 +391,9 @@ Available expectation helpers:
388
391
  - `expect_json_at "$.user.email", "jane@example.com"`
389
392
  - block mode:
390
393
  - `expect_json_at "$.items[0]" { |item| expect(item["id"]).to integer }`
394
+ - top-level shorthand:
395
+ - `expect_json_at :message, "Not found"`
396
+ - `expect_json_at "message", "Not found"`
391
397
 
392
398
  For common array-item checks, use Ruby-style helpers instead of selector strings:
393
399
 
@@ -401,6 +407,16 @@ expect_json_item 2, hash_including("name" => "Third")
401
407
  expect_json_last { |item| expect(item["id"]).to integer }
402
408
  ```
403
409
 
410
+ Raw body assertions for text/non-JSON endpoints:
411
+
412
+ ```ruby
413
+ get path: "/bad_json" do
414
+ expect_status 200
415
+ expect_body_includes "not json"
416
+ expect_body_matches(/this is not json/)
417
+ end
418
+ ```
419
+
404
420
  `expect_error` is a convenience helper for common API error payload assertions:
405
421
 
406
422
  ```ruby
@@ -439,9 +455,13 @@ end
439
455
  get path: "/" do
440
456
  expect_status 200
441
457
  expect_json array_of(contract(:post_summary))
458
+ expect_json array_of(contract_with(:post_summary, id: 1, title: "My Title", author: { id: 1 }))
442
459
  end
443
460
  ```
444
461
 
462
+ Use `contract_with` when you want the base contract shape/types plus specific values
463
+ for selected keys. Override keys must exist in the contract definition.
464
+
445
465
  JSON type helpers:
446
466
 
447
467
  - `integer`
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpec
4
+ module Rest
5
+ module BodyExpectations
6
+ def expect_body_includes(fragment)
7
+ with_request_dump_on_failure do
8
+ case fragment
9
+ when String
10
+ expect(rest_response.body).to include(fragment)
11
+ else
12
+ raise ArgumentError, "expect_body_includes requires a String fragment, got #{fragment.class}"
13
+ end
14
+ end
15
+ end
16
+
17
+ def expect_body_matches(pattern)
18
+ with_request_dump_on_failure do
19
+ case pattern
20
+ when String, Regexp
21
+ expect(rest_response.body).to match(pattern)
22
+ else
23
+ raise ArgumentError, "expect_body_matches requires a String or Regexp pattern, got #{pattern.class}"
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "contract_matcher"
4
+ require_relative "contract_overrides_builder"
5
+ require_relative "contract_with_matcher"
4
6
 
5
7
  module RSpec
6
8
  module Rest
@@ -25,6 +27,45 @@ module RSpec
25
27
  contract_matcher_for(name, lookup_name: :expect_json_contract)
26
28
  end
27
29
 
30
+ def contract_with(name, overrides = nil, **keyword_overrides)
31
+ overrides_builder = ContractOverridesBuilder.new(context: self)
32
+ resolved_overrides = overrides_builder.normalize!(overrides, keyword_overrides)
33
+ contract_name = normalize_contract_name(name)
34
+ return unknown_contract_matcher(name_error_message(name, lookup_name: :contract_with)) if contract_name.nil?
35
+
36
+ definition = self.class.rest_contract_definition(contract_name)
37
+ if definition.nil?
38
+ available = self.class.send(:rest_contracts).keys.map(&:inspect).sort
39
+ message = "Unknown contract #{contract_name.inspect}. Available contracts: [#{available.join(', ')}]"
40
+ return unknown_contract_matcher(message)
41
+ end
42
+
43
+ definition_for_matcher = definition
44
+
45
+ if resolved_overrides.any?
46
+ base_contract_value = instance_exec(&definition)
47
+ key_tree = overrides_builder.key_tree_for(base_contract_value)
48
+ if key_tree.nil?
49
+ return unknown_contract_matcher(
50
+ "Contract #{contract_name.inspect} does not declare hash keys, so overrides cannot be applied."
51
+ )
52
+ end
53
+
54
+ validation_error = overrides_builder.validate_keys(key_tree, resolved_overrides)
55
+ return unknown_contract_matcher(validation_error) unless validation_error.nil?
56
+
57
+ # Reuse the already-evaluated contract value inside the matcher so the
58
+ # contract definition is executed at most once per `contract_with` call.
59
+ definition_for_matcher = proc { base_contract_value }
60
+ end
61
+
62
+ ContractWithMatcher.new(
63
+ name: contract_name,
64
+ contract_matcher: ContractMatcher.new(name: contract_name, definition: definition_for_matcher, context: self),
65
+ overrides_matcher: overrides_builder.build_matcher(resolved_overrides)
66
+ )
67
+ end
68
+
28
69
  private
29
70
 
30
71
  def contract_matcher_for(name, lookup_name:)
@@ -50,7 +91,14 @@ module RSpec
50
91
  end
51
92
 
52
93
  def name_error_message(name, lookup_name:)
53
- lookup_hint = lookup_name == :expect_json_contract ? "expect_json_contract" : "contract(:name)"
94
+ lookup_hint = case lookup_name
95
+ when :expect_json_contract
96
+ "expect_json_contract"
97
+ when :contract_with
98
+ "contract_with(:name, ...)"
99
+ else
100
+ "contract(:name)"
101
+ end
54
102
  "Invalid contract name #{name.inspect} (#{name.class}). " \
55
103
  "#{lookup_hint} requires a contract name that responds to #to_sym."
56
104
  end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpec
4
+ module Rest
5
+ class ContractOverridesBuilder
6
+ def initialize(context:)
7
+ @context = context
8
+ end
9
+
10
+ def normalize!(overrides, keyword_overrides)
11
+ unless keyword_overrides.empty?
12
+ raise ArgumentError, "contract_with received both positional Hash and keyword overrides" unless overrides.nil?
13
+
14
+ return keyword_overrides
15
+ end
16
+
17
+ return {} if overrides.nil?
18
+ return overrides if overrides.is_a?(Hash)
19
+
20
+ raise ArgumentError,
21
+ "contract_with requires overrides to be a Hash or keyword arguments, got #{overrides.class}"
22
+ end
23
+
24
+ def key_tree_for(contract_value)
25
+ hash = extract_contract_hash(contract_value)
26
+ return nil if hash.nil?
27
+
28
+ hash.each_with_object({}) do |(key, nested), memo|
29
+ memo[key.to_s] = key_tree_for(nested)
30
+ end
31
+ end
32
+
33
+ def validate_keys(key_tree, overrides, path = [])
34
+ overrides.each do |key, value|
35
+ key_name = key.to_s
36
+ unless key_tree.key?(key_name)
37
+ available_keys = key_tree.keys.sort
38
+ location = path.empty? ? "contract root" : "$.#{path.join('.')}"
39
+ return "Unknown override key #{key_name.inspect} at #{location}. " \
40
+ "Available keys: [#{available_keys.map(&:inspect).join(', ')}]"
41
+ end
42
+
43
+ next unless value.is_a?(Hash)
44
+
45
+ nested_tree = key_tree[key_name]
46
+ if nested_tree.nil?
47
+ location = (path + [key_name]).join(".")
48
+ return "Override key #{key_name.inspect} at $.#{location} does not support nested overrides."
49
+ end
50
+
51
+ nested_error = validate_keys(nested_tree, value, path + [key_name])
52
+ return nested_error unless nested_error.nil?
53
+ end
54
+
55
+ nil
56
+ end
57
+
58
+ def build_matcher(overrides)
59
+ @context.hash_including(
60
+ overrides.each_with_object({}) do |(key, value), memo|
61
+ memo[key.to_s] = value.is_a?(Hash) ? build_matcher(value) : value
62
+ end
63
+ )
64
+ end
65
+
66
+ private
67
+
68
+ def extract_contract_hash(value)
69
+ return value if value.is_a?(Hash)
70
+
71
+ return nil unless value.respond_to?(:matcher_name) && value.respond_to?(:expecteds)
72
+
73
+ matcher_name = value.matcher_name
74
+ return nil unless matcher_name == :a_hash_including
75
+
76
+ expecteds = value.expecteds
77
+ return nil unless expecteds.is_a?(Array) && expecteds.first.is_a?(Hash)
78
+
79
+ expecteds.first
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpec
4
+ module Rest
5
+ class ContractWithMatcher
6
+ def initialize(name:, contract_matcher:, overrides_matcher:)
7
+ @name = name
8
+ @contract_matcher = contract_matcher
9
+ @overrides_matcher = overrides_matcher
10
+ end
11
+
12
+ def matches?(actual)
13
+ @contract_matched = @contract_matcher.matches?(actual)
14
+ return false unless @contract_matched
15
+
16
+ @overrides_matcher.matches?(actual)
17
+ end
18
+
19
+ def failure_message
20
+ return @contract_matcher.failure_message unless @contract_matched
21
+
22
+ "Contract #{@name.inspect} override assertion failed: #{@overrides_matcher.failure_message}"
23
+ end
24
+
25
+ def failure_message_when_negated
26
+ "Expected value not to match contract #{@name.inspect} with overrides"
27
+ end
28
+
29
+ def description
30
+ "match contract #{@name.inspect} with overrides"
31
+ end
32
+ end
33
+ end
34
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "formatters/request_dump"
4
4
  require_relative "formatters/request_recorder"
5
+ require_relative "body_expectations"
5
6
  require_relative "error_expectations"
6
7
  require_relative "contract_expectations"
7
8
  require_relative "header_expectations"
@@ -14,6 +15,7 @@ module RSpec
14
15
  module Rest
15
16
  module Expectations
16
17
  include ContractExpectations
18
+ include BodyExpectations
17
19
  include ErrorExpectations
18
20
  include HeaderExpectations
19
21
  include JsonItemExpectations
@@ -35,13 +37,32 @@ module RSpec
35
37
 
36
38
  def expect_json_at(selector, expected = nil, &block)
37
39
  with_request_dump_on_failure do
38
- selected = JsonSelector.extract(rest_response.json, selector)
40
+ selected = JsonSelector.extract(rest_response.json, normalize_json_selector(selector))
39
41
  evaluate_json_value(selected, expected, &block)
40
42
  end
41
43
  end
42
44
 
43
45
  private
44
46
 
47
+ def normalize_json_selector(selector)
48
+ case selector
49
+ when Symbol
50
+ "$.#{selector}"
51
+ when String
52
+ return selector if selector.start_with?("$")
53
+ return "$.#{selector}" if selector.match?(/\A[a-zA-Z_][a-zA-Z0-9_]*\z/)
54
+
55
+ raise InvalidJsonSelectorError,
56
+ "Invalid selector #{selector.inspect}. Top-level shorthand accepts Symbol or simple String " \
57
+ "keys (for example :message or \"message\"). Use JSONPath for nested selectors " \
58
+ "(for example \"$.items[0].id\")."
59
+ else
60
+ raise InvalidJsonSelectorError,
61
+ "Invalid selector #{selector.inspect}. Selector must be a Symbol, a String top-level key, " \
62
+ "or a JSONPath String starting with '$'."
63
+ end
64
+ end
65
+
45
66
  def evaluate_json_value(value, expected = nil, &block)
46
67
  if block
47
68
  instance_exec(value, &block)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module RSpec
4
4
  module Rest
5
- VERSION = "0.4.0"
5
+ VERSION = "0.5.0"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rspec-rest
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Carl
@@ -105,12 +105,15 @@ files:
105
105
  - Rakefile
106
106
  - exe/rspec-rest
107
107
  - lib/rspec/rest.rb
108
+ - lib/rspec/rest/body_expectations.rb
108
109
  - lib/rspec/rest/captures.rb
109
110
  - lib/rspec/rest/class_level_contracts.rb
110
111
  - lib/rspec/rest/class_level_presets.rb
111
112
  - lib/rspec/rest/config.rb
112
113
  - lib/rspec/rest/contract_expectations.rb
113
114
  - lib/rspec/rest/contract_matcher.rb
115
+ - lib/rspec/rest/contract_overrides_builder.rb
116
+ - lib/rspec/rest/contract_with_matcher.rb
114
117
  - lib/rspec/rest/deprecation.rb
115
118
  - lib/rspec/rest/dsl.rb
116
119
  - lib/rspec/rest/error_expectations.rb