right_develop 2.1.5 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- data/VERSION +1 -1
- data/bin/right_develop +4 -0
- data/lib/right_develop.rb +1 -0
- data/lib/right_develop/commands.rb +2 -1
- data/lib/right_develop/commands/server.rb +194 -0
- data/lib/right_develop/testing.rb +31 -0
- data/lib/right_develop/testing/clients.rb +36 -0
- data/lib/right_develop/testing/clients/rest.rb +34 -0
- data/lib/right_develop/testing/clients/rest/requests.rb +38 -0
- data/lib/right_develop/testing/clients/rest/requests/base.rb +305 -0
- data/lib/right_develop/testing/clients/rest/requests/playback.rb +293 -0
- data/lib/right_develop/testing/clients/rest/requests/record.rb +175 -0
- data/lib/right_develop/testing/recording.rb +33 -0
- data/lib/right_develop/testing/recording/config.rb +571 -0
- data/lib/right_develop/testing/recording/metadata.rb +805 -0
- data/lib/right_develop/testing/servers/might_api/.gitignore +3 -0
- data/lib/right_develop/testing/servers/might_api/Gemfile +6 -0
- data/lib/right_develop/testing/servers/might_api/Gemfile.lock +18 -0
- data/lib/right_develop/testing/servers/might_api/app/base.rb +323 -0
- data/lib/right_develop/testing/servers/might_api/app/echo.rb +73 -0
- data/lib/right_develop/testing/servers/might_api/app/playback.rb +46 -0
- data/lib/right_develop/testing/servers/might_api/app/record.rb +45 -0
- data/lib/right_develop/testing/servers/might_api/config.ru +8 -0
- data/lib/right_develop/testing/servers/might_api/config/init.rb +47 -0
- data/lib/right_develop/testing/servers/might_api/lib/config.rb +204 -0
- data/lib/right_develop/testing/servers/might_api/lib/logger.rb +68 -0
- data/right_develop.gemspec +30 -2
- metadata +84 -34
@@ -0,0 +1,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
|