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.
Files changed (28) hide show
  1. data/VERSION +1 -1
  2. data/bin/right_develop +4 -0
  3. data/lib/right_develop.rb +1 -0
  4. data/lib/right_develop/commands.rb +2 -1
  5. data/lib/right_develop/commands/server.rb +194 -0
  6. data/lib/right_develop/testing.rb +31 -0
  7. data/lib/right_develop/testing/clients.rb +36 -0
  8. data/lib/right_develop/testing/clients/rest.rb +34 -0
  9. data/lib/right_develop/testing/clients/rest/requests.rb +38 -0
  10. data/lib/right_develop/testing/clients/rest/requests/base.rb +305 -0
  11. data/lib/right_develop/testing/clients/rest/requests/playback.rb +293 -0
  12. data/lib/right_develop/testing/clients/rest/requests/record.rb +175 -0
  13. data/lib/right_develop/testing/recording.rb +33 -0
  14. data/lib/right_develop/testing/recording/config.rb +571 -0
  15. data/lib/right_develop/testing/recording/metadata.rb +805 -0
  16. data/lib/right_develop/testing/servers/might_api/.gitignore +3 -0
  17. data/lib/right_develop/testing/servers/might_api/Gemfile +6 -0
  18. data/lib/right_develop/testing/servers/might_api/Gemfile.lock +18 -0
  19. data/lib/right_develop/testing/servers/might_api/app/base.rb +323 -0
  20. data/lib/right_develop/testing/servers/might_api/app/echo.rb +73 -0
  21. data/lib/right_develop/testing/servers/might_api/app/playback.rb +46 -0
  22. data/lib/right_develop/testing/servers/might_api/app/record.rb +45 -0
  23. data/lib/right_develop/testing/servers/might_api/config.ru +8 -0
  24. data/lib/right_develop/testing/servers/might_api/config/init.rb +47 -0
  25. data/lib/right_develop/testing/servers/might_api/lib/config.rb +204 -0
  26. data/lib/right_develop/testing/servers/might_api/lib/logger.rb +68 -0
  27. data/right_develop.gemspec +30 -2
  28. 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