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,33 @@
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'
25
+
26
+ module RightDevelop
27
+ module Testing
28
+ module Recording
29
+ autoload :Config, 'right_develop/testing/recording/config'
30
+ autoload :Metadata, 'right_develop/testing/recording/metadata'
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,571 @@
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 'extlib'
26
+ require 'json'
27
+ require 'logger'
28
+ require 'rack/utils'
29
+ require 'uri'
30
+ require 'yaml'
31
+
32
+ module RightDevelop::Testing::Recording
33
+
34
+ # Config file format.
35
+ class Config
36
+
37
+ # default relative directories.
38
+ FIXTURES_DIR_NAME = 'fixtures'.freeze
39
+ LOG_DIR_NAME = 'log'.freeze
40
+
41
+ # the empty key is used as a stop traversal signal because any literal
42
+ # value would be ambiguous.
43
+ STOP_TRAVERSAL_KEY = ''.freeze
44
+
45
+ VALID_MODES = ::Mash.new(
46
+ :echo => 'Echoes request back as response and validates route.',
47
+ :playback => 'Playback a session for one or more stubbed web services.',
48
+ :record => 'Record a session for one or more proxied web services.'
49
+ ).freeze
50
+
51
+ # keys allowed under the deep route configuration.
52
+ ALLOWED_KINDS = %w(request response)
53
+ ALLOWED_CONFIG_ACTIONS = %w(significant timeouts transform variables)
54
+ ALLOWED_TIMEOUTS = %w(open_timeout read_timeout)
55
+ ALLOWED_VARIABLE_TYPES = %w(body header query)
56
+
57
+ # metadata.
58
+ METADATA_CLASS = ::RightDevelop::Testing::Recording::Metadata
59
+
60
+ # patterns for fixture files.
61
+ FIXTURE_FILE_NAME_REGEX = /^([0-9A-Fa-f]{32}).yml$/
62
+
63
+ # typename to value expression for significant/requests/responses configurations.
64
+ TYPE_NAME_VALUE_REGEX = /^(body|header|query|verb)(?:\:([^=]+))?=(.*)$/
65
+
66
+ # exceptions.
67
+ class ConfigError < StandardError; end
68
+
69
+ def initialize(config_hash, options = nil)
70
+ # defaults.
71
+ current_dir = ::Dir.pwd
72
+ defaults = ::Mash.new(
73
+ 'mode' => :playback,
74
+ 'routes' => {},
75
+ 'fixtures_dir' => ::File.expand_path(FIXTURES_DIR_NAME, current_dir),
76
+ 'log_level' => :info,
77
+ 'log_dir' => ::File.expand_path(LOG_DIR_NAME, current_dir),
78
+ 'throttle' => 0,
79
+ )
80
+ unless config_hash.kind_of?(::Hash)
81
+ raise ConfigError, 'config_hash must be a hash'
82
+ end
83
+
84
+ # shallow merge of hash because the defaults are a shallow hash. deep mash
85
+ # of caller's config to deep clone and normalize keys.
86
+ config_hash = defaults.merge(deep_mash(config_hash))
87
+ if options
88
+ # another deep merge of any additional options.
89
+ ::RightSupport::Data::HashTools.deep_merge!(config_hash, options)
90
+ end
91
+
92
+ @config_hash = ::Mash.new
93
+ mode(config_hash['mode'])
94
+ routes(config_hash['routes'])
95
+ log_dir(config_hash['log_dir'])
96
+ log_level(config_hash['log_level'])
97
+ fixtures_dir(config_hash['fixtures_dir'])
98
+ throttle(config_hash['throttle'])
99
+ end
100
+
101
+ # @return [String] raw hash representing complete configuration
102
+ def to_hash
103
+ # unmash to hash
104
+ ::JSON.load(@config_hash.to_json)
105
+ end
106
+
107
+ # @return [String] location of fixtures used for record/playback
108
+ def fixtures_dir(value = nil)
109
+ @config_hash['fixtures_dir'] = value if value
110
+ @config_hash['fixtures_dir']
111
+ end
112
+
113
+ def mode(value = nil)
114
+ if value
115
+ value = value.to_s
116
+ if value.empty?
117
+ raise ConfigError, "#{MODE_ENV_VAR} must be set"
118
+ elsif VALID_MODES.has_key?(value)
119
+ @config_hash['mode'] = value.to_sym
120
+ else
121
+ raise ConfigError, "mode must be one of #{VALID_MODES.keys.sort.inspect}: #{value.inspect}"
122
+ end
123
+ end
124
+ @config_hash['mode']
125
+ end
126
+
127
+ def routes(value = nil)
128
+ if value
129
+ case value
130
+ when Hash
131
+ # normalize routes for efficient usage but keep them separate from
132
+ # user's config so that .to_hash returns something understandable and
133
+ # JSONizable/YAMLable.
134
+ @normalized_routes = value.inject(::Mash.new) do |r, (k, v)|
135
+ r[normalize_route_prefix(k)] = normalize_route_data(k, v)
136
+ r
137
+ end
138
+ @config_hash['routes'] = ::RightSupport::Data::HashTools.deep_clone(value)
139
+ else
140
+ raise ConfigError, 'routes must be a hash'
141
+ end
142
+ end
143
+ @normalized_routes
144
+ end
145
+
146
+ def log_level(value = nil)
147
+ if value
148
+ case value
149
+ when Integer
150
+ if value < ::Logger::DEBUG || value >= ::Logger::UNKNOWN
151
+ raise ConfigError, "log_level is out of range: #{value}"
152
+ end
153
+ @config_hash['log_level'] = value
154
+ when String, Symbol
155
+ @config_hash['log_level'] = ::Logger.const_get(value.to_s.upcase)
156
+ else
157
+ raise ConfigError, "log_level is unexpected type: #{log_level}"
158
+ end
159
+ end
160
+ @config_hash['log_level']
161
+ end
162
+
163
+ def log_dir(value = nil)
164
+ @config_hash['log_dir'] = value if value
165
+ @config_hash['log_dir']
166
+ end
167
+
168
+ def throttle(value = nil)
169
+ if value
170
+ value = Integer(value)
171
+ if value < 0 || value > 100
172
+ raise ConfigError, "throttle is out of range: #{value}"
173
+ end
174
+ @config_hash['throttle'] = value
175
+ end
176
+ @config_hash['throttle']
177
+ end
178
+
179
+ # Loads the config from given path or a relative location.
180
+ #
181
+ # @param [String] path to configuration
182
+ # @param [Hash] options to merge after loading config hash
183
+ #
184
+ # @return [Config] configuration object
185
+ #
186
+ # @raise [ArgumentError] on failure to load
187
+ def self.from_file(path, options = nil)
188
+ # load
189
+ unless ::File.file?(path)
190
+ raise ConfigError, "Missing expected configuration file: #{path.inspect}"
191
+ end
192
+ config_hash = deep_mash(::YAML.load_file(path))
193
+
194
+ # enumerate routes looking for any route-specific config data to load
195
+ # into the config hash from .yml files in subdirectories. this allows
196
+ # the user to spread configuration of specific requests/responses out in
197
+ # the file system instead of having a single monster config .yml
198
+ extension = '.yml'
199
+ (config_hash[:routes] || {}).each do |route_path, route_data|
200
+ if subdir = route_data[:subdir]
201
+ route_subdir = ::File.expand_path(::File.join(path, '..', subdir))
202
+ ::Dir[::File.join(route_subdir, "**/*#{extension}")].each do |route_config_path|
203
+ route_config_data = ::Mash.new(::YAML.load_file(route_config_path))
204
+ filename = ::File.basename(route_config_path)[0..-(extension.length + 1)]
205
+ hash_path = ::File.dirname(route_config_path)[(route_subdir.length + 1)..-1].split('/')
206
+ unless current_route_data = ::RightSupport::Data::HashTools.deep_get(route_data, hash_path)
207
+ current_route_data = ::Mash.new
208
+ ::RightSupport::Data::HashTools.deep_set!(route_data, hash_path, current_route_data)
209
+ end
210
+
211
+ # inject a 'stop' at the point where the sub-config file data was
212
+ # inserted into the big hash. the 'stop' basically distingishes the
213
+ # 'directory' from the 'file' information because the hash doesn't
214
+ # use classes to distinguish the data it contains; it only uses
215
+ # simple types. use of simple types makes it easy to YAMLize or
216
+ # JSONize or otherwise serialize in round-trip fashion.
217
+ merge_data = { filename => { STOP_TRAVERSAL_KEY => route_config_data } }
218
+ ::RightSupport::Data::HashTools.deep_merge!(current_route_data, merge_data)
219
+ end
220
+ end
221
+ end
222
+
223
+ # config
224
+ self.new(config_hash, options)
225
+ end
226
+
227
+ # Deeply mashes and duplicates (clones) a hash containing other hashes or
228
+ # arrays of hashes but not other types.
229
+ #
230
+ # Note that Mash.new(my_mash) will convert child hashes to mashes but not
231
+ # with the guarantee of cloning and detaching the deep mash. In other words.
232
+ # if any part of the hash is already a mash then it is not cloned by
233
+ # invoking Mash.new()
234
+ #
235
+ # FIX: put this in HashTools ?
236
+ #
237
+ # @return [Object] depends on input type
238
+ def self.deep_mash(any)
239
+ case any
240
+ when Array
241
+ # traverse arrays
242
+ any.map { |i| deep_mash(i) }
243
+ when Hash
244
+ # mash the hash
245
+ any.inject(::Mash.new) do |m, (k, v)|
246
+ m[k] = deep_mash(v)
247
+ m
248
+ end
249
+ else
250
+ any # whatever
251
+ end
252
+ end
253
+
254
+ protected
255
+
256
+ # @see RightDevelop::Testing::Client::RecordMetadata.normalize_header_key
257
+ def normalize_header_key(key)
258
+ METADATA_CLASS.normalize_header_key(key)
259
+ end
260
+
261
+ # @see Config.deep_mash
262
+ def deep_mash(any)
263
+ self.class.deep_mash(any)
264
+ end
265
+
266
+ def normalize_route_prefix(prefix)
267
+ prefix = prefix.to_s
268
+ unless prefix.end_with?('/')
269
+ prefix += '/'
270
+ end
271
+ prefix
272
+ end
273
+
274
+ def normalize_route_data(route_path, route_data)
275
+ position = ['routes', "#{route_path} (#{route_data[:subdir].inspect})"]
276
+ case route_data
277
+ when Hash
278
+ route_data = deep_mash(route_data) # deep clone and mash
279
+ case mode
280
+ when :record
281
+ uri = nil
282
+ begin
283
+ uri = ::URI.parse(route_data[:url])
284
+ rescue URI::InvalidURIError
285
+ # defer handling
286
+ end
287
+ unless uri && uri.scheme && uri.host
288
+ raise ConfigError, "#{position_string(position, 'url')} must be a valid HTTP(S) URL: #{route_data.inspect}"
289
+ end
290
+ unless uri.path.to_s.empty? && uri.query.to_s.empty?
291
+ raise ConfigError, "#{position_string(position, 'url')} has unexpected path or query string: #{route_data.inspect}"
292
+ end
293
+ end
294
+ subdir = route_data[:subdir]
295
+ if subdir.nil? || subdir.empty?
296
+ raise ConfigError, "#{position_string(position, 'subdir')} is required: #{route_data.inspect}"
297
+ end
298
+ if proxy_data = route_data[:proxy]
299
+ if header_data = proxy_data[:header]
300
+ if case_value = header_data[:case]
301
+ case case_value = case_value.to_s.to_sym
302
+ when :lower, :upper, :capitalize
303
+ header_data[:case] = case_value
304
+ else
305
+ raise ConfigError, "#{position_string(position, 'proxy/headers/case')} must be one of [lower, upper, capitalize]: #{route_data.inspect}"
306
+ end
307
+ end
308
+ if separator_value = header_data[:separator]
309
+ case separator_value = separator_value.to_s.to_sym
310
+ when :dash, :underscore
311
+ header_data[:separator] = separator_value
312
+ else
313
+ raise ConfigError, "#{position_string(position, 'proxy/headers/separator')} must be one of [dash, underscore]: #{route_data.inspect}"
314
+ end
315
+ end
316
+ end
317
+ end
318
+ matchers_key = METADATA_CLASS::MATCHERS_KEY
319
+ if matchers_data = route_data[matchers_key]
320
+ route_data[matchers_key] = normalize_route_configuration(
321
+ route_path,
322
+ position + [matchers_key],
323
+ matchers_data)
324
+ end
325
+ else
326
+ raise ConfigError, "route must be a hash: #{route_data.class}"
327
+ end
328
+ route_data
329
+ end
330
+
331
+ # Formats a displayable position string.
332
+ #
333
+ # @param [String] position as base
334
+ # @param [String|Array] subpath to join or nil
335
+ #
336
+ # @return [String] displayable config-root relative position string
337
+ def position_string(position, subpath = nil)
338
+ "might_config[#{(position + Array(subpath).join('/').split('/')).join('][')}]"
339
+ end
340
+
341
+ # Converts hierarchical hash of URI path fragments to sub-configurations to
342
+ # a flat hash of regular expressions matching URI to sub-configuration.
343
+ # Additional matchers such as verb or header are not included in the regex.
344
+ # this is intended to reduce how much searching is needed to find the
345
+ # configuration for a particular request/response. Ruby has no direct
346
+ # support for hashing by matching a regular expression to a value but the
347
+ # hash idiom is still useful here.
348
+ def normalize_route_configuration(uri_path, position, configuration_data)
349
+ uri_path = uri_path[1..-1] if uri_path.start_with?('/')
350
+ uri_path = uri_path.chomp('/').split('/')
351
+ recursive_traverse_uri(regex_to_data = {}, position, uri_path, configuration_data)
352
+ end
353
+
354
+ # Builds regular expressions from URI paths by recursively looking for
355
+ # either the 'stop' key or the start of matcher information. When the path
356
+ # for the URI is complete the wildcard 'file' path is converted to a regular
357
+ # expression and inserted into the regex_to_data hash. The data has a regex
358
+ # on the left-hand and a matcher data hash on the right-hand. The matcher
359
+ # data may have empty criteria if no matching beyond the URI path is needed.
360
+ def recursive_traverse_uri(regex_to_data, position, uri_path, data)
361
+ unless data.respond_to?(:has_key?)
362
+ message = "Expected a hash at #{position_string(position, uri_path)}; " +
363
+ "use the #{STOP_TRAVERSAL_KEY.inspect} key to stop traversal."
364
+ raise ConfigError, message
365
+ end
366
+
367
+ data.each do |k, v|
368
+ # stop key or 'type:name=value' qualifier stops URI path traversal.
369
+ k = k.to_s
370
+ if k == STOP_TRAVERSAL_KEY || k.index('=')
371
+ # create regular expression from uri_path elements up to this point.
372
+ # include a leading forward-slash (/) because uri.path will generally
373
+ # have it.
374
+ regex_string = '^/' + uri_path.map do |path_element|
375
+ case path_element
376
+ when '**'
377
+ '.*' # example: 'api/**'
378
+ else
379
+ # element may contain single wildcard character (*)
380
+ ::Regexp.escape(path_element).gsub("\\*", '[^/]*')
381
+ end
382
+ end.join('/') + '$'
383
+ regex = ::Regexp.compile(regex_string)
384
+
385
+ # URI path is the outermost qualifier, but there can be literal verb,
386
+ # header, query qualifiers as well. create another interesting map of
387
+ # (qualifier name to value) to (configuration data). the qualifiers
388
+ # can be hierarchical, of course, so we must traverse and flatten
389
+ # those also.
390
+ #
391
+ # examples of resulting uri regex to matcher data:
392
+ # request:
393
+ # /^api\/create$/ => { { 'verb' => 'POST', 'header' => { 'x_foo' => 'foo' } } => { 'body' => { 'name' => 'foo_name_variable' } } }
394
+ # response:
395
+ # { { 'verb' => 'GET', 'query' => { 'view' => 'full' } } => { 'body' => { 'name' => 'foo_name_variable' } } }
396
+ #
397
+ # FIX: don't think there is a need for wildcard qualifiers beyond URI
398
+ # path (i.e. wildcard matchers) so they are not currently supported.
399
+ qualifiers_to_data = regex_to_data[regex] ||= {}
400
+ current_qualifiers = ::Mash.new
401
+ if k == STOP_TRAVERSAL_KEY
402
+ # no qualifiers; stopped after URI path
403
+ qualifiers_to_data[current_qualifiers] = normalize_route_stop_configuration(position, uri_path + [k], v)
404
+ else
405
+ recursive_traverse_qualifiers(qualifiers_to_data, current_qualifiers, position, uri_path + [k], v)
406
+ end
407
+ else
408
+ # recursion
409
+ recursive_traverse_uri(regex_to_data, position, uri_path + [k], v)
410
+ end
411
+ end
412
+ regex_to_data
413
+ end
414
+
415
+ # Recursively builds one of more hash of qualifiers cumulatively with the
416
+ # parent qualifiers being included in child qualifier hashes. The completed
417
+ # qualifier hash is then inserted into the qualifiers_to_data hash.
418
+ def recursive_traverse_qualifiers(qualifiers_to_data, current_qualifiers, position, subpath, data)
419
+ unless data.kind_of?(::Hash)
420
+ message = "Expected a hash at #{position_string(position, subpath)}; " +
421
+ "use the #{STOP_TRAVERSAL_KEY.inspect} key to stop traversal."
422
+ raise ConfigError, message
423
+ end
424
+
425
+ # could be multiple qualifiers in a CGI-style string.
426
+ current_qualifiers = ::Mash.new(current_qualifiers)
427
+ more_qualifiers = ::Mash.new
428
+ ::CGI.unescape(subpath.last).split('&').each do |q|
429
+ if matched = TYPE_NAME_VALUE_REGEX.match(q)
430
+ case qualifier_type = matched[1]
431
+ when 'verb'
432
+ if matched[2]
433
+ message = "Verb qualifiers cannot have a name: " +
434
+ position_string(position, subpath)
435
+ raise ConfigError, message
436
+ end
437
+ verb = matched[3].upcase
438
+ unless METADATA_CLASS::VERBS.include?(verb)
439
+ message = "Unknown verb = #{verb}: " +
440
+ position_string(position, subpath + [k])
441
+ raise ConfigError, message
442
+ end
443
+ more_qualifiers[qualifier_type] = verb
444
+ else
445
+ unless matched[2]
446
+ message = "#{qualifier_type} qualifiers must have a name: " +
447
+ position_string(position, subpath)
448
+ raise ConfigError, message
449
+ end
450
+
451
+ # qualifier may be nested (for query string or body).
452
+ qualifier = ::Rack::Utils.parse_nested_query("#{matched[2]}=#{matched[3]}")
453
+
454
+ # names and values are case sensitive but header keys are usually
455
+ # not case-sensitive so convert the header keys to snake_case to
456
+ # match normalized headers from request.
457
+ if qualifier_type == 'header'
458
+ qualifier = qualifier.inject(::Mash.new) do |h, (k, v)|
459
+ h[normalize_header_key(k)] = v
460
+ h
461
+ end
462
+ end
463
+ ::RightSupport::Data::HashTools.deep_merge!(
464
+ more_qualifiers[qualifier_type] ||= ::Mash.new,
465
+ qualifier)
466
+ end
467
+ else
468
+ message = "Qualifier does not match expected pattern: " +
469
+ position_string(position, subpath)
470
+ raise ConfigError, message
471
+ end
472
+ end
473
+ ::RightSupport::Data::HashTools.deep_merge!(current_qualifiers, more_qualifiers)
474
+
475
+ data.each do |k, v|
476
+ # only the 'stop' key stops traversal now.
477
+ k = k.to_s
478
+ if k == STOP_TRAVERSAL_KEY
479
+ qualifiers_to_data[current_qualifiers] = normalize_route_stop_configuration(position, subpath, v)
480
+ else
481
+ # recursion
482
+ recursive_traverse_qualifiers(qualifiers_to_data, current_qualifiers, position, subpath + [k], v)
483
+ end
484
+ end
485
+ qualifiers_to_data
486
+ end
487
+
488
+ # Ensures that any header keys are normalized in deep stop configuration.
489
+ # Only header keys are normalized; other fields are case-sensitive and
490
+ # otherwise adhere to a standard specific to the API for field names.
491
+ def normalize_route_stop_configuration(position, subpath, route_stop_config)
492
+
493
+ # sanity check.
494
+ unwanted_keys = route_stop_config.keys.map(&:to_s) - ALLOWED_KINDS
495
+ unless unwanted_keys.empty?
496
+ message = 'The route configuration for route configuration at ' +
497
+ "#{position_string(position, subpath)} " +
498
+ "contained illegal kind specifiers = " +
499
+ "#{unwanted_keys.inspect}. Only #{ALLOWED_KINDS} are allowed."
500
+ raise ConfigError, message
501
+ end
502
+
503
+ route_stop_config.inject(::Mash.new) do |rst, (rst_k, rst_v)|
504
+
505
+ # sanity check.
506
+ unwanted_keys = rst_v.keys.map(&:to_s) - ALLOWED_CONFIG_ACTIONS
507
+ unless unwanted_keys.empty?
508
+ message = 'The route configuration for route configuration at ' +
509
+ "#{position_string(position, subpath + [rst_k])} " +
510
+ "contained illegal action specifiers = " +
511
+ "#{unwanted_keys.inspect}. Only #{ALLOWED_CONFIG_ACTIONS} are allowed."
512
+ raise ConfigError, message
513
+ end
514
+
515
+ rst[rst_k] = rst_v.inject(::Mash.new) do |kc, (kc_k, kc_v)|
516
+ case kc_k
517
+ when METADATA_CLASS::TIMEOUTS_KEY
518
+ # sanity check.
519
+ kc_v = kc_v.inject(::Mash.new) do |h, (k, v)|
520
+ h[k] = Integer(v)
521
+ h
522
+ end
523
+ unwanted_keys = kc_v.keys - ALLOWED_TIMEOUTS
524
+ unless unwanted_keys.empty?
525
+ message = 'The route configuration for timeouts at ' +
526
+ "#{position_string(position, subpath + [rst_k, kc_k])} " +
527
+ "contained illegal timeout specifiers = " +
528
+ "#{unwanted_keys.inspect}. Only #{ALLOWED_TIMEOUTS} are allowed."
529
+ raise ConfigError, message
530
+ end
531
+ else
532
+ # sanity check.
533
+ kc_v = deep_mash(kc_v)
534
+ unwanted_keys = kc_v.keys - ALLOWED_VARIABLE_TYPES
535
+ unless unwanted_keys.empty?
536
+ message = 'The route configuration for variables at ' +
537
+ "#{position_string(position, subpath + [rst_k, kc_k])} " +
538
+ "contained illegal variable specifiers = " +
539
+ "#{unwanted_keys.inspect}. Only #{ALLOWED_VARIABLE_TYPES} are allowed."
540
+ raise ConfigError, message
541
+ end
542
+
543
+ if headers = kc_v[:header]
544
+ case headers
545
+ when ::Array
546
+ # significant
547
+ kc_v[:header] = headers.inject([]) do |a, k|
548
+ a << normalize_header_key(k)
549
+ end
550
+ when ::Hash
551
+ # transform, variables
552
+ kc_v[:header] = headers.inject(::Mash.new) do |h, (k, v)|
553
+ h[normalize_header_key(k)] = v
554
+ h
555
+ end
556
+ else
557
+ message = "Expected an array at #{position_string(position, subpath + [rst_k, kc_k, :header])}; " +
558
+ "use the #{STOP_TRAVERSAL_KEY.inspect} key to stop traversal."
559
+ raise ConfigError, message
560
+ end
561
+ end
562
+ end
563
+ kc[kc_k] = kc_v
564
+ kc
565
+ end
566
+ rst
567
+ end
568
+ end
569
+
570
+ end # Config
571
+ end # RightDevelop::Testing::Recording