right_develop 2.1.5 → 2.2.0
Sign up to get free protection for your applications and to get access to all the features.
- data/VERSION +1 -1
- data/bin/right_develop +4 -0
- data/lib/right_develop.rb +1 -0
- data/lib/right_develop/commands.rb +2 -1
- data/lib/right_develop/commands/server.rb +194 -0
- data/lib/right_develop/testing.rb +31 -0
- data/lib/right_develop/testing/clients.rb +36 -0
- data/lib/right_develop/testing/clients/rest.rb +34 -0
- data/lib/right_develop/testing/clients/rest/requests.rb +38 -0
- data/lib/right_develop/testing/clients/rest/requests/base.rb +305 -0
- data/lib/right_develop/testing/clients/rest/requests/playback.rb +293 -0
- data/lib/right_develop/testing/clients/rest/requests/record.rb +175 -0
- data/lib/right_develop/testing/recording.rb +33 -0
- data/lib/right_develop/testing/recording/config.rb +571 -0
- data/lib/right_develop/testing/recording/metadata.rb +805 -0
- data/lib/right_develop/testing/servers/might_api/.gitignore +3 -0
- data/lib/right_develop/testing/servers/might_api/Gemfile +6 -0
- data/lib/right_develop/testing/servers/might_api/Gemfile.lock +18 -0
- data/lib/right_develop/testing/servers/might_api/app/base.rb +323 -0
- data/lib/right_develop/testing/servers/might_api/app/echo.rb +73 -0
- data/lib/right_develop/testing/servers/might_api/app/playback.rb +46 -0
- data/lib/right_develop/testing/servers/might_api/app/record.rb +45 -0
- data/lib/right_develop/testing/servers/might_api/config.ru +8 -0
- data/lib/right_develop/testing/servers/might_api/config/init.rb +47 -0
- data/lib/right_develop/testing/servers/might_api/lib/config.rb +204 -0
- data/lib/right_develop/testing/servers/might_api/lib/logger.rb +68 -0
- data/right_develop.gemspec +30 -2
- metadata +84 -34
@@ -0,0 +1,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
|