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 +4 -4
- data/CHANGELOG.md +13 -0
- data/Gemfile.lock +1 -1
- data/README.md +20 -0
- data/lib/rspec/rest/body_expectations.rb +29 -0
- data/lib/rspec/rest/contract_expectations.rb +49 -1
- data/lib/rspec/rest/contract_overrides_builder.rb +83 -0
- data/lib/rspec/rest/contract_with_matcher.rb +34 -0
- data/lib/rspec/rest/expectations.rb +22 -1
- data/lib/rspec/rest/version.rb +1 -1
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3fef8eafbf21f5d0f307fa44423fa4549ad602de47671f6bec67257121e66263
|
|
4
|
+
data.tar.gz: fbea7383a57565b80277d093d66c4e1bd80a24b3343bf1118f86699ed04e88f8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
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
|
|
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)
|
data/lib/rspec/rest/version.rb
CHANGED
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
|
+
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
|