right_develop 2.1.5 → 2.2.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|