right_develop 2.1.5 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. data/VERSION +1 -1
  2. data/bin/right_develop +4 -0
  3. data/lib/right_develop.rb +1 -0
  4. data/lib/right_develop/commands.rb +2 -1
  5. data/lib/right_develop/commands/server.rb +194 -0
  6. data/lib/right_develop/testing.rb +31 -0
  7. data/lib/right_develop/testing/clients.rb +36 -0
  8. data/lib/right_develop/testing/clients/rest.rb +34 -0
  9. data/lib/right_develop/testing/clients/rest/requests.rb +38 -0
  10. data/lib/right_develop/testing/clients/rest/requests/base.rb +305 -0
  11. data/lib/right_develop/testing/clients/rest/requests/playback.rb +293 -0
  12. data/lib/right_develop/testing/clients/rest/requests/record.rb +175 -0
  13. data/lib/right_develop/testing/recording.rb +33 -0
  14. data/lib/right_develop/testing/recording/config.rb +571 -0
  15. data/lib/right_develop/testing/recording/metadata.rb +805 -0
  16. data/lib/right_develop/testing/servers/might_api/.gitignore +3 -0
  17. data/lib/right_develop/testing/servers/might_api/Gemfile +6 -0
  18. data/lib/right_develop/testing/servers/might_api/Gemfile.lock +18 -0
  19. data/lib/right_develop/testing/servers/might_api/app/base.rb +323 -0
  20. data/lib/right_develop/testing/servers/might_api/app/echo.rb +73 -0
  21. data/lib/right_develop/testing/servers/might_api/app/playback.rb +46 -0
  22. data/lib/right_develop/testing/servers/might_api/app/record.rb +45 -0
  23. data/lib/right_develop/testing/servers/might_api/config.ru +8 -0
  24. data/lib/right_develop/testing/servers/might_api/config/init.rb +47 -0
  25. data/lib/right_develop/testing/servers/might_api/lib/config.rb +204 -0
  26. data/lib/right_develop/testing/servers/might_api/lib/logger.rb +68 -0
  27. data/right_develop.gemspec +30 -2
  28. metadata +84 -34
@@ -0,0 +1,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