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,3 @@
1
+ config/might_deploy.yml
2
+ fixtures/
3
+ *.log
@@ -0,0 +1,6 @@
1
+ source 'https://rubygems.org'
2
+
3
+ gem 'extlib'
4
+ gem 'rack'
5
+ gem 'rest-client'
6
+ gem 'right_support'
@@ -0,0 +1,18 @@
1
+ GEM
2
+ remote: https://rubygems.org/
3
+ specs:
4
+ extlib (0.9.16)
5
+ mime-types (2.2)
6
+ rack (1.5.2)
7
+ rest-client (1.6.7)
8
+ mime-types (>= 1.16)
9
+ right_support (2.8.20)
10
+
11
+ PLATFORMS
12
+ ruby
13
+
14
+ DEPENDENCIES
15
+ extlib
16
+ rack
17
+ rest-client
18
+ right_support
@@ -0,0 +1,323 @@
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
+ require ::File.expand_path('../../config/init', __FILE__)
24
+
25
+ require 'rack/chunked'
26
+ require 'stringio'
27
+ require 'uri'
28
+
29
+ module RightDevelop::Testing::Server::MightApi
30
+ module App
31
+ class Base
32
+
33
+ MAX_REDIRECTS = 10 # 500 after so many redirects
34
+
35
+ # exceptions
36
+ class MightError < StandardError; end
37
+ class MissingRoute < MightError; end
38
+
39
+ attr_reader :config, :logger, :state_file_path
40
+
41
+ def initialize(state_file_name)
42
+ @config = ::RightDevelop::Testing::Server::MightApi::Config
43
+ @logger = ::RightDevelop::Testing::Server::MightApi.logger
44
+
45
+ @state_file_path = state_file_name ? ::File.join(@config.fixtures_dir, state_file_name) : nil
46
+ end
47
+
48
+ def call(env)
49
+ env['rack.logger'] ||= logger
50
+
51
+ # read body from stream.
52
+ request = ::Rack::Request.new(env)
53
+ body = request.body.read
54
+
55
+ # proxy any headers from env starting with HTTP_
56
+ headers = env.inject({}) do |r, (k,v)|
57
+ # note that HTTP_HOST refers to this proxy server instead of the
58
+ # proxied target server. in the case of AWS authentication, it is
59
+ # necessary to pass the value through unmodified or else AWS auth
60
+ # fails.
61
+ if k.start_with?('HTTP_')
62
+ r[k[5..-1]] = v
63
+ end
64
+ r
65
+ end
66
+
67
+ # special cases.
68
+ ['ACCEPT', 'CONTENT_TYPE', 'CONTENT_LENGTH', 'USER_AGENT'].each do |key|
69
+ headers[key] = env[key] unless env[key].to_s.empty?
70
+ end
71
+
72
+ # handler
73
+ verb = request.request_method
74
+ uri = ::URI.parse(request.url)
75
+ handle_request(env, verb, uri, headers, body)
76
+ rescue MissingRoute => e
77
+ message = "#{e.class} #{e.message}"
78
+ logger.error(message)
79
+ if config.routes.empty?
80
+ logger.error("No routes configured.")
81
+ else
82
+ logger.error("The following routes are configured:")
83
+ config.routes.keys.each do |prefix|
84
+ logger.error(" #{prefix}...")
85
+ end
86
+ end
87
+
88
+ # not a 404 because this is a proxy/stub service and 40x might appear to
89
+ # have come from a proxied request/response whereas 500 is never an
90
+ # expected response.
91
+ internal_server_error(message)
92
+ rescue ::RightDevelop::Testing::Client::Rest::Request::Playback::PlaybackError => e
93
+ # response has not been recorded.
94
+ message = e.message
95
+ trace = [e.class.name] + (e.backtrace || [])
96
+ logger.error(message)
97
+ logger.debug(trace.join("\n"))
98
+ internal_server_error(message)
99
+ rescue ::Exception => e
100
+ message = "Unhandled exception: #{e.class} #{e.message}"
101
+ trace = e.backtrace || []
102
+ if logger
103
+ logger.error(message)
104
+ logger.debug(trace.join("\n"))
105
+ else
106
+ env['rack.errors'].puts(message)
107
+ env['rack.errors'].puts(trace.join("\n"))
108
+ end
109
+ internal_server_error(message)
110
+ end
111
+
112
+ # Handler.
113
+ #
114
+ # @param [Hash] env from rack
115
+ # @param [String] verb as one of ['GET', 'POST', etc.]
116
+ # @param [URI] uri parsed from full url
117
+ # @param [Hash] headers for proxy call with any non-proxy data omitted
118
+ # @param [String] body streamed from payload or empty
119
+ #
120
+ # @return [TrueClass] always true
121
+ def handle_request(env, verb, uri, headers, body)
122
+ raise ::NotImplementedError, 'Must be overridden'
123
+ end
124
+
125
+ # Makes a proxied API request using the given request class.
126
+ #
127
+ # @param [Class] request_class for API call
128
+ # @param [String] verb as one of ['GET', 'POST', etc.]
129
+ # @param [URI] uri parsed from full url
130
+ # @param [Hash] headers for proxy call with any non-proxy data omitted
131
+ # @param [String] body streamed from payload or empty
132
+ # @param [Integer] throttle for playback or nil
133
+ #
134
+ # @return [Array] rack-style tuple of [code, headers, [body]]
135
+ def proxy(request_class, verb, uri, headers, body, throttle = nil)
136
+
137
+ # check routes.
138
+ unless route = find_route(uri)
139
+ raise MissingRoute, "No route configured for #{uri.path.inspect}"
140
+ end
141
+ route_path, route_data = route
142
+ response = nil
143
+ max_redirects = MAX_REDIRECTS
144
+ while response.nil? do
145
+ request_proxy = nil
146
+ begin
147
+ proxied_url = ::File.join(route_data[:url], uri.path)
148
+ unless uri.query.to_s.empty?
149
+ proxied_url << '?' << uri.query
150
+ end
151
+ proxied_headers = proxy_headers(headers, route_data)
152
+
153
+ request_options = {
154
+ fixtures_dir: config.fixtures_dir,
155
+ logger: logger,
156
+ route_path: route_path,
157
+ route_data: route_data,
158
+ state_file_path: state_file_path,
159
+ method: verb.downcase.to_sym,
160
+ url: proxied_url,
161
+ headers: proxied_headers,
162
+ payload: body
163
+ }
164
+ request_options[:throttle] = throttle if throttle
165
+ request_proxy = request_class.new(request_options)
166
+
167
+ # log normalized data for obfuscation.
168
+ logger.debug("normalized request headers = #{request_proxy.request_metadata.headers.inspect}")
169
+ logger.debug("normalized request body:\n" << request_proxy.request_metadata.body)
170
+
171
+ request_proxy.execute do |rest_response, rest_request, net_http_response, &block|
172
+
173
+ # headers.
174
+ response_headers = normalize_rack_response_headers(net_http_response.to_hash)
175
+
176
+ # eliminate headers that interfere with response via proxy.
177
+ %w(
178
+ connection status content-encoding
179
+ ).each { |key| response_headers.delete(key) }
180
+
181
+ case response_code = Integer(rest_response.code)
182
+ when 301, 302, 307
183
+ raise RestClient::Exceptions::EXCEPTIONS_MAP[response_code].new(rest_response, response_code)
184
+ else
185
+ # special handling for chunked body.
186
+ if response_headers['transfer-encoding'] == 'chunked'
187
+ response_body = ::Rack::Chunked::Body.new([rest_response.body])
188
+ else
189
+ response_body = [rest_response.body]
190
+ end
191
+ response = [response_code, response_headers, response_body]
192
+ end
193
+ end
194
+
195
+ # log normalized data for obfuscation.
196
+ logger.debug("normalized response headers = #{request_proxy.response_metadata.headers.inspect}")
197
+ logger.debug("normalized response body:\n" << request_proxy.response_metadata.body.to_s)
198
+ rescue RestClient::RequestTimeout
199
+ net_http_response = request_proxy.handle_timeout
200
+ response_code = Integer(net_http_response.code)
201
+ response_headers = normalize_rack_response_headers(net_http_response.to_hash)
202
+ response_body = [net_http_response.body]
203
+ response = [response_code, response_headers, response_body]
204
+ rescue RestClient::Exception => e
205
+ case e.http_code
206
+ when 301, 302, 307
207
+ max_redirects -= 1
208
+ raise MightError.new('Exceeded max redirects') if max_redirects < 0
209
+ if location = e.response.headers[:location]
210
+ redirect_uri = ::URI.parse(location)
211
+ redirect_uri.path = ''
212
+ redirect_uri.query = nil
213
+ logger.debug("#{e.message} from #{route_data[:url]} to #{redirect_uri}")
214
+ route_data[:url] = redirect_uri.to_s
215
+
216
+ # move to end of FIFO queue for retry.
217
+ request_proxy.forget_outstanding_request
218
+ else
219
+ logger.debug("#{e.message} was missing expected location header.")
220
+ raise
221
+ end
222
+ else
223
+ raise
224
+ end
225
+ ensure
226
+ # remove from FIFO queue in case of any unhandled error.
227
+ request_proxy.forget_outstanding_request if request_proxy
228
+ end
229
+ end
230
+ response
231
+ end
232
+
233
+ # @param [URI] uri path to find
234
+ #
235
+ # @return [Array] pair of [prefix, data] or nil
236
+ def find_route(uri)
237
+ find_path = uri.path
238
+ logger.debug "Route URI path to match = #{find_path.inspect}"
239
+ config.routes.find do |prefix, data|
240
+ matched = find_path.start_with?(prefix)
241
+ logger.debug "Tried = #{prefix.inspect}, matched = #{matched}"
242
+ matched
243
+ end
244
+ end
245
+
246
+ # Sets the header style using configuration of the proxied service.
247
+ #
248
+ # @param [Hash] headers for proxy
249
+ # @param [Hash] route_data containing header configuration, if any
250
+ #
251
+ # @return [Mash] proxied headers
252
+ def proxy_headers(headers, route_data)
253
+ proxied = nil
254
+ if proxy_data = route_data[:proxy]
255
+ if header_data = proxy_data[:header]
256
+ to_separator = (header_data[:separator] == :underscore) ? '_' : '-'
257
+ from_separator = (to_separator == '-') ? '_' : '-'
258
+ proxied = headers.inject(::Mash.new) do |h, (k, v)|
259
+ k = k.to_s
260
+ case header_data[:case]
261
+ when nil
262
+ k = k.gsub(from_separator, to_separator)
263
+ when :lower
264
+ k = k.downcase.gsub(from_separator, to_separator)
265
+ when :upper
266
+ k = k.upcase.gsub(from_separator, to_separator)
267
+ when :capitalize
268
+ k = k.split(/-|_/).map { |word| word.capitalize }.join(to_separator)
269
+ else
270
+ raise ::ArgumentError,
271
+ "Unexpected header case: #{route_data.inspect}"
272
+ end
273
+ h[k] = v
274
+ h
275
+ end
276
+ end
277
+ end
278
+ proxied || ::Mash.new(headers)
279
+ end
280
+
281
+ # rack has a convention of newline-delimited header multi-values.
282
+ #
283
+ # HACK: changes underscore to dash to defeat RestClient::AbstractResponse
284
+ # line 27 (on client side) from failing to parse cookies array; it
285
+ # incorrectly calls .inject on the stringized form instead of using the
286
+ # raw array form or parsing the cookies into a hash, but only if the raw
287
+ # name is 'set_cookie' ('set-cookie' is okay).
288
+ #
289
+ # even wierder, on line 78 it assumes the raw name is 'set-cookie' and
290
+ # that works out for us here.
291
+ #
292
+ # @param [Hash] headers to normalize
293
+ #
294
+ # @return [Hash] normalized headers
295
+ def normalize_rack_response_headers(headers)
296
+ headers.inject({}) do |h, (k, v)|
297
+ h[k.to_s.gsub('_', '-').downcase] = v.join("\n")
298
+ h
299
+ end
300
+ end
301
+
302
+ # @return [Array] rack-style response for 500
303
+ def internal_server_error(message)
304
+ formal = <<EOF
305
+ MightAPI internal error
306
+
307
+ Problem:
308
+ #{message}
309
+ EOF
310
+
311
+ [
312
+ 500,
313
+ {
314
+ 'Content-Type' => 'text/plain',
315
+ 'Content-Length' => ::Rack::Utils.bytesize(formal).to_s
316
+ },
317
+ [formal]
318
+ ]
319
+ end
320
+
321
+ end
322
+ end
323
+ end
@@ -0,0 +1,73 @@
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
+ require ::File.expand_path('../base', __FILE__)
24
+
25
+ require 'right_support'
26
+
27
+ module RightDevelop::Testing::Server::MightApi::App
28
+
29
+ # Implements an echo service.
30
+ class Echo < ::RightDevelop::Testing::Server::MightApi::App::Base
31
+
32
+ # metadata
33
+ METADATA_CLASS = ::RightDevelop::Testing::Recording::Metadata
34
+
35
+ def initialize
36
+ super(nil)
37
+ end
38
+
39
+ # @see RightDevelop::Testing::Server::MightApi::App::Base#handle_request
40
+ def handle_request(env, verb, uri, headers, body)
41
+
42
+ # check routes.
43
+ response = ::Rack::Response.new
44
+ if route = find_route(uri)
45
+ response.write "URL matched #{route.last[:name].inspect}\n"
46
+ else
47
+ response.write "Failed to match any route. The following routes are valid:\n"
48
+ config.routes.keys.each do |prefix|
49
+ response.write "Prefix = #{prefix.inspect}\n\n"
50
+ end
51
+ end
52
+
53
+ # echo request back as response.
54
+ response.write "\nruby %sp%s\n\n" % [RUBY_VERSION, RUBY_PATCHLEVEL]
55
+ response.write "Raw configuration = #{::JSON.pretty_generate(config.to_hash)}\n\n" # hash order is significant in config
56
+ config.routes.each do |route_path, route_config|
57
+ response.write "=== Compiled route matchers begin for root = #{route_path.inspect}:\nmatchers = {\n"
58
+ (route_config[METADATA_CLASS::MATCHERS_KEY] || {}).each do |regex, route_data|
59
+ response.write " #{regex.inspect} =>\n #{route_data.inspect},\n"
60
+ end
61
+ response.write "}\n=== Compiled route matchers end.\n\n"
62
+ end
63
+ response.write "env = #{METADATA_CLASS.deep_sorted_json(env, true)}\n\n"
64
+ response.write "ENV = #{METADATA_CLASS.deep_sorted_json(::ENV, true)}\n\n"
65
+ response.write "verb = #{verb.inspect}\n\n"
66
+ response.write "uri = #{uri}\n\n"
67
+ response.write "headers = #{METADATA_CLASS.deep_sorted_json(headers, true)}\n\n"
68
+ response.write "body = #{body.inspect}\n\n"
69
+ response.finish
70
+ end
71
+
72
+ end
73
+ end
@@ -0,0 +1,46 @@
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
+ require ::File.expand_path('../base', __FILE__)
24
+
25
+ module RightDevelop::Testing::Server::MightApi::App
26
+ class Playback < ::RightDevelop::Testing::Server::MightApi::App::Base
27
+
28
+ STATE_FILE_NAME = 'playback_state.yml'
29
+
30
+ def initialize
31
+ super(STATE_FILE_NAME)
32
+ end
33
+
34
+ # @see RightDevelop::Testing::Server::MightApi::App::Base#handle_request
35
+ def handle_request(env, verb, uri, headers, body)
36
+ proxy(
37
+ ::RightDevelop::Testing::Client::Rest::Request::Playback,
38
+ verb,
39
+ uri,
40
+ headers,
41
+ body,
42
+ config.throttle)
43
+ end
44
+
45
+ end
46
+ end