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.
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