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,293 @@
1
+ #
2
+ # Copyright (c) 2013 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/clients/rest'
25
+
26
+ require 'rest_client'
27
+
28
+ module RightDevelop::Testing::Client::Rest::Request
29
+
30
+ # Provides a middle-ware layer that intercepts transmition of the request and
31
+ # escapes out of the execute call with a stubbed response using throw/catch.
32
+ class Playback < ::RightDevelop::Testing::Client::Rest::Request::Base
33
+
34
+ HALT_TRANSMIT = :halt_transmit
35
+
36
+ # exceptions
37
+ class PlaybackError < StandardError; end
38
+
39
+ # fake Net::HTTPResponse
40
+ class FakeNetHttpResponse
41
+ attr_reader :code, :body, :elapsed_seconds, :call_count
42
+
43
+ def initialize(response_hash, response_metadata)
44
+ @elapsed_seconds = Integer(response_hash[:elapsed_seconds] || 0)
45
+ @code = response_metadata.http_status.to_s
46
+ @headers = response_metadata.headers.inject({}) do |h, (k, v)|
47
+ h[k] = Array(v) # expected to be an array
48
+ h
49
+ end
50
+ @body = response_metadata.body # optional
51
+ @call_count = Integer(response_hash[:call_count]) || 1
52
+ end
53
+
54
+ def [](key)
55
+ if header = @headers[key.downcase]
56
+ header.join(', ')
57
+ else
58
+ nil
59
+ end
60
+ end
61
+
62
+ def to_hash; @headers; end
63
+ end
64
+
65
+ attr_reader :throttle
66
+
67
+ def initialize(args)
68
+ if args[:throttle]
69
+ args = args.dup
70
+ @throttle = Integer(args.delete(:throttle))
71
+ if @throttle < 0 || @throttle > 100
72
+ raise ::ArgumentError, 'throttle must be a percentage between 0 and 100'
73
+ end
74
+ else
75
+ @throttle = 0
76
+ end
77
+ super(args)
78
+ end
79
+
80
+ # Overrides log_request to interrupt transmit before any connection is made.
81
+ #
82
+ # @raise [Symbol] always throws HALT_TRANSMIT
83
+ def log_request
84
+ super
85
+ throw(HALT_TRANSMIT, HALT_TRANSMIT)
86
+ end
87
+
88
+ RETRY_DELAY = 0.5
89
+ MAX_RETRIES = 100 # = 50 seconds; a socket usually times out in 60-120 seconds
90
+
91
+ # Overrides transmit to catch halt thrown by log_request.
92
+ #
93
+ # @param [URI[ uri of some kind
94
+ # @param [Net::HTTP] req of some kind
95
+ # @param [RestClient::Payload] of some kind
96
+ #
97
+ # @return
98
+ def transmit(uri, req, payload, &block)
99
+ caught = catch(HALT_TRANSMIT) { super }
100
+ if caught == HALT_TRANSMIT
101
+ response = nil
102
+ try_counter = 0
103
+ while response.nil?
104
+ with_state_lock do |state|
105
+ response = catch(METADATA_CLASS::HALT) do
106
+ fetch_response(state)
107
+ end
108
+ end
109
+ case response
110
+ when METADATA_CLASS::RetryableFailure
111
+ try_counter += 1
112
+ if try_counter >= MAX_RETRIES
113
+ message =
114
+ "Released thread id=#{::Thread.current.object_id} after " <<
115
+ "#{try_counter} attempts to satisfy a retryable condition:\n" <<
116
+ response.message
117
+ raise PlaybackError, message
118
+ end
119
+ if 1 == try_counter
120
+ message = "Blocking thread id=#{::Thread.current.object_id} " <<
121
+ 'until a retryable condition is satisfied...'
122
+ logger.debug(message)
123
+ end
124
+ response = nil
125
+ sleep RETRY_DELAY
126
+ else
127
+ if try_counter > 0
128
+ message = "Released thread id=#{::Thread.current.object_id} " <<
129
+ 'after a retryable condition was satisfied.'
130
+ logger.debug(message)
131
+ end
132
+ end
133
+ end
134
+
135
+ # delay, if throttled, to simulate server response time.
136
+ if @throttle > 0 && response.elapsed_seconds > 0
137
+ delay = (Float(response.elapsed_seconds) * @throttle) / 100.0
138
+ logger.debug("throttle delay = #{delay}")
139
+ sleep delay
140
+ end
141
+ log_response(response)
142
+ process_result(response, &block)
143
+ else
144
+ raise PlaybackError,
145
+ 'Unexpected RestClient::Request#transmit returned without calling RestClient::Request#log_request'
146
+ end
147
+ end
148
+
149
+ # @see RightDevelop::Testing::Client::Rest::Request::Base.handle_timeout
150
+ def handle_timeout
151
+ raise ::NotImplementedError, 'Timeout is unexpected for stubbed API call.'
152
+ end
153
+
154
+ protected
155
+
156
+ # @see RightDevelop::Testing::Client::Rest::Request::Base#recording_mode
157
+ def recording_mode
158
+ :playback
159
+ end
160
+
161
+ def fetch_response(state)
162
+ # response must exist in the current epoch (i.e. can only enter next epoch
163
+ # after a valid response is found) or in a past epoch. the latter was
164
+ # allowed due to multithreaded requests causing the epoch to advance
165
+ # (in a non-throttled playback) before all requests for a past epoch have
166
+ # been made. the current epoch is always preferred over past.
167
+ logger.debug("BEGIN playback state = #{state.inspect}") if logger.debug?
168
+ file_path = nil
169
+ past_epochs = state[:past_epochs] ||= []
170
+ try_epochs = [state[:epoch]] + past_epochs
171
+ tried_paths = []
172
+ try_epochs.each do |epoch|
173
+ file_path = response_file_path(epoch)
174
+ break if ::File.file?(file_path)
175
+ tried_paths << file_path
176
+ file_path = nil
177
+ end
178
+ if file_path
179
+ response_hash = ::Mash.new(::YAML.load_file(file_path))
180
+ @response_metadata = create_response_metadata(
181
+ state,
182
+ response_hash[:http_status],
183
+ response_hash[:headers],
184
+ response_hash[:body])
185
+ result = FakeNetHttpResponse.new(response_hash, response_metadata)
186
+ else
187
+ raise PlaybackError,
188
+ "Unable to locate response file(s): \"#{tried_paths.join("\", \"")}\""
189
+ end
190
+ logger.debug("Played back response from #{file_path.inspect}.")
191
+
192
+ # determine if epoch is done, which it is if every known request has been
193
+ # responded to for the current epoch. there is a steady state at the end
194
+ # of time when all responses are given but there is no next epoch.
195
+ unless state[:end_of_time]
196
+
197
+ # list epochs once.
198
+ unless epochs = state[:epochs]
199
+ epochs = []
200
+ ::Dir[::File.join(fixtures_dir, '*')].each do |path|
201
+ if ::File.directory?(path)
202
+ name = ::File.basename(path)
203
+ epochs << Integer(name) if name =~ /^\d+$/
204
+ end
205
+ end
206
+ state[:epochs] = epochs.sort!
207
+ end
208
+
209
+ # current epoch must be listed.
210
+ current_epoch = state[:epoch]
211
+ unless current_epoch == epochs.first
212
+ raise PlaybackError,
213
+ "Unable to locate current epoch directory: #{::File.join(fixtures_dir, current_epoch.to_s).inspect}"
214
+ end
215
+
216
+ # sorted epochs reveal the future.
217
+ if next_epoch = epochs[1]
218
+ # list all responses in current epoch once.
219
+ unless remaining = state[:remaining_responses]
220
+ # use all configured route subdirectories when building remaining
221
+ # responses hash.
222
+ #
223
+ # note that any unknown route fixtures would cause playback to spin
224
+ # on the same epoch forever. we could specifically select known
225
+ # route directories but it is just easier to find all here.
226
+ search_path = ::File.join(
227
+ @fixtures_dir,
228
+ current_epoch.to_s,
229
+ '*/response/**/*.yml')
230
+ remaining = state[:remaining_responses] = ::Dir[search_path].inject({}) do |h, path|
231
+ h[path] = { call_count: 0 }
232
+ h
233
+ end
234
+ if remaining.empty?
235
+ raise PlaybackError,
236
+ "Unable to determine remaining responses from #{search_path.inspect}"
237
+ end
238
+ logger.debug("Pending responses for epoch = #{current_epoch}: #{remaining.inspect}")
239
+ end
240
+
241
+ # may have been reponded before in same epoch; only care if this is
242
+ # the first time response was used unless playback is throttled.
243
+ #
244
+ # when playback is not throttled, there is no time delay (beyond the
245
+ # time needed to compute response) and the minimum number of calls per
246
+ # response is one.
247
+ #
248
+ # when playback is throttled (non-zero) we must satisfy the call count
249
+ # before advancing epoch. the point of this is to force the client to
250
+ # repeat the request the recorded number of times before the state
251
+ # appears to change.
252
+ #
253
+ # note that the user can achieve minimum delay while checking call
254
+ # count by setting @throttle = 1
255
+ if response_data = remaining[file_path]
256
+ response_data[:call_count] += 1
257
+ exhausted_response =
258
+ (0 == @throttle) ||
259
+ (response_data[:call_count] >= result.call_count)
260
+ if exhausted_response
261
+ remaining.delete(file_path)
262
+ if remaining.empty?
263
+ # time marches on.
264
+ past_epochs.unshift(epochs.shift)
265
+ state[:epoch] = next_epoch
266
+ state.delete(:remaining_responses) # reset responses for next epoch
267
+ if logger.debug?
268
+ message = <<EOF
269
+
270
+ A new epoch = #{state[:epoch]} begins due to
271
+ verb = #{request_metadata.verb}
272
+ uri = \"#{request_metadata.uri}\"
273
+ throttle = #{@throttle}
274
+ call_count = #{@throttle == 0 ? '<ignored>' : "#{response_data[:call_count]} >= #{result.call_count}"}
275
+ EOF
276
+ logger.debug(message)
277
+ end
278
+ end
279
+ end
280
+ end
281
+ else
282
+ # the future is now; no need to add final epoch to past epochs.
283
+ state.delete(:remaining_responses)
284
+ state.delete(:epochs)
285
+ state[:end_of_time] = true
286
+ end
287
+ end
288
+ logger.debug("END playback state = #{state.inspect}") if logger.debug?
289
+ result
290
+ end
291
+
292
+ end # Base
293
+ end # RightDevelop::Testing::Client::Rest
@@ -0,0 +1,175 @@
1
+ #
2
+ # Copyright (c) 2013 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/clients/rest'
25
+
26
+ require 'fileutils'
27
+ require 'rest_client'
28
+
29
+ module RightDevelop::Testing::Client::Rest::Request
30
+
31
+ # Provides a middle-ware layer that intercepts response by overriding the
32
+ # logging mechanism built into rest-client Request. Request supports 'before'
33
+ # hooks (for request) but not 'after' hooks (for response) so logging is all
34
+ # we have.
35
+ class Record < ::RightDevelop::Testing::Client::Rest::Request::Base
36
+
37
+ # simulated 504 Net::HTTPResponse
38
+ class TimeoutNetHttpResponse
39
+ attr_reader :code, :body
40
+
41
+ def initialize
42
+ message = 'Timeout'
43
+ @code = 504
44
+ @headers = {
45
+ 'content-type' => 'text/plain',
46
+ 'content-length' => ::Rack::Utils.bytesize(message).to_s,
47
+ 'connection' => 'close',
48
+ }.inject({}) do |h, (k, v)|
49
+ h[k] = Array(v) # expected to be an array
50
+ h
51
+ end
52
+ @body = message
53
+ end
54
+
55
+ def [](key)
56
+ if header = @headers[key.downcase]
57
+ header.join(', ')
58
+ else
59
+ nil
60
+ end
61
+ end
62
+
63
+ def to_hash; @headers; end
64
+ end
65
+
66
+ # Overrides log_request for basic logging.
67
+ #
68
+ # @param [RestClient::Response] to capture
69
+ #
70
+ # @return [Object] undefined
71
+ def log_request
72
+ logger.debug("proxied_url = #{@url.inspect}")
73
+ super
74
+ end
75
+
76
+ # Overrides log_response to capture both request and response.
77
+ #
78
+ # @param [RestClient::Response] to capture
79
+ #
80
+ # @return [Object] undefined
81
+ def log_response(response)
82
+ result = super
83
+ with_state_lock { |state| record_response(state, response) }
84
+ result
85
+ end
86
+
87
+ # @see RightDevelop::Testing::Client::Rest::Request::Base.handle_timeout
88
+ def handle_timeout
89
+ super
90
+ response = TimeoutNetHttpResponse.new
91
+ with_state_lock { |state| record_response(state, response) }
92
+ response
93
+ end
94
+
95
+ protected
96
+
97
+ # @see RightDevelop::Testing::Client::Rest::Request::Base#recording_mode
98
+ def recording_mode
99
+ :record
100
+ end
101
+
102
+ def record_response(state, response)
103
+ # never record redirects because a redirect cannot be proxied back to the
104
+ # client (i.e. the client cannot update it's request url when proxied).
105
+ code = response.code
106
+ http_status = Integer(code)
107
+ if http_status >= 300 && http_status < 400
108
+ return true
109
+ end
110
+
111
+ # use raw headers for response instead of the usual RestClient behavior of
112
+ # converting arrays to comma-delimited strings.
113
+ @response_metadata = create_response_metadata(
114
+ state, http_status, response.to_hash, response.body)
115
+
116
+ # record elapsed time in (integral) seconds. not intended to be a precise
117
+ # measure of time but rather used to throttle server if client is time-
118
+ # sensitive for some reason.
119
+ elapsed_seconds = @response_timestamp - @request_timestamp
120
+ response_hash = {
121
+ elapsed_seconds: elapsed_seconds,
122
+ http_status: response_metadata.http_status,
123
+ headers: response_metadata.headers.to_hash,
124
+ body: response_metadata.body,
125
+ }
126
+
127
+ # detect collision, if any, to determine if we have entered a new epoch.
128
+ ork = outstanding_request_key
129
+ call_count = 0
130
+ next_checksum = response_metadata.checksum
131
+ if response_data = (state[:response_data] ||= {})[ork]
132
+ last_checksum = response_data[:checksum]
133
+ if last_checksum != next_checksum
134
+ # note that variables never reset due to epoch change but they can be
135
+ # updated by a subsequent client request.
136
+ state[:epoch] += 100 # leave room to insert custom epochs
137
+ state[:response_data] = {} # reset checksums for next epoch
138
+ logger.debug("A new epoch = #{state[:epoch]} begins due to #{request_metadata.verb} \"#{request_metadata.uri}\"")
139
+ else
140
+ call_count = response_data[:call_count]
141
+ end
142
+ end
143
+ call_count += 1
144
+ state[:response_data][ork] = {
145
+ checksum: next_checksum,
146
+ call_count: call_count,
147
+ }
148
+ response_hash[:call_count] = call_count
149
+
150
+ # write request unless already written.
151
+ file_path = request_file_path(state[:epoch])
152
+ unless ::File.file?(file_path)
153
+ # note that variables are not recorded because they must always be
154
+ # supplied by the client's request.
155
+ request_hash = {
156
+ verb: request_metadata.verb,
157
+ query: request_metadata.query,
158
+ headers: request_metadata.headers.to_hash,
159
+ body: request_metadata.body
160
+ }
161
+ ::FileUtils.mkdir_p(::File.dirname(file_path))
162
+ ::File.open(file_path, 'w') { |f| f.puts(::YAML.dump(request_hash)) }
163
+ end
164
+ logger.debug("Recorded request at #{file_path.inspect}.")
165
+
166
+ # response always written for incremented call count.
167
+ file_path = response_file_path(state[:epoch])
168
+ ::FileUtils.mkdir_p(::File.dirname(file_path))
169
+ ::File.open(file_path, 'w') { |f| f.puts(::YAML.dump(response_hash)) }
170
+ logger.debug("Recorded response at #{file_path.inspect}.")
171
+ true
172
+ end
173
+
174
+ end # Base
175
+ end # RightDevelop::Testing::Client::Rest