telecaster 0.1.1

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/README.rdoc ADDED
@@ -0,0 +1,111 @@
1
+ = Telecaster
2
+
3
+ A multiplexing HTTP proxy based on EventMachine. Forwards every incoming request
4
+ onto a set of backend servers, collects the responses and sends a summary back
5
+ to the client.
6
+
7
+ This is designed to allow independent services to notify each other about
8
+ changes without tightly coupling them. Each service just calls a Telecaster
9
+ server, which knows where to forward things to. The publisher gets a summary of
10
+ which services were called and how they responded, which makes this notification
11
+ style easier to debug than async message-passing systems.
12
+
13
+ Telecaster supports delegation, so requests can be distributed to slaves and all
14
+ the backend responses will be aggregated by the master.
15
+
16
+
17
+ == Usage
18
+
19
+ We recommend an EventMachine-based server like Thin, to best take advantage of
20
+ EventMachine's concurrency. Create a Telecaster with a list of backend hosts and
21
+ a logger:
22
+
23
+ require 'telecaster'
24
+ require 'logger'
25
+
26
+ telecaster = Telecaster.new(
27
+ :backends => %w[http://concerts-service http://accounts-service],
28
+ :logger => Logger.new($stdout)
29
+ )
30
+
31
+ Then either boot it directly with Thin:
32
+
33
+ require 'rack'
34
+ Rack::Handler.get('thin').run(telecaster, :Port => 9000)
35
+
36
+ or use a +rackup+ script
37
+
38
+ # config.ru
39
+ run telecaster
40
+
41
+ $ thin start -R config.ru -p 9000
42
+
43
+
44
+ == Protocol
45
+
46
+ On every request, Telecaster returns a 200 response containing a JSON summary of
47
+ the backend responses, for example:
48
+
49
+ $ curl localhost:9000/hello
50
+ [
51
+ {
52
+ "host": "http://localhost:9001",
53
+ "status": 200,
54
+ "duration": 16,
55
+ "data": {
56
+ "status": "ok"
57
+ }
58
+ },
59
+ {
60
+ "host": "http://localhost:9002",
61
+ "status": 200,
62
+ "duration": 16
63
+ },
64
+ {
65
+ "host": "http://localhost:9003",
66
+ "status": 404,
67
+ "duration": 15
68
+ }
69
+ ]
70
+
71
+ This JSON list contains a record for each backend that was called. Each record
72
+ contains the following fields:
73
+
74
+ * +host+ - the hostname of the backend server
75
+ * +status+ - a numetic HTTP status code if a response was received, else +nil+
76
+ * +duration+ - the request time in milliseconds
77
+ * +data+ - any JSON data returned by the backend
78
+
79
+ Every backend may return a JSON object using <tt>Content-Type:
80
+ application/json</tt>. If it does so, the data is included in the Telecaster
81
+ summary.
82
+
83
+ If the backend returns a JSON array, this is interpretted as Telecaster summary
84
+ data and merged in - this is how Telecaster aggregates responses from delegate
85
+ servers. So if you want to return custom data, the root of the response must be
86
+ a single JSON object.
87
+
88
+
89
+ == License
90
+
91
+ (The MIT License)
92
+
93
+ Copyright (c) 2012 Songkick.com
94
+
95
+ Permission is hereby granted, free of charge, to any person obtaining a copy of
96
+ this software and associated documentation files (the 'Software'), to deal in
97
+ the Software without restriction, including without limitation the rights to use,
98
+ copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
99
+ Software, and to permit persons to whom the Software is furnished to do so,
100
+ subject to the following conditions:
101
+
102
+ The above copyright notice and this permission notice shall be included in all
103
+ copies or substantial portions of the Software.
104
+
105
+ THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
106
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
107
+ FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
108
+ COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
109
+ IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
110
+ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
111
+
@@ -0,0 +1,20 @@
1
+ require 'rubygems'
2
+ require File.expand_path('../../lib/telecaster', __FILE__)
3
+ require File.expand_path('../../spec/backends', __FILE__)
4
+
5
+ require 'logger'
6
+ require 'thin'
7
+ Thin::Logging.silent = true
8
+
9
+ EM.run {
10
+ Rack::Handler.get('thin').run(Responder, :Port => 9001)
11
+ Rack::Handler.get('thin').run(Blank, :Port => 9002)
12
+ Rack::Handler.get('thin').run(Error, :Port => 9003)
13
+
14
+ hosts = (1..3).map { |n| "http://localhost:900#{n}" }
15
+ logger = Logger.new($stdout)
16
+ telecaster = Telecaster.new(:backends => hosts, :logger => logger)
17
+
18
+ Rack::Handler.get('thin').run(telecaster, :Port => 9000)
19
+ }
20
+
data/lib/telecaster.rb ADDED
@@ -0,0 +1,83 @@
1
+ require 'eventmachine'
2
+ require 'em-http-request'
3
+ require 'yajl'
4
+
5
+ class Telecaster
6
+ ASYNC_RESPONSE = [-1, {}, []].freeze
7
+ TYPE_JSON = 'application/json'
8
+
9
+ ROOT = File.expand_path('..', __FILE__)
10
+ autoload :Multi, ROOT + '/telecaster/multi'
11
+
12
+ def initialize(options)
13
+ @backend_hosts = options[:backends]
14
+ @logger = options[:logger]
15
+ end
16
+
17
+ def call(env)
18
+ ensure_reactor_running
19
+
20
+ method = env['REQUEST_METHOD'].downcase
21
+ multi = Multi.new
22
+ callback = env['async.callback']
23
+ start = Time.now.to_f
24
+
25
+ @backend_hosts.each do |host|
26
+ request, args = *create_request(env, host)
27
+ multi.add(host, request.__send__(method, args))
28
+ end
29
+
30
+ multi.callback do |response|
31
+ duration = ((Time.now.to_f - start) * 1000).round
32
+ summary = response.map { |r| '[' + [r['host'], r['status'], r['duration']] * ' ' + ']' }
33
+ @logger.info "#{env['REQUEST_METHOD']} #{env['REQUEST_URI']} backends:#{response.size} duration:#{duration} #{summary * ' '}"
34
+
35
+ json = Yajl::Encoder.encode(response, :pretty => true, :indent => ' ')
36
+ callback.call [200, {'Content-Type' => TYPE_JSON}, [json]]
37
+ end
38
+
39
+ ASYNC_RESPONSE
40
+
41
+ rescue => e
42
+ [500, {'Content-Type' => 'text/plain'}, [e.message]]
43
+ end
44
+
45
+ private
46
+
47
+ def create_request(env, host)
48
+ uri = File.join(host, env['REQUEST_URI'])
49
+ request = EM::HttpRequest.new(uri)
50
+ args = {:head => {}}
51
+
52
+ env.each do |header, value|
53
+ next unless header =~ /^HTTP_/ and header != 'HTTP_HOST'
54
+
55
+ name = header.gsub(/^HTTP_/, '').gsub('_', '-').
56
+ downcase.
57
+ gsub(/(^|-)([a-z])/) { $1 + $2.upcase }
58
+
59
+ args[:head][name] = value
60
+ end
61
+
62
+ if length = env['CONTENT_LENGTH']
63
+ args[:head]['Content-Length'] = length
64
+ end
65
+
66
+ if type = env['CONTENT_TYPE']
67
+ args[:head]['Content-Type'] = type
68
+ end
69
+
70
+ if input = env['rack.input']
71
+ args[:body] = input.read
72
+ end
73
+
74
+ [request, args]
75
+ end
76
+
77
+ def ensure_reactor_running
78
+ return if EM.reactor_running?
79
+ Thread.new { EM.run }
80
+ Thread.pass until EM.reactor_running?
81
+ end
82
+ end
83
+
@@ -0,0 +1,54 @@
1
+ # This is Telecaster::Multi, not a multitelecaster.
2
+ # http://farm4.static.flickr.com/3068/2369563217_bd2c623c53.jpg
3
+
4
+ class Telecaster
5
+ class Multi
6
+ include EM::Deferrable
7
+
8
+ def initialize
9
+ @requests = {}
10
+ @responses = {}
11
+ end
12
+
13
+ def add(host, request)
14
+ start_time = Time.now.to_f
15
+ @requests[host] = request
16
+
17
+ request.callback do |http|
18
+ response = {
19
+ 'host' => host,
20
+ 'status' => http.response_header.status,
21
+ 'duration' => ((Time.now.to_f - start_time) * 1000).round
22
+ }
23
+ content_type = (http.response_header['CONTENT_TYPE'] || '').split(/\s*;\s*/).first
24
+
25
+ if content_type == TYPE_JSON
26
+ response['data'] = Yajl::Parser.parse(http.response)
27
+ end
28
+ @responses[host] = response
29
+ succeed if @responses.size == @requests.size
30
+ end
31
+
32
+ request.errback do |http|
33
+ response = {'host' => host, 'status' => nil}
34
+ @responses[host] = response
35
+ succeed if @responses.size == @requests.size
36
+ end
37
+ end
38
+
39
+ private
40
+
41
+ def succeed
42
+ responses = []
43
+ @responses.each do |host, response|
44
+ if Array === response['data']
45
+ responses.concat(response['data'])
46
+ else
47
+ responses << response
48
+ end
49
+ end
50
+ super responses.sort_by { |r| r['host'] }
51
+ end
52
+ end
53
+ end
54
+
data/spec/backends.rb ADDED
@@ -0,0 +1,48 @@
1
+ require 'sinatra'
2
+
3
+ class Responder < Sinatra::Base
4
+ before do
5
+ headers 'Content-Type' => 'application/json'
6
+ end
7
+
8
+ get '/hello' do
9
+ Yajl::Encoder.encode('status' => 'ok')
10
+ end
11
+
12
+ get '/error' do
13
+ status 503
14
+ Yajl::Encoder.encode('error' => 'down')
15
+ end
16
+
17
+ get '/users/:username' do |username|
18
+ Yajl::Encoder.encode('username' => username)
19
+ end
20
+
21
+ get '/search' do
22
+ Yajl::Encoder.encode('query' => params[:q])
23
+ end
24
+
25
+ put '/blog' do
26
+ status 201
27
+ Yajl::Encoder.encode('title' => params[:title])
28
+ end
29
+ end
30
+
31
+ class Blank < Sinatra::Base
32
+ get '/hello' do
33
+ ''
34
+ end
35
+ end
36
+
37
+ class Slow < Sinatra::Base
38
+ get '/hello' do
39
+ EM.add_timer 2 do
40
+ env['async.callback'].call [200, {}, []]
41
+ end
42
+ [-1, {}, []]
43
+ end
44
+ end
45
+
46
+ class Error < Sinatra::Base
47
+ end
48
+
@@ -0,0 +1,25 @@
1
+ require 'rubygems'
2
+ require 'bundler/setup'
3
+ require File.expand_path('../../lib/telecaster', __FILE__)
4
+
5
+ require 'rack/proxy'
6
+ require 'rack/test'
7
+ require 'uri'
8
+
9
+ require File.expand_path('../../vendor/em-rspec/lib/em-rspec', __FILE__)
10
+ require File.expand_path('../telecaster_steps', __FILE__)
11
+
12
+ class Proxy < Rack::Proxy
13
+ def initialize(host)
14
+ super()
15
+ @uri = URI.parse(host)
16
+ end
17
+
18
+ def rewrite_env(env)
19
+ env['SERVER_NAME'] = @uri.host
20
+ env['SERVER_PORT'] = @uri.port.to_s
21
+ env['HTTP_HOST'] = [@uri.host, @uri.port].join(':')
22
+ env
23
+ end
24
+ end
25
+
@@ -0,0 +1,159 @@
1
+ require "spec_helper"
2
+ require "backends"
3
+ require "logger"
4
+
5
+ describe Telecaster do
6
+ include TelecasterSteps
7
+
8
+ let(:logger) { Logger.new(StringIO.new) }
9
+
10
+ before do
11
+ Thread.new { EM.run }
12
+ Thread.pass until EM.reactor_running?
13
+ end
14
+
15
+ after { stop_all_servers }
16
+
17
+ describe "with a backend" do
18
+ let :app do
19
+ Proxy.new("http://localhost:9000")
20
+ end
21
+
22
+ before do
23
+ telecaster = Telecaster.new(:backends => ["http://localhost:9001"], :logger => logger)
24
+ boot telecaster, 9000
25
+
26
+ boot Responder, 9001
27
+ end
28
+
29
+ after { sync }
30
+
31
+ it "forwards a GET request" do
32
+ get "/hello", {}
33
+ check_status 200
34
+ check_json [{
35
+ "host" => "http://localhost:9001",
36
+ "status" => 200,
37
+ "duration" => an_instance_of(Fixnum),
38
+ "data" => {"status" => "ok"}
39
+ }]
40
+ end
41
+
42
+ it "forwards a GET request with path params" do
43
+ get "/users/jcoglan", {}
44
+ check_status 200
45
+ check_json [{
46
+ "host" => "http://localhost:9001",
47
+ "status" => 200,
48
+ "duration" => an_instance_of(Fixnum),
49
+ "data" => {"username" => "jcoglan"}
50
+ }]
51
+ end
52
+
53
+ it "forwards a GET request with query string params" do
54
+ get "/search?q=foo", {}
55
+ check_status 200
56
+ check_json [{
57
+ "host" => "http://localhost:9001",
58
+ "status" => 200,
59
+ "duration" => an_instance_of(Fixnum),
60
+ "data" => {"query" => "foo"}
61
+ }]
62
+ end
63
+
64
+ it "forwards a PUT request with entity body params" do
65
+ put "/blog", :title => "A new post"
66
+ check_status 200
67
+ check_json [{
68
+ "host" => "http://localhost:9001",
69
+ "status" => 201,
70
+ "duration" => an_instance_of(Fixnum),
71
+ "data" => {"title" => "A new post"}
72
+ }]
73
+ end
74
+
75
+ it "returns an error response" do
76
+ get "/error", {}
77
+ check_status 200
78
+ check_json [{
79
+ "host" => "http://localhost:9001",
80
+ "status" => 503,
81
+ "duration" => an_instance_of(Fixnum),
82
+ "data" => {"error" => "down"}
83
+ }]
84
+ end
85
+ end
86
+
87
+ describe "with a backend that's down" do
88
+ let :app do
89
+ Proxy.new("http://localhost:9000")
90
+ end
91
+
92
+ before do
93
+ telecaster = Telecaster.new(:backends => ["http://localhost:9001"], :logger => logger)
94
+ boot telecaster, 9000
95
+ end
96
+
97
+ after { sync }
98
+
99
+ it "returns a response with no status" do
100
+ get "/hello", {}
101
+ check_status 200
102
+ check_json [{ "host" => "http://localhost:9001", "status" => nil }]
103
+ end
104
+ end
105
+
106
+ describe "with multiple backends" do
107
+ let :app do
108
+ Proxy.new("http://localhost:9000")
109
+ end
110
+
111
+ before do
112
+ hosts = (1..3).map { |n| "http://localhost:900#{n}" }
113
+ telecaster = Telecaster.new(:backends => hosts, :logger => logger)
114
+
115
+ boot telecaster, 9000
116
+
117
+ boot Responder, 9001
118
+ boot Blank, 9002
119
+ boot Error, 9003
120
+ end
121
+
122
+ after { sync }
123
+
124
+ it "returns the responses from the backends" do
125
+ get "/hello", {}
126
+ check_status 200
127
+ check_json [
128
+ { "host" => "http://localhost:9001", "status" => 200, "duration" => an_instance_of(Fixnum), "data" => {"status" => "ok"} },
129
+ { "host" => "http://localhost:9002", "status" => 200, "duration" => an_instance_of(Fixnum) },
130
+ { "host" => "http://localhost:9003", "status" => 404, "duration" => an_instance_of(Fixnum) }
131
+ ]
132
+ end
133
+
134
+ describe "delegation" do
135
+ let :app do
136
+ Proxy.new("http://localhost:9005")
137
+ end
138
+
139
+ before do
140
+ telecaster = Telecaster.new(:backends => [0,4].map { |n| "http://localhost:900#{n}" }, :logger => logger)
141
+ boot telecaster, 9005
142
+
143
+ boot Slow, 9004
144
+ end
145
+
146
+ it "aggregates responses from delegate servers" do
147
+ get "/hello", {}
148
+ check_status 200
149
+ check_json [
150
+ { "host" => "http://localhost:9001", "status" => 200, "duration" => an_instance_of(Fixnum), "data" => {"status" => "ok"} },
151
+ { "host" => "http://localhost:9002", "status" => 200, "duration" => an_instance_of(Fixnum) },
152
+ { "host" => "http://localhost:9003", "status" => 404, "duration" => an_instance_of(Fixnum) },
153
+ { "host" => "http://localhost:9004", "status" => 200, "duration" => an_instance_of(Fixnum) }
154
+ ]
155
+ end
156
+ end
157
+ end
158
+ end
159
+
@@ -0,0 +1,55 @@
1
+ TelecasterSteps = EM::RSpec.async_steps do
2
+ include Rack::Test::Methods
3
+
4
+ def boot(application, port, &callback)
5
+ @apps ||= {}
6
+ @apps[port] = application
7
+ application.extend(ThinRunner)
8
+ application.start(port)
9
+ EM.next_tick(&callback)
10
+ end
11
+
12
+ def stop_all_servers(&callback)
13
+ @apps.each_value { |app| app.stop }
14
+ EM.next_tick(&callback)
15
+ end
16
+
17
+ %w[get post put delete].each do |method|
18
+ class_eval %Q{
19
+ def #{method}(path, params, &callback)
20
+ EM.defer {
21
+ super(path, params)
22
+ callback.call
23
+ }
24
+ end
25
+ }
26
+ end
27
+
28
+ def check_status(status, &callback)
29
+ last_response.status.to_i.should == status
30
+ callback.call
31
+ end
32
+
33
+ def check_json(json, &callback)
34
+ last_response['Content-Type'].should == 'application/json'
35
+ Yajl::Parser.parse(last_response.body).should == json
36
+ callback.call
37
+ end
38
+ end
39
+
40
+ require 'thin'
41
+ Thin::Logging.silent = true
42
+
43
+ module ThinRunner
44
+ def start(port)
45
+ handler = Rack::Handler.get('thin')
46
+ handler.run(self, :Port => port) do |server|
47
+ @server = server
48
+ end
49
+ end
50
+
51
+ def stop
52
+ @server.stop
53
+ end
54
+ end
55
+
metadata ADDED
@@ -0,0 +1,184 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: telecaster
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.1
5
+ prerelease:
6
+ platform: ruby
7
+ authors:
8
+ - James Coglan
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2012-09-19 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: em-http-request
16
+ requirement: !ruby/object:Gem::Requirement
17
+ none: false
18
+ requirements:
19
+ - - ! '>='
20
+ - !ruby/object:Gem::Version
21
+ version: 0.3.0
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ none: false
26
+ requirements:
27
+ - - ! '>='
28
+ - !ruby/object:Gem::Version
29
+ version: 0.3.0
30
+ - !ruby/object:Gem::Dependency
31
+ name: eventmachine
32
+ requirement: !ruby/object:Gem::Requirement
33
+ none: false
34
+ requirements:
35
+ - - ! '>='
36
+ - !ruby/object:Gem::Version
37
+ version: 0.12.0
38
+ type: :runtime
39
+ prerelease: false
40
+ version_requirements: !ruby/object:Gem::Requirement
41
+ none: false
42
+ requirements:
43
+ - - ! '>='
44
+ - !ruby/object:Gem::Version
45
+ version: 0.12.0
46
+ - !ruby/object:Gem::Dependency
47
+ name: yajl-ruby
48
+ requirement: !ruby/object:Gem::Requirement
49
+ none: false
50
+ requirements:
51
+ - - ! '>='
52
+ - !ruby/object:Gem::Version
53
+ version: 1.0.0
54
+ type: :runtime
55
+ prerelease: false
56
+ version_requirements: !ruby/object:Gem::Requirement
57
+ none: false
58
+ requirements:
59
+ - - ! '>='
60
+ - !ruby/object:Gem::Version
61
+ version: 1.0.0
62
+ - !ruby/object:Gem::Dependency
63
+ name: rspec
64
+ requirement: !ruby/object:Gem::Requirement
65
+ none: false
66
+ requirements:
67
+ - - ~>
68
+ - !ruby/object:Gem::Version
69
+ version: 2.8.0
70
+ type: :development
71
+ prerelease: false
72
+ version_requirements: !ruby/object:Gem::Requirement
73
+ none: false
74
+ requirements:
75
+ - - ~>
76
+ - !ruby/object:Gem::Version
77
+ version: 2.8.0
78
+ - !ruby/object:Gem::Dependency
79
+ name: rack-proxy
80
+ requirement: !ruby/object:Gem::Requirement
81
+ none: false
82
+ requirements:
83
+ - - ! '>='
84
+ - !ruby/object:Gem::Version
85
+ version: '0'
86
+ type: :development
87
+ prerelease: false
88
+ version_requirements: !ruby/object:Gem::Requirement
89
+ none: false
90
+ requirements:
91
+ - - ! '>='
92
+ - !ruby/object:Gem::Version
93
+ version: '0'
94
+ - !ruby/object:Gem::Dependency
95
+ name: rack-test
96
+ requirement: !ruby/object:Gem::Requirement
97
+ none: false
98
+ requirements:
99
+ - - ! '>='
100
+ - !ruby/object:Gem::Version
101
+ version: '0'
102
+ type: :development
103
+ prerelease: false
104
+ version_requirements: !ruby/object:Gem::Requirement
105
+ none: false
106
+ requirements:
107
+ - - ! '>='
108
+ - !ruby/object:Gem::Version
109
+ version: '0'
110
+ - !ruby/object:Gem::Dependency
111
+ name: sinatra
112
+ requirement: !ruby/object:Gem::Requirement
113
+ none: false
114
+ requirements:
115
+ - - ! '>='
116
+ - !ruby/object:Gem::Version
117
+ version: '0'
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ none: false
122
+ requirements:
123
+ - - ! '>='
124
+ - !ruby/object:Gem::Version
125
+ version: '0'
126
+ - !ruby/object:Gem::Dependency
127
+ name: thin
128
+ requirement: !ruby/object:Gem::Requirement
129
+ none: false
130
+ requirements:
131
+ - - ! '>='
132
+ - !ruby/object:Gem::Version
133
+ version: 1.2.0
134
+ type: :development
135
+ prerelease: false
136
+ version_requirements: !ruby/object:Gem::Requirement
137
+ none: false
138
+ requirements:
139
+ - - ! '>='
140
+ - !ruby/object:Gem::Version
141
+ version: 1.2.0
142
+ description:
143
+ email: james@songkick.com
144
+ executables: []
145
+ extensions: []
146
+ extra_rdoc_files:
147
+ - README.rdoc
148
+ files:
149
+ - README.rdoc
150
+ - example/servers.rb
151
+ - lib/telecaster/multi.rb
152
+ - lib/telecaster.rb
153
+ - spec/backends.rb
154
+ - spec/spec_helper.rb
155
+ - spec/telecaster_spec.rb
156
+ - spec/telecaster_steps.rb
157
+ homepage: http://github.com/songkick/telecaster
158
+ licenses: []
159
+ post_install_message:
160
+ rdoc_options:
161
+ - --main
162
+ - README.rdoc
163
+ require_paths:
164
+ - lib
165
+ required_ruby_version: !ruby/object:Gem::Requirement
166
+ none: false
167
+ requirements:
168
+ - - ! '>='
169
+ - !ruby/object:Gem::Version
170
+ version: '0'
171
+ required_rubygems_version: !ruby/object:Gem::Requirement
172
+ none: false
173
+ requirements:
174
+ - - ! '>='
175
+ - !ruby/object:Gem::Version
176
+ version: '0'
177
+ requirements: []
178
+ rubyforge_project:
179
+ rubygems_version: 1.8.23
180
+ signing_key:
181
+ specification_version: 3
182
+ summary: HTTP proxy for forwarding to multiple backends and collecting results
183
+ test_files: []
184
+ has_rdoc: