rspec-rest 0.1.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.
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpec
4
+ module Rest
5
+ class UnknownContractMatcher
6
+ def initialize(message)
7
+ @message = message
8
+ end
9
+
10
+ def matches?(_actual)
11
+ false
12
+ end
13
+
14
+ def failure_message
15
+ @message
16
+ end
17
+
18
+ def failure_message_when_negated
19
+ @message
20
+ end
21
+
22
+ def description
23
+ "unknown JSON contract"
24
+ end
25
+ end
26
+
27
+ class ContractMatcher
28
+ def initialize(name:, definition:, context:)
29
+ @name = name
30
+ @definition = definition
31
+ @context = context
32
+ end
33
+
34
+ def matches?(actual)
35
+ matcher.matches?(actual)
36
+ end
37
+
38
+ def failure_message
39
+ "Contract #{@name.inspect} failed: #{matcher.failure_message}"
40
+ end
41
+
42
+ def failure_message_when_negated
43
+ "Contract #{@name.inspect} failed: #{matcher.failure_message_when_negated}"
44
+ end
45
+
46
+ def description
47
+ "match contract #{@name.inspect}"
48
+ end
49
+
50
+ private
51
+
52
+ def matcher
53
+ @matcher ||= begin
54
+ value = @context.instance_exec(&@definition)
55
+ value.respond_to?(:matches?) ? value : @context.eq(value)
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,239 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "config"
4
+ require_relative "captures"
5
+ require_relative "class_level_contracts"
6
+ require_relative "class_level_presets"
7
+ require_relative "errors"
8
+ require_relative "expectations"
9
+ require_relative "json_selector"
10
+ require_relative "request_builders"
11
+ require_relative "session"
12
+ module RSpec
13
+ module Rest
14
+ module DSL
15
+ HTTP_METHODS = ::RSpec::Rest::Session::SUPPORTED_HTTP_METHODS
16
+
17
+ class ApiConfigBuilder
18
+ def initialize(config)
19
+ @config = Config.new(
20
+ app: config.app,
21
+ base_path: config.base_path,
22
+ base_headers: config.base_headers,
23
+ default_format: config.default_format,
24
+ redact_headers: config.redact_headers,
25
+ base_url: config.base_url
26
+ )
27
+ end
28
+
29
+ def app(value)
30
+ @config.app = value
31
+ end
32
+
33
+ def base_path(value)
34
+ @config.base_path = value
35
+ end
36
+
37
+ def base_headers(value)
38
+ @config.base_headers = value.dup
39
+ end
40
+
41
+ def default_format(value)
42
+ @config.default_format = value
43
+ end
44
+
45
+ def redact_headers(value)
46
+ @config.redact_headers = value.dup
47
+ end
48
+
49
+ def base_url(value)
50
+ @config.base_url = value
51
+ end
52
+
53
+ def to_config
54
+ @config
55
+ end
56
+ end
57
+
58
+ module ClassMethods
59
+ include ClassLevelContracts
60
+ include ClassLevelPresets
61
+
62
+ def api(&)
63
+ builder = ApiConfigBuilder.new(rest_config)
64
+ builder.instance_eval(&)
65
+ @rest_config = builder.to_config
66
+ end
67
+
68
+ def resource(path, &)
69
+ @rest_resource_stack ||= []
70
+ @rest_preset_stack ||= []
71
+ @rest_resource_stack << path
72
+ @rest_preset_stack << blank_presets
73
+ class_eval(&)
74
+ ensure
75
+ @rest_preset_stack.pop
76
+ @rest_resource_stack.pop
77
+ end
78
+
79
+ HTTP_METHODS.each do |method|
80
+ define_method(method) do |path, &block|
81
+ resource_path = current_resource_path
82
+ request_presets = deep_dup_presets(current_request_presets)
83
+ it("#{method.to_s.upcase} #{path}") do
84
+ start_rest_request(
85
+ method: method,
86
+ path: path,
87
+ resource_path: resource_path,
88
+ presets: request_presets
89
+ )
90
+ instance_eval(&block) if block
91
+ execute_rest_request_if_pending
92
+ end
93
+ end
94
+ end
95
+
96
+ def rest_config
97
+ return @rest_config if instance_variable_defined?(:@rest_config)
98
+
99
+ parent = superclass.respond_to?(:rest_config) ? superclass.rest_config : Config.new
100
+ Config.new(
101
+ app: parent.app,
102
+ base_path: parent.base_path,
103
+ base_headers: parent.base_headers,
104
+ default_format: parent.default_format,
105
+ redact_headers: parent.redact_headers,
106
+ base_url: parent.base_url
107
+ )
108
+ end
109
+
110
+ private
111
+
112
+ def current_resource_path
113
+ stack = @rest_resource_stack || []
114
+ return nil if stack.empty?
115
+
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?)
120
+
121
+ path = normalized_segments.join("/")
122
+ first_had_leading_slash && !path.empty? ? "/#{path}" : path
123
+ end
124
+ end
125
+
126
+ module InstanceMethods
127
+ include Captures
128
+ include Expectations
129
+ include RequestBuilders
130
+
131
+ def rest_session
132
+ @rest_session ||= Session.new(self.class.rest_config)
133
+ end
134
+
135
+ def rest_response
136
+ ensure_request_context!
137
+ execute_rest_request_if_pending
138
+ @rest_response
139
+ end
140
+
141
+ def last_request
142
+ ensure_request_context!
143
+ execute_rest_request_if_pending
144
+ rest_session.last_request
145
+ end
146
+
147
+ private
148
+
149
+ def start_rest_request(method:, path:, resource_path:, presets: nil)
150
+ effective_presets = presets || ClassLevelPresets::DEFAULT_PRESETS
151
+ preset_headers = (effective_presets[:headers] || {}).dup
152
+ preset_query = (effective_presets[:query] || {}).dup
153
+
154
+ @rest_request_state = {
155
+ method: method,
156
+ path: path,
157
+ resource_path: resource_path,
158
+ headers: preset_headers,
159
+ query: preset_query.empty? ? nil : preset_query,
160
+ json: nil,
161
+ multipart: false,
162
+ params: nil,
163
+ path_params: {}
164
+ }
165
+ @rest_response = nil
166
+ @rest_request_executed = false
167
+ end
168
+
169
+ def execute_rest_request_if_pending
170
+ ensure_request_context!
171
+ return if @rest_request_executed
172
+
173
+ @rest_request_executed = true
174
+ execute_rest_request
175
+ end
176
+
177
+ def execute_rest_request
178
+ request_path = apply_path_params(
179
+ rest_request_state[:path],
180
+ rest_request_state[:path_params]
181
+ )
182
+ request_resource_path = apply_path_params(
183
+ rest_request_state[:resource_path],
184
+ rest_request_state[:path_params]
185
+ )
186
+ if rest_request_state[:multipart] && !rest_request_state[:json].nil?
187
+ raise ArgumentError, "Cannot use json(...) with multipart! requests. Use file(...) and params."
188
+ end
189
+
190
+ @rest_response = rest_session.request(
191
+ method: rest_request_state[:method],
192
+ path: request_path,
193
+ resource_path: request_resource_path,
194
+ headers: rest_request_state[:headers],
195
+ query: rest_request_state[:query],
196
+ json: rest_request_state[:json],
197
+ params: rest_request_state[:params]
198
+ )
199
+ end
200
+
201
+ def rest_request_state
202
+ unless defined?(@rest_request_state) && @rest_request_state
203
+ raise MissingRequestContextError,
204
+ "REST request context is not initialized. Call this inside a verb block (get/post/put/patch/delete)."
205
+ end
206
+
207
+ @rest_request_state
208
+ end
209
+
210
+ def ensure_request_context!
211
+ return if defined?(@rest_request_state) && @rest_request_state
212
+
213
+ raise MissingRequestContextError,
214
+ "No active REST request context. Call this inside a verb block (get/post/put/patch/delete)."
215
+ end
216
+
217
+ def apply_path_params(path, params)
218
+ rendered = params.reduce(path.to_s) do |current, (key, value)|
219
+ current.gsub("{#{key}}", value.to_s)
220
+ end
221
+
222
+ # Detect any placeholders that were not replaced and provide a clear error
223
+ missing_placeholders = rendered.scan(/\{([^}]+)\}/).flatten.uniq
224
+ unless missing_placeholders.empty?
225
+ raise ArgumentError,
226
+ "Missing path params for placeholders: #{missing_placeholders.join(', ')} in path '#{path}'"
227
+ end
228
+
229
+ rendered
230
+ end
231
+ end
232
+
233
+ def self.included(base)
234
+ base.extend(ClassMethods)
235
+ base.include(InstanceMethods)
236
+ end
237
+ end
238
+ end
239
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpec
4
+ module Rest
5
+ module ErrorExpectations
6
+ def expect_error(status:, message: nil, includes: nil, field: nil, key: "error")
7
+ with_request_dump_on_failure do
8
+ expect(rest_response.status).to eq(status)
9
+ error_value = extract_error_value(key)
10
+ expect_error_message!(error_value, message) if message
11
+ expect_error_includes!(error_value, includes) if includes
12
+ expect_error_field!(error_value, field) if field
13
+ error_value
14
+ end
15
+ end
16
+
17
+ private
18
+
19
+ def extract_error_value(key)
20
+ payload = rest_response.json
21
+ unless payload.is_a?(Hash)
22
+ raise ::RSpec::Expectations::ExpectationNotMetError,
23
+ "Expected JSON response to be an object with #{key.inspect} key, got #{payload.class}."
24
+ end
25
+
26
+ normalized_key = key.to_s
27
+ error_value = payload[normalized_key]
28
+ return error_value unless error_value.nil?
29
+
30
+ raise ::RSpec::Expectations::ExpectationNotMetError,
31
+ "Expected JSON response to include #{normalized_key.inspect} key."
32
+ end
33
+
34
+ def expect_error_message!(error_value, message)
35
+ if error_value.is_a?(Array)
36
+ expect(error_value).to include(message)
37
+ else
38
+ expect(error_value).to eq(message)
39
+ end
40
+ end
41
+
42
+ def expect_error_includes!(error_value, includes)
43
+ Array(includes).each do |expected_fragment|
44
+ expect(error_text(error_value)).to include(expected_fragment.to_s)
45
+ end
46
+ end
47
+
48
+ def expect_error_field!(error_value, field)
49
+ expect(error_text(error_value)).to include(field.to_s)
50
+ end
51
+
52
+ def error_text(error_value)
53
+ error_value.is_a?(Array) ? error_value.join(" ") : error_value.to_s
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpec
4
+ module Rest
5
+ class Error < StandardError; end
6
+
7
+ class MissingAppError < Error; end
8
+
9
+ class InvalidJsonError < Error; end
10
+
11
+ class UnsupportedHttpMethodError < Error; end
12
+
13
+ class MissingRequestContextError < Error; end
14
+
15
+ class InvalidJsonSelectorError < Error; end
16
+
17
+ class MissingJsonPathError < Error; end
18
+
19
+ class MissingCaptureError < Error; end
20
+ end
21
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "formatters/request_dump"
4
+ require_relative "formatters/request_recorder"
5
+ require_relative "error_expectations"
6
+ require_relative "contract_expectations"
7
+ require_relative "header_expectations"
8
+ require_relative "json_selector"
9
+ require_relative "json_type_helpers"
10
+ require_relative "pagination_expectations"
11
+
12
+ module RSpec
13
+ module Rest
14
+ module Expectations
15
+ include ContractExpectations
16
+ include ErrorExpectations
17
+ include HeaderExpectations
18
+ include JsonTypeHelpers
19
+ include PaginationExpectations
20
+
21
+ def expect_status(code)
22
+ with_request_dump_on_failure do
23
+ expect(rest_response.status).to eq(code)
24
+ end
25
+ end
26
+
27
+ def expect_json(expected = nil, &block)
28
+ with_request_dump_on_failure do
29
+ 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
45
+ end
46
+ end
47
+
48
+ def expect_json_at(selector, expected = nil, &block)
49
+ with_request_dump_on_failure do
50
+ selected = JsonSelector.extract(rest_response.json, selector)
51
+
52
+ if block
53
+ instance_exec(selected, &block)
54
+ next selected
55
+ end
56
+
57
+ next selected if expected.nil?
58
+
59
+ if expected.respond_to?(:matches?)
60
+ expect(selected).to expected
61
+ else
62
+ expect(selected).to eq(expected)
63
+ end
64
+
65
+ selected
66
+ end
67
+ end
68
+
69
+ private
70
+
71
+ def with_request_dump_on_failure
72
+ yield
73
+ rescue ::RSpec::Expectations::ExpectationNotMetError => e
74
+ message = "#{e.message}\n\n#{request_dump}\n\nReproduce with:\n#{request_curl}"
75
+ new_exception = e.exception(message)
76
+ new_exception.set_backtrace(e.backtrace)
77
+ raise new_exception
78
+ end
79
+
80
+ def request_dump
81
+ Formatters::RequestDump.new(
82
+ last_request: safe_last_request,
83
+ response: safe_rest_response,
84
+ redacted_headers: redacted_headers_for_dump
85
+ ).format
86
+ end
87
+
88
+ def safe_last_request
89
+ rest_session.last_request
90
+ rescue StandardError
91
+ nil
92
+ end
93
+
94
+ def safe_rest_response
95
+ rest_response
96
+ rescue MissingRequestContextError, StandardError
97
+ nil
98
+ end
99
+
100
+ def redacted_headers_for_dump
101
+ self.class.rest_config.redact_headers
102
+ rescue StandardError
103
+ nil
104
+ end
105
+
106
+ def request_curl
107
+ Formatters::RequestRecorder.new(
108
+ last_request: safe_last_request,
109
+ redacted_headers: redacted_headers_for_dump
110
+ ).to_curl
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpec
4
+ module Rest
5
+ module Formatters
6
+ module Helpers
7
+ private
8
+
9
+ def sanitize_for_json(value)
10
+ case value
11
+ when Hash
12
+ value.transform_values { |inner| sanitize_for_json(inner) }
13
+ when Array
14
+ value.map { |inner| sanitize_for_json(inner) }
15
+ else
16
+ value.respond_to?(:to_str) ? value.to_str : value.to_s
17
+ end
18
+ end
19
+
20
+ def normalize_redacted_headers(headers)
21
+ headers.map { |header| header.to_s.downcase }
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "../config"
5
+ require_relative "helpers"
6
+
7
+ module RSpec
8
+ module Rest
9
+ module Formatters
10
+ class RequestDump
11
+ include Helpers
12
+
13
+ def initialize(last_request:, response:, redacted_headers: nil)
14
+ @last_request = last_request || {}
15
+ @response = response
16
+ @redacted_headers = normalize_redacted_headers(redacted_headers || Config::DEFAULT_REDACT_HEADERS)
17
+ end
18
+
19
+ def format
20
+ [
21
+ "Request:",
22
+ request_line,
23
+ "Headers:",
24
+ formatted_headers(@last_request[:headers]),
25
+ "Body:",
26
+ formatted_body(@last_request[:body]),
27
+ "",
28
+ "Response:",
29
+ "Status: #{response_status}",
30
+ "Headers:",
31
+ formatted_headers(response_headers),
32
+ "Body:",
33
+ formatted_body(response_body)
34
+ ].join("\n")
35
+ end
36
+
37
+ private
38
+
39
+ def request_line
40
+ method = @last_request[:method] || "UNKNOWN"
41
+ path = @last_request[:path] || "(unknown path)"
42
+ "#{method} #{path}"
43
+ end
44
+
45
+ def response_status
46
+ @response&.status || "(unknown status)"
47
+ end
48
+
49
+ def response_headers
50
+ @response&.headers || {}
51
+ end
52
+
53
+ def response_body
54
+ @response&.body
55
+ end
56
+
57
+ def formatted_headers(headers)
58
+ return "(none)" if headers.nil? || headers.empty?
59
+
60
+ headers.sort_by { |key, _| key.to_s.downcase }
61
+ .map { |key, value| "#{key}: #{redacted_header_value(key, value)}" }
62
+ .join("\n")
63
+ end
64
+
65
+ def formatted_body(body)
66
+ return "(empty)" if body.nil? || (body.respond_to?(:empty?) && body.empty?)
67
+
68
+ if body.is_a?(Hash) || body.is_a?(Array)
69
+ begin
70
+ return JSON.pretty_generate(body)
71
+ rescue TypeError
72
+ return JSON.pretty_generate(sanitize_for_json(body))
73
+ end
74
+ end
75
+
76
+ body_str = body.to_s
77
+ parsed = parse_json(body_str)
78
+ return JSON.pretty_generate(parsed) unless parsed.nil?
79
+
80
+ body_str
81
+ end
82
+
83
+ def parse_json(value)
84
+ JSON.parse(value)
85
+ rescue JSON::ParserError
86
+ nil
87
+ end
88
+
89
+ def redacted_header_value(key, value)
90
+ return value unless redacted_header?(key)
91
+
92
+ "[REDACTED]"
93
+ end
94
+
95
+ def redacted_header?(key)
96
+ @redacted_headers.include?(key.to_s.downcase)
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "../config"
5
+ require_relative "helpers"
6
+
7
+ module RSpec
8
+ module Rest
9
+ module Formatters
10
+ class RequestRecorder
11
+ include Helpers
12
+
13
+ def initialize(last_request:, redacted_headers: nil)
14
+ @last_request = last_request || {}
15
+ @redacted_headers = normalize_redacted_headers(redacted_headers || Config::DEFAULT_REDACT_HEADERS)
16
+ end
17
+
18
+ def to_curl
19
+ [
20
+ "curl",
21
+ "-X #{method}",
22
+ shell_escape(url),
23
+ formatted_headers,
24
+ formatted_body
25
+ ].compact.join(" ")
26
+ end
27
+
28
+ private
29
+
30
+ def method
31
+ (@last_request[:method] || "GET").to_s.upcase
32
+ end
33
+
34
+ def url
35
+ @last_request[:url] || @last_request[:path] || "http://example.org/"
36
+ end
37
+
38
+ def formatted_headers
39
+ headers = @last_request[:headers]
40
+ return nil if headers.nil? || headers.empty?
41
+
42
+ headers.sort_by { |key, _| key.to_s.downcase }
43
+ .map { |key, value| %(-H #{shell_escape("#{key}: #{redacted_value(key, value)}")}) }
44
+ .join(" ")
45
+ end
46
+
47
+ def formatted_body
48
+ body = @last_request[:body]
49
+ return nil if body.nil?
50
+ return nil if body.respond_to?(:empty?) && body.empty?
51
+
52
+ value = serialize_body(body)
53
+ "-d #{shell_escape(value)}"
54
+ end
55
+
56
+ def serialize_body(body)
57
+ return body.to_s unless body.is_a?(Hash) || body.is_a?(Array)
58
+
59
+ JSON.dump(body)
60
+ rescue TypeError
61
+ JSON.dump(sanitize_for_json(body))
62
+ end
63
+
64
+ def redacted_value(key, value)
65
+ return value unless @redacted_headers.include?(key.to_s.downcase)
66
+
67
+ "[REDACTED]"
68
+ end
69
+
70
+ def shell_escape(value)
71
+ "'#{value.to_s.gsub("'", %q('"'"'))}'"
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end