right_develop 2.1.5 → 2.2.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.
- data/VERSION +1 -1
- data/bin/right_develop +4 -0
- data/lib/right_develop.rb +1 -0
- data/lib/right_develop/commands.rb +2 -1
- data/lib/right_develop/commands/server.rb +194 -0
- data/lib/right_develop/testing.rb +31 -0
- data/lib/right_develop/testing/clients.rb +36 -0
- data/lib/right_develop/testing/clients/rest.rb +34 -0
- data/lib/right_develop/testing/clients/rest/requests.rb +38 -0
- data/lib/right_develop/testing/clients/rest/requests/base.rb +305 -0
- data/lib/right_develop/testing/clients/rest/requests/playback.rb +293 -0
- data/lib/right_develop/testing/clients/rest/requests/record.rb +175 -0
- data/lib/right_develop/testing/recording.rb +33 -0
- data/lib/right_develop/testing/recording/config.rb +571 -0
- data/lib/right_develop/testing/recording/metadata.rb +805 -0
- data/lib/right_develop/testing/servers/might_api/.gitignore +3 -0
- data/lib/right_develop/testing/servers/might_api/Gemfile +6 -0
- data/lib/right_develop/testing/servers/might_api/Gemfile.lock +18 -0
- data/lib/right_develop/testing/servers/might_api/app/base.rb +323 -0
- data/lib/right_develop/testing/servers/might_api/app/echo.rb +73 -0
- data/lib/right_develop/testing/servers/might_api/app/playback.rb +46 -0
- data/lib/right_develop/testing/servers/might_api/app/record.rb +45 -0
- data/lib/right_develop/testing/servers/might_api/config.ru +8 -0
- data/lib/right_develop/testing/servers/might_api/config/init.rb +47 -0
- data/lib/right_develop/testing/servers/might_api/lib/config.rb +204 -0
- data/lib/right_develop/testing/servers/might_api/lib/logger.rb +68 -0
- data/right_develop.gemspec +30 -2
- metadata +84 -34
@@ -0,0 +1,805 @@
|
|
1
|
+
#
|
2
|
+
# Copyright (c) 2014 RightScale Inc
|
3
|
+
#
|
4
|
+
# Permission is hereby granted, free of charge, to any person obtaining
|
5
|
+
# a copy of this software and associated documentation files (the
|
6
|
+
# "Software"), to deal in the Software without restriction, including
|
7
|
+
# without limitation the rights to use, copy, modify, merge, publish,
|
8
|
+
# distribute, sublicense, and/or sell copies of the Software, and to
|
9
|
+
# permit persons to whom the Software is furnished to do so, subject to
|
10
|
+
# the following conditions:
|
11
|
+
#
|
12
|
+
# The above copyright notice and this permission notice shall be
|
13
|
+
# included in all copies or substantial portions of the Software.
|
14
|
+
#
|
15
|
+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
16
|
+
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
17
|
+
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
18
|
+
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
19
|
+
# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
20
|
+
# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
21
|
+
# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
22
|
+
|
23
|
+
# ancestor
|
24
|
+
require 'right_develop/testing/recording'
|
25
|
+
require 'digest/md5'
|
26
|
+
require 'uri'
|
27
|
+
|
28
|
+
module RightDevelop::Testing::Recording
|
29
|
+
|
30
|
+
# Metadata for record and playback.
|
31
|
+
class Metadata
|
32
|
+
|
33
|
+
# value used for obfuscation.
|
34
|
+
HIDDEN_CREDENTIAL_VALUE = 'HIDDEN_CREDENTIAL'.freeze
|
35
|
+
|
36
|
+
# common API verbs.
|
37
|
+
VERBS = %w(DELETE GET HEAD PATCH POST PUT).freeze
|
38
|
+
|
39
|
+
# valid modes, determines how variables are substituted, etc.
|
40
|
+
MODES = %w(echo record playback)
|
41
|
+
|
42
|
+
# valid kinds, also keys under matchers.
|
43
|
+
KINDS = %w(request response)
|
44
|
+
|
45
|
+
# route-relative config keys.
|
46
|
+
MATCHERS_KEY = 'matchers'.freeze
|
47
|
+
SIGNIFICANT_KEY = 'significant'.freeze
|
48
|
+
TIMEOUTS_KEY = 'timeouts'.freeze
|
49
|
+
TRANSFORM_KEY = 'transform'.freeze
|
50
|
+
VARIABLES_KEY = 'variables'.freeze
|
51
|
+
|
52
|
+
# finds the value index for a recorded variable, if any.
|
53
|
+
VARIABLE_INDEX_REGEX = /\[(\d+)\]$/
|
54
|
+
|
55
|
+
# throw/catch signals.
|
56
|
+
HALT = :halt_recording_metadata_generator
|
57
|
+
|
58
|
+
# retry-able failures.
|
59
|
+
class RetryableFailure; end
|
60
|
+
|
61
|
+
class MissingVariableFailure < RetryableFailure
|
62
|
+
attr_reader :path, :variable, :variable_array_index, :variable_array_size
|
63
|
+
|
64
|
+
def initialize(options)
|
65
|
+
@path = options[:path] or raise ::ArgumentError, 'options[:path] is required'
|
66
|
+
@variable = options[:variable] or raise ::ArgumentError, 'options[:variable] is required'
|
67
|
+
@variable_array_index = options[:variable_array_index] || 0
|
68
|
+
@variable_array_size = options[:variable_array_size] || 0
|
69
|
+
end
|
70
|
+
|
71
|
+
def message
|
72
|
+
if (0 == variable_array_size)
|
73
|
+
result = 'A variable was never defined by request '
|
74
|
+
else
|
75
|
+
result =
|
76
|
+
'A variable index is past the range of values defined by request ' <<
|
77
|
+
"(#{variable_array_index} >= #{variable_array_size}) "
|
78
|
+
end
|
79
|
+
result <<
|
80
|
+
"while replacing variable = #{variable.inspect} at " <<
|
81
|
+
path.join('/').inspect
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
# exceptions.
|
86
|
+
class RecordingError < StandardError; end
|
87
|
+
|
88
|
+
attr_reader :uri, :verb, :http_status, :headers, :body
|
89
|
+
attr_reader :mode, :logger, :effective_route_config, :variables
|
90
|
+
|
91
|
+
# Computes the metadata used to identify where the request/response should
|
92
|
+
# be stored-to/retrieved-from. Recording the full request is not strictly
|
93
|
+
# necessary (because the request maps to a MD5 used for response-only) but
|
94
|
+
# it adds human-readability and the ability to manually customize some or
|
95
|
+
# all responses.
|
96
|
+
def initialize(options)
|
97
|
+
unless (@logger = options[:logger])
|
98
|
+
raise ::ArgumentError, "options[:logger] is required: #{@logger.inspect}"
|
99
|
+
end
|
100
|
+
unless (@mode = options[:mode].to_s) && MODES.include?(@mode)
|
101
|
+
raise ::ArgumentError, "options[:mode] must be one of #{MODES.inspect}: #{@mode.inspect}"
|
102
|
+
end
|
103
|
+
unless (@kind = options[:kind].to_s) && KINDS.include?(@kind)
|
104
|
+
raise ::ArgumentError, "options[:kind] must be one of #{KINDS.inspect}: #{@kind.inspect}"
|
105
|
+
end
|
106
|
+
unless (@uri = options[:uri]) && @uri.respond_to?(:path)
|
107
|
+
raise ::ArgumentError, "options[:uri] must be a valid parsed URI: #{@uri.inspect}"
|
108
|
+
end
|
109
|
+
unless (@verb = options[:verb]) && VERBS.include?(@verb)
|
110
|
+
raise ::ArgumentError, "options[:verb] must be one of #{VERBS.inspect}: #{@verb.inspect}"
|
111
|
+
end
|
112
|
+
unless (@headers = options[:headers]).kind_of?(::Hash)
|
113
|
+
raise ::ArgumentError, "options[:headers] must be a hash: #{@headers.inspect}"
|
114
|
+
end
|
115
|
+
unless (@route_data = options[:route_data]).kind_of?(::Hash)
|
116
|
+
raise ::ArgumentError, "options[:route_data] must be a hash: #{@route_data.inspect}"
|
117
|
+
end
|
118
|
+
@http_status = options[:http_status]
|
119
|
+
if @kind == 'response'
|
120
|
+
@http_status = Integer(@http_status)
|
121
|
+
elsif !@http_status.nil?
|
122
|
+
raise ::ArgumentError, "options[:http_status] is unexpected for #{@kind}."
|
123
|
+
end
|
124
|
+
unless (@variables = options[:variables]).kind_of?(::Hash)
|
125
|
+
raise ::ArgumentError, "options[:variables] must be a hash: #{@variables.inspect}"
|
126
|
+
end
|
127
|
+
if (@effective_route_config = options[:effective_route_config]) && !@effective_route_config.kind_of?(::Hash)
|
128
|
+
raise ::ArgumentError, "options[:effective_route_config] is not a hash: #{@effective_route_config.inspect}"
|
129
|
+
end
|
130
|
+
@body = options[:body] # not required
|
131
|
+
|
132
|
+
# merge one or more wildcard configurations matching the current uri and
|
133
|
+
# parameters.
|
134
|
+
@headers = normalize_headers(@headers)
|
135
|
+
@typenames_to_values = compute_typenames_to_values
|
136
|
+
|
137
|
+
# effective route config may already have been computed for request
|
138
|
+
# (on record) or not (on playback).
|
139
|
+
@effective_route_config ||= compute_effective_route_config
|
140
|
+
|
141
|
+
# apply the configuration by substituting for variables in the request and
|
142
|
+
# by obfuscating wherever a variable name is nil.
|
143
|
+
case @mode
|
144
|
+
when 'echo'
|
145
|
+
# do nothing; used to validate the fixtures before playback, etc.
|
146
|
+
else
|
147
|
+
erck = @effective_route_config[@kind]
|
148
|
+
if effective_variables = erck && erck[VARIABLES_KEY]
|
149
|
+
recursive_replace_variables(
|
150
|
+
[@kind, VARIABLES_KEY],
|
151
|
+
@typenames_to_values,
|
152
|
+
effective_variables,
|
153
|
+
erck[TRANSFORM_KEY])
|
154
|
+
end
|
155
|
+
if logger.debug?
|
156
|
+
logger.debug("#{@kind} effective_route_config = #{@effective_route_config[@kind].inspect}")
|
157
|
+
logger.debug("#{@kind} typenames_to_values = #{@typenames_to_values.inspect}")
|
158
|
+
end
|
159
|
+
end
|
160
|
+
|
161
|
+
# recreate headers and body from data using variable substitutions and
|
162
|
+
# obfuscations.
|
163
|
+
@headers = @typenames_to_values[:header]
|
164
|
+
@body = normalize_body(@headers, @typenames_to_values[:body] || @body)
|
165
|
+
end
|
166
|
+
|
167
|
+
# @return [String] normalized query string
|
168
|
+
def query
|
169
|
+
q = @typenames_to_values[:query]
|
170
|
+
if q && !q.empty?
|
171
|
+
build_query_string(q)
|
172
|
+
else
|
173
|
+
nil
|
174
|
+
end
|
175
|
+
end
|
176
|
+
|
177
|
+
# @return [String] computed checksum for normalized 'significant' data
|
178
|
+
def checksum
|
179
|
+
@checksum ||= compute_checksum
|
180
|
+
end
|
181
|
+
|
182
|
+
# @return [Hash] timeouts from effective configuration or empty
|
183
|
+
def timeouts
|
184
|
+
(@effective_route_config[@kind] || {})[TIMEOUTS_KEY] || {}
|
185
|
+
end
|
186
|
+
|
187
|
+
# Establishes a normal header key form for agreement between configuration
|
188
|
+
# and metadata pieces.
|
189
|
+
#
|
190
|
+
# @param [String|Symbol] key to normalize
|
191
|
+
#
|
192
|
+
# @return [String] normalized key
|
193
|
+
def self.normalize_header_key(key)
|
194
|
+
key.to_s.downcase.gsub('-', '_')
|
195
|
+
end
|
196
|
+
|
197
|
+
# @param [String] url to normalize
|
198
|
+
#
|
199
|
+
# @return [URI] uri with scheme inserted if necessary
|
200
|
+
def self.normalize_uri(url)
|
201
|
+
# the following logic is borrowed from RestClient::Request#parse_url
|
202
|
+
url = "http://#{url}" unless url.match(/^http/)
|
203
|
+
uri = ::URI.parse(url)
|
204
|
+
|
205
|
+
# need at least a (leading) forward-slash in path for any subsequent route
|
206
|
+
# matching.
|
207
|
+
uri.path = '/' if uri.path.empty?
|
208
|
+
|
209
|
+
# strip proxied server details not needed for playback.
|
210
|
+
# strip any basic authentication, which is never recorded.
|
211
|
+
uri = ::URI.parse(url)
|
212
|
+
uri.scheme = nil
|
213
|
+
uri.host = nil
|
214
|
+
uri.port = nil
|
215
|
+
uri.user = nil
|
216
|
+
uri.password = nil
|
217
|
+
uri
|
218
|
+
end
|
219
|
+
|
220
|
+
# Sorts data for a consistent appearance in JSON.
|
221
|
+
#
|
222
|
+
# HACK: replacement for ::RightSupport::Data::HashTools.deep_sorted_json
|
223
|
+
# method that can underflow the @state.depth field as -1 probably due to
|
224
|
+
# some (1.9.3+?) logic that resets the depth to zero when JSON data gets too
|
225
|
+
# deep or else @state.depth doesn't mean what it used to mean in Ruby 1.8.
|
226
|
+
# need to fix the utility...
|
227
|
+
#
|
228
|
+
# @param [Hash|Array] data to JSONize
|
229
|
+
#
|
230
|
+
# @return [String] sorted JSON
|
231
|
+
def self.deep_sorted_json(data, pretty = false)
|
232
|
+
data = deep_sorted_data(data)
|
233
|
+
pretty ? ::JSON.pretty_generate(data) : ::JSON.dump(data)
|
234
|
+
end
|
235
|
+
|
236
|
+
# Duplicates and sorts hash keys for a consistent appearance (in JSON).
|
237
|
+
# Traverses arrays to sort hash elements. Note this only works for Ruby 1.9+
|
238
|
+
# due to hashes now preserving insertion order.
|
239
|
+
#
|
240
|
+
# @param [Hash|Array] data to deep-sort
|
241
|
+
#
|
242
|
+
# @return [String] sorted data
|
243
|
+
def self.deep_sorted_data(data)
|
244
|
+
case data
|
245
|
+
when ::Hash
|
246
|
+
data = data.map { |k, v| [k.to_s, v] }.sort.inject({}) do |h, (k, v)|
|
247
|
+
h[k] = deep_sorted_data(v)
|
248
|
+
h
|
249
|
+
end
|
250
|
+
when Array
|
251
|
+
data.map { |e| deep_sorted_data(e) }
|
252
|
+
else
|
253
|
+
if data.respond_to?(:to_hash)
|
254
|
+
deep_sorted_data(data.to_hash)
|
255
|
+
else
|
256
|
+
data
|
257
|
+
end
|
258
|
+
end
|
259
|
+
end
|
260
|
+
|
261
|
+
protected
|
262
|
+
|
263
|
+
# @see RightDevelop::Testing::Client::RecordMetadata.normalize_header_key
|
264
|
+
def normalize_header_key(key)
|
265
|
+
self.class.normalize_header_key(key)
|
266
|
+
end
|
267
|
+
|
268
|
+
# Transforms all relevant request fields to data that can be matched by
|
269
|
+
# qualifiers or substituted with variables.
|
270
|
+
#
|
271
|
+
# @param [URI] uri for query string, etc.
|
272
|
+
# @param [Hash] normalized_headers for header information
|
273
|
+
# @param [String] body to parse
|
274
|
+
#
|
275
|
+
# @return [Mash] types to names to values
|
276
|
+
def compute_typenames_to_values
|
277
|
+
::Mash.new(
|
278
|
+
verb: @verb,
|
279
|
+
query: parse_query_string(@uri.query.to_s),
|
280
|
+
header: @headers,
|
281
|
+
body: parse_body(@headers, @body)
|
282
|
+
)
|
283
|
+
end
|
284
|
+
|
285
|
+
# Parses a query string (from URI or payload) to a mapping of parameter name
|
286
|
+
# to a mapping of parameter name to value(s). Parses nested queries using
|
287
|
+
# the "hash_name[key][subkey][]" notation (FIX: which is called what?).
|
288
|
+
#
|
289
|
+
# @param [String] query_string to parse
|
290
|
+
#
|
291
|
+
# @return [Hash] parsed query
|
292
|
+
def parse_query_string(query_string)
|
293
|
+
::Rack::Utils.parse_nested_query(query_string)
|
294
|
+
end
|
295
|
+
|
296
|
+
# @param [Hash] hash for query string
|
297
|
+
#
|
298
|
+
# @return [String] query string
|
299
|
+
def build_query_string(hash)
|
300
|
+
::Rack::Utils.build_nested_query(hash)
|
301
|
+
end
|
302
|
+
|
303
|
+
# Content-Type header can have other information (such as encoding) so look
|
304
|
+
# specifically for the 'application/blah' information.
|
305
|
+
#
|
306
|
+
# @param [Hash] normalized_headers for lookup
|
307
|
+
#
|
308
|
+
# @return [String] content type or nil
|
309
|
+
def compute_content_type(normalized_headers)
|
310
|
+
# content type may be an array or an array of strings needing to be split.
|
311
|
+
#
|
312
|
+
# example: ["application/json; charset=utf-8"]
|
313
|
+
content_type = normalized_headers['content_type']
|
314
|
+
content_type = Array(content_type).join(';').split(';').map { |ct| ct.strip }
|
315
|
+
last_seen = nil
|
316
|
+
content_type.each do |ct|
|
317
|
+
ct = ct.strip
|
318
|
+
return ct if ct.start_with?('application/')
|
319
|
+
end
|
320
|
+
nil
|
321
|
+
end
|
322
|
+
|
323
|
+
# Parses the body using content type.
|
324
|
+
#
|
325
|
+
# @param [Hash] normalized_headers for content type, etc.
|
326
|
+
#
|
327
|
+
# @return [Hash] body as a hash of name/value pairs or empty if not parsable
|
328
|
+
def parse_body(normalized_headers, body)
|
329
|
+
body = body.to_s
|
330
|
+
unless body.empty?
|
331
|
+
case compute_content_type(normalized_headers)
|
332
|
+
when nil
|
333
|
+
# do nothing
|
334
|
+
when 'application/x-www-form-urlencoded'
|
335
|
+
return parse_query_string(body)
|
336
|
+
when 'application/json'
|
337
|
+
return ::JSON.load(body)
|
338
|
+
else
|
339
|
+
# try-parse for other application/* content types to avoid having to
|
340
|
+
# specify anything more here. modern formats are JSONish and we
|
341
|
+
# currently don't care about XMLish formats.
|
342
|
+
begin
|
343
|
+
return ::JSON.load(body)
|
344
|
+
rescue ::JSON::ParserError
|
345
|
+
# ignored
|
346
|
+
end
|
347
|
+
end
|
348
|
+
end
|
349
|
+
nil
|
350
|
+
end
|
351
|
+
|
352
|
+
# Reformats body data in a normal form that involves sorted query-string or
|
353
|
+
# JSON format. The idea is that the same payload can appear many times with
|
354
|
+
# slightly different ordering of body elements and yet represent the same
|
355
|
+
# request or response.
|
356
|
+
#
|
357
|
+
# @return [String] normalized body
|
358
|
+
def normalize_body(normalized_headers, body)
|
359
|
+
case body
|
360
|
+
when nil
|
361
|
+
return body
|
362
|
+
when ::Hash, ::Array
|
363
|
+
body_hash = body
|
364
|
+
when ::String
|
365
|
+
body_hash = parse_body(normalized_headers, body)
|
366
|
+
return body unless body_hash
|
367
|
+
else
|
368
|
+
return body
|
369
|
+
end
|
370
|
+
case ct = compute_content_type(normalized_headers)
|
371
|
+
when 'application/x-www-form-urlencoded'
|
372
|
+
result = build_query_string(body_hash)
|
373
|
+
normalize_content_length(normalized_headers, result)
|
374
|
+
else
|
375
|
+
result = ::JSON.dump(body_hash)
|
376
|
+
normalize_content_length(normalized_headers, result)
|
377
|
+
end
|
378
|
+
result
|
379
|
+
end
|
380
|
+
|
381
|
+
# Updates content-length header for normalized body, if necessary.
|
382
|
+
def normalize_content_length(normalized_headers, normalized_body)
|
383
|
+
if normalized_headers['content_length']
|
384
|
+
normalized_headers['content_length'] = ::Rack::Utils.bytesize(normalized_body)
|
385
|
+
end
|
386
|
+
true
|
387
|
+
end
|
388
|
+
|
389
|
+
# Computes the effective route configuration for the current request, which
|
390
|
+
# may be an amalgam of several configurations found by URI wildcard.
|
391
|
+
#
|
392
|
+
# @param [URI] uri for request with some details omitted
|
393
|
+
# @param [Hash] typenames_to_values in form of { type => name [=> subkey]* => value }
|
394
|
+
#
|
395
|
+
# @return [Hash] effective route configuration
|
396
|
+
def compute_effective_route_config
|
397
|
+
result = ::Mash.new
|
398
|
+
if configuration_data = @route_data[MATCHERS_KEY]
|
399
|
+
# the top-level keys are expected to be regular expressions used to
|
400
|
+
# match only the URI path.
|
401
|
+
uri_qualified_data = []
|
402
|
+
configuration_data.each do |uri_regex, qualified_data|
|
403
|
+
if uri_regex.match(uri.path)
|
404
|
+
uri_qualified_data << qualified_data
|
405
|
+
end
|
406
|
+
end
|
407
|
+
|
408
|
+
# the next level attempts to match qualifiers, which could be empty.
|
409
|
+
# if all known qualifiers match then the configuration is applied.
|
410
|
+
uri_qualified_data.each do |qualified_data|
|
411
|
+
# the same URI can map to multiple sets of qualified configurations.
|
412
|
+
# the left-hand is a mapping of (typenames to required values) and
|
413
|
+
# the right-hand is the configuration to use when matched.
|
414
|
+
#
|
415
|
+
# note that .all? == true when .empty? == true
|
416
|
+
qualified_data.each do |qualifier_hash, configuration|
|
417
|
+
all_matched = qualifier_hash.all? do |qualifier_type, qualifier_name_to_value|
|
418
|
+
match_deep(@typenames_to_values[qualifier_type], qualifier_name_to_value)
|
419
|
+
end
|
420
|
+
if all_matched
|
421
|
+
# the final data is the union of all configurations matching
|
422
|
+
# this request path and qualifiers. the uri regex and other
|
423
|
+
# data used to match the request parameters is eliminated from
|
424
|
+
# the final configuration.
|
425
|
+
::RightSupport::Data::HashTools.deep_merge!(result, configuration)
|
426
|
+
end
|
427
|
+
end
|
428
|
+
end
|
429
|
+
end
|
430
|
+
result
|
431
|
+
end
|
432
|
+
|
433
|
+
# Partially matches the real data against a subset of known values.
|
434
|
+
#
|
435
|
+
# @param [Hash] target to match
|
436
|
+
# @param [Hash] source for lookup in real data
|
437
|
+
#
|
438
|
+
# @return [TrueClass|FalseClass] true if all matched, false if any differed
|
439
|
+
def match_deep(target, source)
|
440
|
+
case source
|
441
|
+
when ::Hash
|
442
|
+
source.all? do |k, v|
|
443
|
+
target_value = target[k]
|
444
|
+
if target_value.kind_of?(::Hash) && v.kind_of?(::Hash)
|
445
|
+
match_deep(target_value, v)
|
446
|
+
else
|
447
|
+
target_value.to_s == v.to_s
|
448
|
+
end
|
449
|
+
end
|
450
|
+
else
|
451
|
+
target == source
|
452
|
+
end
|
453
|
+
end
|
454
|
+
|
455
|
+
# Deep substitutes variables while capturing/substituting real data and/or
|
456
|
+
# obfuscates sensitive data.
|
457
|
+
#
|
458
|
+
# note that @variables is a flat hash of variable names (chosen by the user
|
459
|
+
# configuration) to literal values; the user is required to make unique or
|
460
|
+
# reuse these effectively 'global' variable names where appropriate. the are
|
461
|
+
# not constrained in any way so a convention such as
|
462
|
+
# 'my::namespace::variable_name' could be used.
|
463
|
+
#
|
464
|
+
# the literal values can be any JSON type, etc. the type of the value does
|
465
|
+
# not have to be string (even though we will record a placeholder string)
|
466
|
+
# because the value will be changed back to the original before playback
|
467
|
+
# responds.
|
468
|
+
#
|
469
|
+
# the value can change over time without a problem. we will keep all of the
|
470
|
+
# values by the same variable name in an array. the value that we insert
|
471
|
+
# into the recorded data will be the variable name plus the value array
|
472
|
+
# index in brackets ([]) if the index is non-zero. if the value keeps being
|
473
|
+
# changed to distinct values in an unbounded fashion then that would be an
|
474
|
+
# issue for recording because we have no bounds check here.
|
475
|
+
#
|
476
|
+
# example: {someFieldInBody:"my_variable_name[42]"}
|
477
|
+
#
|
478
|
+
# this allows
|
479
|
+
#
|
480
|
+
# @param [Hash] variables from current state
|
481
|
+
# @param [Hash] target to receive substituted names
|
482
|
+
# @param [Hash] source for variables to substitute
|
483
|
+
# @param [Hash] transform for data structure elements or nil
|
484
|
+
#
|
485
|
+
# @return [Hash] data with any replacements
|
486
|
+
def recursive_replace_variables(path, target, source, transform)
|
487
|
+
source.each do |k, variable|
|
488
|
+
unless (target_value = target[k]).nil?
|
489
|
+
|
490
|
+
# apply transform, if any, before attempting to replace variables.
|
491
|
+
case current_transform = transform && transform[k]
|
492
|
+
when ::String
|
493
|
+
case current_transform
|
494
|
+
when 'JSON'
|
495
|
+
target_value = ::JSON.parse(target_value)
|
496
|
+
else
|
497
|
+
raise RecordingError, "Unknown transform: #{current_transform}"
|
498
|
+
end
|
499
|
+
end
|
500
|
+
|
501
|
+
# variable replacement.
|
502
|
+
case variable
|
503
|
+
when nil
|
504
|
+
# non-captured hidden credential. same for request or response.
|
505
|
+
target_value = HIDDEN_CREDENTIAL_VALUE
|
506
|
+
when ::Array
|
507
|
+
# array should have a single element which should be a hash with
|
508
|
+
# futher variable declarations for
|
509
|
+
# "one ore more objects of the same type in an array."
|
510
|
+
# the array is only an indicator that an array is expected here.
|
511
|
+
variables_for_elements = variable.first
|
512
|
+
if variable.size != 1 || !variables_for_elements.kind_of?(::Hash)
|
513
|
+
message = 'Invalid variable specification has an array but does '+
|
514
|
+
'not have exactly one element that is a hash at ' +
|
515
|
+
"#{(path + [k]).join('/').inspect}"
|
516
|
+
raise RecordingError, message
|
517
|
+
end
|
518
|
+
|
519
|
+
transform_for_elements = nil
|
520
|
+
if current_transform
|
521
|
+
if current_transform.kind_of?(::Array) &&
|
522
|
+
current_transform.size == 1 &&
|
523
|
+
current_transform.first.kind_of?(::Hash)
|
524
|
+
transform_for_elements = current_transform.first
|
525
|
+
else
|
526
|
+
message = 'Invalid transform specification does not match ' +
|
527
|
+
'array variable specification at ' +
|
528
|
+
(path + [k]).join('/').inspect
|
529
|
+
raise RecordingError, message
|
530
|
+
end
|
531
|
+
end
|
532
|
+
if target_value.kind_of?(::Array)
|
533
|
+
target_value.each_with_index do |item, index|
|
534
|
+
recursive_replace_variables(
|
535
|
+
path + [k, index],
|
536
|
+
item,
|
537
|
+
variables_for_elements,
|
538
|
+
transform_for_elements)
|
539
|
+
end
|
540
|
+
end
|
541
|
+
when ::Hash
|
542
|
+
# ignore if target is not a hash; allow a root config to try and
|
543
|
+
# replace variables without knowing exact schema for each request.
|
544
|
+
if target_value.kind_of?(::Hash)
|
545
|
+
transform_for_subhash = current_transform.kind_of?(::Hash) ? current_transform : nil
|
546
|
+
recursive_replace_variables(
|
547
|
+
path + [k],
|
548
|
+
target_value,
|
549
|
+
variable,
|
550
|
+
transform_for_subhash)
|
551
|
+
end
|
552
|
+
when ::String
|
553
|
+
case @kind
|
554
|
+
when 'request'
|
555
|
+
# record request changes real data to variable name after
|
556
|
+
# caching the real value.
|
557
|
+
# playback request is parsed only in order to cache the variable
|
558
|
+
# value; variable name will not appear anywhere in playback.
|
559
|
+
target_value = variable_to_cache(variable, target_value)
|
560
|
+
when 'response'
|
561
|
+
case @mode
|
562
|
+
when 'record'
|
563
|
+
# value must exist (from some previous request) for the response
|
564
|
+
# to be able to reference it.
|
565
|
+
target_value = variable_in_cache(path, variable, target_value)
|
566
|
+
when 'playback'
|
567
|
+
# playback response uses cached variable value from some
|
568
|
+
# previous request.
|
569
|
+
target_value = variable_from_cache(path, variable, target_value)
|
570
|
+
else
|
571
|
+
fail "Unexpected mode: #{@mode.inspect}"
|
572
|
+
end
|
573
|
+
else
|
574
|
+
fail "Unexpected kind: #{@kind.inspect}"
|
575
|
+
end
|
576
|
+
else
|
577
|
+
# a nil target_value would mean the data did not have a placeholder
|
578
|
+
# for the value to subtitute, which is ignorable.
|
579
|
+
# what is not ignorable, however, is having an unexpected type here.
|
580
|
+
message = 'Unexpected variable entry at ' +
|
581
|
+
"#{(path + [k]).join('/').inspect}"
|
582
|
+
raise RecordingError, message
|
583
|
+
end
|
584
|
+
|
585
|
+
# reverse transform, if any, before reassignment.
|
586
|
+
case current_transform
|
587
|
+
when 'JSON'
|
588
|
+
target_value = ::JSON.dump(target_value)
|
589
|
+
end
|
590
|
+
target[k] = target_value
|
591
|
+
end
|
592
|
+
end
|
593
|
+
target
|
594
|
+
end
|
595
|
+
|
596
|
+
# Inserts (or reuses) a real value into cached array by variable name.
|
597
|
+
def variable_to_cache(variable, real_value)
|
598
|
+
result = nil
|
599
|
+
if values = @variables[variable]
|
600
|
+
# quick out for same as initial value; don't show array index.
|
601
|
+
if values.first == real_value
|
602
|
+
result = variable
|
603
|
+
else
|
604
|
+
# show zero-based array index beyond the zero index.
|
605
|
+
unless value_index = values.index(real_value)
|
606
|
+
value_index = values.size
|
607
|
+
values << real_value
|
608
|
+
end
|
609
|
+
result = "#{variable}[#{value_index}]"
|
610
|
+
end
|
611
|
+
else
|
612
|
+
# new variable, quick out.
|
613
|
+
@variables[variable] = [real_value]
|
614
|
+
result = variable
|
615
|
+
end
|
616
|
+
result
|
617
|
+
end
|
618
|
+
|
619
|
+
# Requires a real value to already exist in cache by variable name.
|
620
|
+
def variable_in_cache(path, variable, real_value)
|
621
|
+
result = nil
|
622
|
+
values = @variables[variable]
|
623
|
+
case value_index = values && values.index(real_value)
|
624
|
+
when nil
|
625
|
+
message = 'A variable referenced by a response has not yet been ' +
|
626
|
+
"defined by a request while replacing variable = " +
|
627
|
+
"#{variable.inspect} at #{path.join('/').inspect}"
|
628
|
+
raise RecordingError, message
|
629
|
+
when 0
|
630
|
+
variable
|
631
|
+
else
|
632
|
+
"#{variable}[#{value_index}]"
|
633
|
+
end
|
634
|
+
end
|
635
|
+
|
636
|
+
# Attempts to get cached variable value by index from recorded string.
|
637
|
+
def variable_from_cache(path, variable, target_value)
|
638
|
+
result = nil
|
639
|
+
if variable_array = @variables[variable]
|
640
|
+
if matched = VARIABLE_INDEX_REGEX.match(target_value)
|
641
|
+
variable_array_index = Integer(matched[1])
|
642
|
+
else
|
643
|
+
variable_array_index = 0
|
644
|
+
end
|
645
|
+
if variable_array_index >= variable_array.size
|
646
|
+
# see below.
|
647
|
+
throw(
|
648
|
+
HALT,
|
649
|
+
MissingVariableFailure.new(
|
650
|
+
path: path,
|
651
|
+
variable: variable,
|
652
|
+
variable_array_index: variable_array_index,
|
653
|
+
variable_array_size: variable_array.size))
|
654
|
+
end
|
655
|
+
result = variable_array[variable_array_index]
|
656
|
+
else
|
657
|
+
# this might be caused by a race condition where the request that
|
658
|
+
# expects the variable to be set is made on a thread that runs faster
|
659
|
+
# than the thread making the API call that defines the variable. if so
|
660
|
+
# then the race should resolve itself after a few retries. if the
|
661
|
+
# variable is never defined then that can be handled later.
|
662
|
+
# unfortunately, all of the metadata has to be recreated after the state
|
663
|
+
# has changed and there is no way to determine this condition exists
|
664
|
+
# without performing that work.
|
665
|
+
throw(
|
666
|
+
HALT,
|
667
|
+
MissingVariableFailure.new(
|
668
|
+
path: path,
|
669
|
+
variable: variable))
|
670
|
+
end
|
671
|
+
result
|
672
|
+
end
|
673
|
+
|
674
|
+
# normalizes header keys, removes some unwanted headers and obfuscates any
|
675
|
+
# cookies.
|
676
|
+
def normalize_headers(headers)
|
677
|
+
result = headers.inject({}) do |h, (k, v)|
|
678
|
+
# value is in raw form as array of sequential header values
|
679
|
+
h[normalize_header_key(k)] = v
|
680
|
+
h
|
681
|
+
end
|
682
|
+
|
683
|
+
# eliminate headers that interfere with playback or don't make sense to
|
684
|
+
# record.
|
685
|
+
%w(
|
686
|
+
connection status host user_agent content_encoding
|
687
|
+
).each { |key| result.delete(key) }
|
688
|
+
|
689
|
+
# always obfuscate cookie headers as they won't be needed for playback and
|
690
|
+
# would be non-trivial to configure for each service.
|
691
|
+
%w(cookie set_cookie).each do |k|
|
692
|
+
if cookies = result[k]
|
693
|
+
if cookies.is_a?(::String)
|
694
|
+
cookies = cookies.split(';').map { |c| c.strip }
|
695
|
+
end
|
696
|
+
result[k] = cookies.map do |cookie|
|
697
|
+
if offset = cookie.index('=')
|
698
|
+
cookie_name = cookie[0..(offset-1)]
|
699
|
+
"#{cookie_name}=#{HIDDEN_CREDENTIAL_VALUE}"
|
700
|
+
else
|
701
|
+
cookie
|
702
|
+
end
|
703
|
+
end
|
704
|
+
end
|
705
|
+
end
|
706
|
+
result
|
707
|
+
end
|
708
|
+
|
709
|
+
# determines which values are significant to checksum. by default the verb
|
710
|
+
# query and body are significant but not headers. if the caller specifies
|
711
|
+
# any of them (except verb and http_status) then that overrides default.
|
712
|
+
def compute_checksum
|
713
|
+
# some things are significant by default but can be overridden by config.
|
714
|
+
significant =
|
715
|
+
(@effective_route_config[@kind] &&
|
716
|
+
@effective_route_config[@kind][SIGNIFICANT_KEY]) ||
|
717
|
+
{}
|
718
|
+
|
719
|
+
# verb and (response-only) http_status are always significant.
|
720
|
+
significant_data = ::Mash.new(verb: @verb)
|
721
|
+
significant_data[:http_status] = @http_status if @http_status
|
722
|
+
|
723
|
+
# headers
|
724
|
+
copy_if_significant(:header, significant, significant_data)
|
725
|
+
|
726
|
+
# query
|
727
|
+
unless copy_if_significant(:query, significant, significant_data)
|
728
|
+
# entire query string is significant by default.
|
729
|
+
significant_data[:query] = @typenames_to_values[:query]
|
730
|
+
end
|
731
|
+
|
732
|
+
# body
|
733
|
+
unless copy_if_significant(:body, significant, significant_data)
|
734
|
+
case body_value = @typenames_to_values[:body]
|
735
|
+
when nil
|
736
|
+
# body is either nil, empty or was not parsable; insert the checksum
|
737
|
+
# of the original body.
|
738
|
+
case @body
|
739
|
+
when nil, '', ' '
|
740
|
+
significant_data[:body_checksum] = 'empty'
|
741
|
+
else
|
742
|
+
significant_data[:body_checksum] = ::Digest::MD5.hexdigest(@body)
|
743
|
+
end
|
744
|
+
else
|
745
|
+
# body was parsed but no single element was considered significant.
|
746
|
+
# use the parsed body so that it can be 'normalized' in sorted order.
|
747
|
+
significant_data[:body] = body_value
|
748
|
+
end
|
749
|
+
end
|
750
|
+
|
751
|
+
# use deep-sorted JSON to prevent random ordering changing the checksum.
|
752
|
+
checksum_data = self.class.deep_sorted_json(significant_data)
|
753
|
+
if logger.debug? && @mode != 'echo'
|
754
|
+
logger.debug("#{@kind} checksum_data = #{checksum_data.inspect}")
|
755
|
+
end
|
756
|
+
::Digest::MD5.hexdigest(checksum_data)
|
757
|
+
end
|
758
|
+
|
759
|
+
# Performs a selective copy of any significant fields (recursively) or else
|
760
|
+
# does nothing. Significance does not require the field to exist in the
|
761
|
+
# known fields; a missing field is still significant (value = nil).
|
762
|
+
#
|
763
|
+
# @param [String|Symbol] type of significance
|
764
|
+
# @param [Hash] significant selectors
|
765
|
+
# @param [Hash] significant_data to populate
|
766
|
+
#
|
767
|
+
# @return [TrueClass|FalseClass] true if any were significant
|
768
|
+
def copy_if_significant(type, significant, significant_data)
|
769
|
+
if significant_type = significant[type]
|
770
|
+
significant_data[type] = recursive_selective_hash_copy(
|
771
|
+
::Mash.new, @typenames_to_values[type], significant_type)
|
772
|
+
true
|
773
|
+
else
|
774
|
+
false
|
775
|
+
end
|
776
|
+
end
|
777
|
+
|
778
|
+
# Recursively selects and copies values from source to target.
|
779
|
+
def recursive_selective_hash_copy(target, source, selections, path = [])
|
780
|
+
selections.each do |k, v|
|
781
|
+
case v
|
782
|
+
when nil
|
783
|
+
# hash to nil; user configured by using flat hashes instead of arrays.
|
784
|
+
# it's a style thing that makes the YAML look prettier.
|
785
|
+
copy_hash_value(target, source, path + [k])
|
786
|
+
when ::Array
|
787
|
+
# also supporting arrays of names at top level or under a hash.
|
788
|
+
v.each { |item| copy_hash_value(target, source, path + [item]) }
|
789
|
+
when ::Hash
|
790
|
+
# recursion.
|
791
|
+
recursive_selective_hash_copy(target, source, v, path + [k])
|
792
|
+
end
|
793
|
+
end
|
794
|
+
target
|
795
|
+
end
|
796
|
+
|
797
|
+
# copies a single value between hashes by path.
|
798
|
+
def copy_hash_value(target, source, path)
|
799
|
+
value = ::RightSupport::Data::HashTools.deep_get(source, path)
|
800
|
+
::RightSupport::Data::HashTools.deep_set!(target, path, value)
|
801
|
+
true
|
802
|
+
end
|
803
|
+
|
804
|
+
end # Metadata
|
805
|
+
end # RightDevelop::Testing::Recording
|