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.
- checksums.yaml +7 -0
- data/.gitignore +69 -0
- data/.rubocop.yml +49 -0
- data/CHANGELOG.md +45 -0
- data/Gemfile +5 -0
- data/Gemfile.lock +74 -0
- data/LICENSE +21 -0
- data/README.md +447 -0
- data/Rakefile +8 -0
- data/exe/rspec-rest +12 -0
- data/lib/rspec/rest/captures.rb +27 -0
- data/lib/rspec/rest/class_level_contracts.rb +40 -0
- data/lib/rspec/rest/class_level_presets.rb +90 -0
- data/lib/rspec/rest/config.rb +27 -0
- data/lib/rspec/rest/contract_expectations.rb +38 -0
- data/lib/rspec/rest/contract_matcher.rb +60 -0
- data/lib/rspec/rest/dsl.rb +239 -0
- data/lib/rspec/rest/error_expectations.rb +57 -0
- data/lib/rspec/rest/errors.rb +21 -0
- data/lib/rspec/rest/expectations.rb +114 -0
- data/lib/rspec/rest/formatters/helpers.rb +26 -0
- data/lib/rspec/rest/formatters/request_dump.rb +101 -0
- data/lib/rspec/rest/formatters/request_recorder.rb +76 -0
- data/lib/rspec/rest/header_expectations.rb +36 -0
- data/lib/rspec/rest/json_selector.rb +79 -0
- data/lib/rspec/rest/json_type_helpers.rb +27 -0
- data/lib/rspec/rest/pagination_expectations.rb +60 -0
- data/lib/rspec/rest/request_builders.rb +73 -0
- data/lib/rspec/rest/response.rb +37 -0
- data/lib/rspec/rest/session.rb +136 -0
- data/lib/rspec/rest/version.rb +7 -0
- data/lib/rspec/rest.rb +18 -0
- metadata +157 -0
|
@@ -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
|