rails-threaded-proxy 0.4.1 → 0.5.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 627edadeb8a4d381699398dcd596285b425994116b21393d5a1c4b61f91bf195
4
- data.tar.gz: '0066744825548c505bf61585302e20a011761f7198f12cdb34fa9f79607317c4'
3
+ metadata.gz: 8d77c14092a49dc2e231f233da21a934600a5159aabbc6bbb40294404c8e49d7
4
+ data.tar.gz: 8590d4359939c617098a036435c340623de538fcb5a65bbd15fe1b72726be86b
5
5
  SHA512:
6
- metadata.gz: 52d909c71d9657f8e0f5f601bb4478979dfb4971bbf93230067172cd0077fbfc77601b9a1bc76f27b94f01185f83e07c08540eacbea70c612e66211d0cca4bb3
7
- data.tar.gz: 108158c59c1b87c72e6559cb7fdb535d1c05819a581f321feff8679ea4213491a37c7dd204fc55357dbdf9bfc6a3e7724fa4223c488036b53a20594c235a7d56
6
+ metadata.gz: 7704567ef587afce79dbf21ce20f67add2e04c87cbc1efb7fbb3491cc7a95e01e05a8b7881917a64073caca7f4769c07814d22038f06fce045aa80b07f00b501
7
+ data.tar.gz: 025e8d6eda736b0ffa5c8f8e97d47941f64b06081b4a3a2164c6288a337b50d7325d64971c89fab35622f9c383dad0941584f1e1e4bf80d2a9c99bc4be20501b
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.4.1
1
+ 0.5.0
@@ -2,10 +2,15 @@
2
2
 
3
3
  require 'addressable/uri'
4
4
  require 'active_support/notifications'
5
+ require 'action_dispatch'
5
6
  require 'net/http'
7
+
6
8
  require_relative 'http'
9
+ require_relative 'socket_responder'
7
10
 
8
11
  module ThreadedProxy
12
+ class ResponseBodyAlreadyConsumedError < StandardError; end
13
+
9
14
  class Client
10
15
  DISALLOWED_RESPONSE_HEADERS = %w[keep-alive].freeze
11
16
 
@@ -22,7 +27,6 @@ module ThreadedProxy
22
27
  CALLBACK_METHODS = %i[
23
28
  on_response
24
29
  on_headers
25
- on_body
26
30
  on_complete
27
31
  on_error
28
32
  ].freeze
@@ -42,11 +46,13 @@ module ThreadedProxy
42
46
  def initialize(origin_url, options = {})
43
47
  @origin_url = Addressable::URI.parse(origin_url)
44
48
  @options = DEFAULT_OPTIONS.merge(options)
49
+ @wrote_headers = false
45
50
 
46
51
  @callbacks = {}
47
- CALLBACK_METHODS.each do |method_name|
52
+ (CALLBACK_METHODS - [:on_error]).each do |method_name|
48
53
  @callbacks[method_name] = proc {}
49
54
  end
55
+ @callbacks[:on_error] = proc { |e| raise e }
50
56
 
51
57
  yield(self) if block_given?
52
58
  end
@@ -67,6 +73,8 @@ module ThreadedProxy
67
73
  http_request.body = @options[:body]
68
74
  end
69
75
 
76
+ socket_responder = SocketResponder.new(socket)
77
+
70
78
  ActiveSupport::Notifications.instrument('threaded_proxy.fetch', method: request_method, url: @origin_url.to_s,
71
79
  headers: request_headers) do
72
80
  http = HTTP.new(@origin_url.host, @origin_url.port || default_port(@origin_url))
@@ -76,15 +84,14 @@ module ThreadedProxy
76
84
 
77
85
  http.start do
78
86
  http.request(http_request) do |client_response|
79
- @callbacks[:on_response].call(client_response, socket)
87
+ @callbacks[:on_response].call(client_response, socket_responder)
80
88
  break if socket.closed?
81
89
 
82
90
  log('Writing response status and headers')
83
91
  write_headers(client_response, socket)
84
92
  break if socket.closed?
85
93
 
86
- @callbacks[:on_body].call(client_response, socket)
87
- break if socket.closed?
94
+ raise ResponseBodyAlreadyConsumedError if client_response.read?
88
95
 
89
96
  # There may have been some existing data in client_response's read buffer, flush it out
90
97
  # before we manually connect the raw sockets
@@ -97,9 +104,13 @@ module ThreadedProxy
97
104
 
98
105
  @callbacks[:on_complete].call(client_response)
99
106
  end
100
- rescue StandardError => e
101
- @callbacks[:on_error].call(e) or raise
102
107
  end
108
+ rescue StandardError => e
109
+ @callbacks[:on_error].call(e, socket_responder)
110
+ # Default to 500 if the error callback didn't write a response
111
+ socket_responder.render(status: 500, text: 'Internal Server Error') unless socket.closed? || @wrote_headers
112
+
113
+ socket.close unless socket.closed?
103
114
  end
104
115
  end
105
116
 
@@ -118,6 +129,7 @@ module ThreadedProxy
118
129
 
119
130
  # Done with headers
120
131
  socket.write "\r\n"
132
+ @wrote_headers = true
121
133
  end
122
134
 
123
135
  def default_port(uri)
@@ -4,36 +4,58 @@ require_relative 'client'
4
4
 
5
5
  module ThreadedProxy
6
6
  module Controller
7
+ # Proxies a fetch request to the specified origin URL, allowing for hijacking
8
+ # the controller response outside of the Rack request/response cycle.
9
+ #
10
+ # @param origin_url [String] The URL to which the request will be proxied.
11
+ # @param options [Hash] Optional parameters for the request.
12
+ # @option options [Symbol] :body The body of the request. If set to :rack, the request body stream will be used.
13
+ # @option options [Hash] :headers Additional headers to include in the request.
14
+ # @yield [Client] Optional block to configure the client.
15
+ #
16
+ # @raise [RuntimeError] If a non-chunked POST request is made without a content-length header.
17
+ #
18
+ # @return [void]
19
+ #
20
+ # @example
21
+ # proxy_fetch('http://example.com', body: :rack, headers: { 'Custom-Header' => 'value' }) do |client|
22
+ # client.on_headers { |client_response| client_response['x-foo'] = 'bar' }
23
+ # client.on_error { |e| Rails.logger.error(e) }
24
+ # end
7
25
  def proxy_fetch(origin_url, options = {}, &block)
8
26
  # hijack the response so we can take it outside of the rack request/response cycle
9
27
  request.env['rack.hijack'].call
10
28
  socket = request.env['rack.hijack_io']
11
29
 
12
- Thread.new do
13
- if options[:body] == :rack
14
- options[:headers] ||= {}
15
- options[:body] = request.body_stream
16
-
17
- if request.env['HTTP_TRANSFER_ENCODING'] == 'chunked'
18
- options[:headers]['Transfer-Encoding'] = 'chunked'
19
- elsif request.env['CONTENT_LENGTH']
20
- options[:headers]['content-length'] = request.env['CONTENT_LENGTH'].to_s
21
- else
22
- raise 'Cannot proxy a non-chunked POST request without content-length'
23
- end
24
-
25
- options[:headers]['Content-Type'] = request.env['CONTENT_TYPE'] if request.env['CONTENT_TYPE']
26
- end
30
+ options.deep_merge!(proxy_options_from_request) if options[:body] == :rack
27
31
 
32
+ Thread.new do
28
33
  client = Client.new(origin_url, options, &block)
29
34
  client.start(socket)
30
- rescue Errno::EPIPE
31
- # client disconnected before request finished; not an error
32
35
  ensure
33
36
  socket.close unless socket.closed?
34
37
  end
35
38
 
36
39
  head :ok
37
40
  end
41
+
42
+ protected
43
+
44
+ def proxy_options_from_request
45
+ options = {}
46
+ options[:headers] ||= {}
47
+ options[:body] = request.body_stream
48
+
49
+ if request.env['HTTP_TRANSFER_ENCODING'] == 'chunked'
50
+ options[:headers]['Transfer-Encoding'] = 'chunked'
51
+ elsif request.env['CONTENT_LENGTH']
52
+ options[:headers]['content-length'] = request.env['CONTENT_LENGTH'].to_s
53
+ else
54
+ raise 'Cannot proxy a non-chunked POST request without content-length'
55
+ end
56
+
57
+ options[:headers]['Content-Type'] = request.env['CONTENT_TYPE'] if request.env['CONTENT_TYPE']
58
+ options
59
+ end
38
60
  end
39
61
  end
@@ -20,7 +20,14 @@ module ThreadedProxy
20
20
 
21
21
  def request(*args)
22
22
  if block_given?
23
- super { |res| yield hijack_response(res) }
23
+ super do |res|
24
+ access_read(res)
25
+ yield(res).tap do
26
+ # In the block case, the response is hijacked _after_ the block is called
27
+ # to allow the block to read the response body if it wants
28
+ hijack_response(res)
29
+ end
30
+ end
24
31
  else
25
32
  hijack_response(super)
26
33
  end
@@ -30,8 +37,19 @@ module ThreadedProxy
30
37
 
31
38
  # We read the response ourselves; don't need net/http to try to read it again
32
39
  def hijack_response(res)
33
- res.instance_variable_set('@read', true)
40
+ access_read(res) unless res.respond_to?(:read?)
41
+ res.read = true
34
42
  res
35
43
  end
44
+
45
+ def access_read(res)
46
+ res.singleton_class.class_eval do
47
+ attr_writer :read
48
+
49
+ def read?
50
+ @read
51
+ end
52
+ end
53
+ end
36
54
  end
37
55
  end
@@ -0,0 +1,65 @@
1
+ module ThreadedProxy
2
+ class SocketResponder
3
+ def initialize(socket)
4
+ @socket = socket
5
+ end
6
+
7
+ def render(options = {})
8
+ return false if @socket.closed?
9
+
10
+ status = options[:status] || 200
11
+ headers = options[:headers] || {}
12
+ body = options[:body]
13
+ json = options[:json]
14
+ text = options[:text]
15
+
16
+ if json
17
+ body = json.to_json
18
+ headers['Content-Type'] ||= 'application/json; charset=utf-8'
19
+ elsif text
20
+ body = text
21
+ headers['Content-Type'] ||= 'text/plain; charset=utf-8'
22
+ else
23
+ body ||= ''
24
+ end
25
+
26
+ response = ActionDispatch::Response.new(status, headers, [])
27
+ response.prepare!
28
+
29
+ # Build the HTTP response
30
+ response_str = "HTTP/1.1 #{response.status} #{response.message}\r\n"
31
+ response.headers.each do |key, value|
32
+ Array(value).each do |v|
33
+ response_str += "#{key}: #{v}\r\n"
34
+ end
35
+ end
36
+ response_str += "\r\n"
37
+
38
+ write(response_str)
39
+
40
+ if body.respond_to?(:read)
41
+ IO.copy_stream(body, @socket)
42
+ else
43
+ write(body)
44
+ end
45
+
46
+ close
47
+ end
48
+
49
+ def redirect_to(url)
50
+ render(status: 302, headers: { 'Location' => url })
51
+ end
52
+
53
+ def write(data)
54
+ @socket.write(data) unless @socket.closed?
55
+ end
56
+
57
+ def close
58
+ @socket.close unless @socket.closed?
59
+ end
60
+
61
+ def closed?
62
+ @socket.closed?
63
+ end
64
+ end
65
+ end
@@ -2,16 +2,16 @@
2
2
  # DO NOT EDIT THIS FILE DIRECTLY
3
3
  # Instead, edit Jeweler::Tasks in Rakefile, and run 'rake gemspec'
4
4
  # -*- encoding: utf-8 -*-
5
- # stub: rails-threaded-proxy 0.4.1 ruby lib
5
+ # stub: rails-threaded-proxy 0.5.0 ruby lib
6
6
 
7
7
  Gem::Specification.new do |s|
8
8
  s.name = "rails-threaded-proxy".freeze
9
- s.version = "0.4.1".freeze
9
+ s.version = "0.5.0".freeze
10
10
 
11
11
  s.required_rubygems_version = Gem::Requirement.new(">= 0".freeze) if s.respond_to? :required_rubygems_version=
12
12
  s.require_paths = ["lib".freeze]
13
13
  s.authors = ["Michael Nutt".freeze]
14
- s.date = "2024-10-17"
14
+ s.date = "2024-10-18"
15
15
  s.description = "Threaded reverse proxy for Ruby on Rails".freeze
16
16
  s.email = "michael@nuttnet.net".freeze
17
17
  s.extra_rdoc_files = [
@@ -35,9 +35,11 @@ Gem::Specification.new do |s|
35
35
  "lib/threaded_proxy/client.rb",
36
36
  "lib/threaded_proxy/controller.rb",
37
37
  "lib/threaded_proxy/http.rb",
38
+ "lib/threaded_proxy/socket_responder.rb",
38
39
  "rails-threaded-proxy.gemspec",
39
40
  "spec/spec_helper.rb",
40
- "spec/threaded_proxy/client_spec.rb"
41
+ "spec/threaded_proxy/client_spec.rb",
42
+ "spec/threaded_proxy/controller_spec.rb"
41
43
  ]
42
44
  s.homepage = "http://github.com/mnutt/rails-threaded-proxy".freeze
43
45
  s.licenses = ["MIT".freeze]
@@ -5,6 +5,15 @@ require 'json'
5
5
 
6
6
  BACKEND_STUB_PORT = 38_293
7
7
 
8
+ def parse_raw_response(raw_response)
9
+ status, rest = raw_response.split("\r\n", 2)
10
+ headers, body = rest.split("\r\n\r\n", 2)
11
+
12
+ parsed_headers = headers.split("\r\n").map { |h| h.split(': ', 2) }.to_h
13
+
14
+ [status, parsed_headers, body]
15
+ end
16
+
8
17
  RSpec.describe ThreadedProxy::Client do
9
18
  before(:all) do
10
19
  @backend_server = WEBrick::HTTPServer.new(Port: BACKEND_STUB_PORT,
@@ -50,15 +59,199 @@ RSpec.describe ThreadedProxy::Client do
50
59
  body: 'hello world')
51
60
  client.start(socket)
52
61
 
53
- status, rest = socket.string.split("\r\n", 2)
54
- headers, body = rest.split("\r\n\r\n", 2)
62
+ status, headers, body = parse_raw_response(socket.string)
55
63
 
56
64
  parsed_body = JSON.parse(body)
57
- parsed_headers = headers.split("\r\n").map { |h| h.split(': ', 2) }.to_h
58
65
 
59
66
  expect(status).to eq('HTTP/1.1 200 OK')
60
- expect(parsed_headers['content-type']).to eq('application/json')
67
+ expect(headers['content-type']).to eq('application/json')
61
68
  expect(parsed_body['path']).to eq('/post')
62
69
  expect(parsed_body['headers']['content-length']).to eq(['11'])
63
70
  end
71
+
72
+ describe 'callbacks' do
73
+ describe 'on_headers' do
74
+ it 'proxies a request and modifies the response headers' do
75
+ socket = StringIO.new
76
+
77
+ client = ThreadedProxy::Client.new("http://localhost:#{BACKEND_STUB_PORT}/get") do |config|
78
+ config.on_headers do |response|
79
+ response['X-Test'] = 'test'
80
+ end
81
+ end
82
+ client.start(socket)
83
+
84
+ status, headers, body = parse_raw_response(socket.string)
85
+
86
+ expect(status).to eq('HTTP/1.1 200 OK')
87
+ expect(headers['x-test']).to eq('test')
88
+ expect(headers['connection']).to eq('close')
89
+ expect(body).to eq('Received request: /get')
90
+ end
91
+ end
92
+
93
+ describe 'on_complete' do
94
+ it 'fires when the request is successful' do
95
+ socket = StringIO.new
96
+ received_client_response = nil
97
+
98
+ client = ThreadedProxy::Client.new("http://localhost:#{BACKEND_STUB_PORT}/get") do |config|
99
+ config.on_complete do |client_response|
100
+ received_client_response = client_response
101
+ end
102
+ end
103
+ client.start(socket)
104
+
105
+ expect(received_client_response.code).to eq('200')
106
+ end
107
+ end
108
+
109
+ describe 'on_error' do
110
+ it 'fires when the request is unsuccessful' do
111
+ socket = StringIO.new
112
+ received_error = nil
113
+
114
+ client = ThreadedProxy::Client.new('http://localhost:9999') do |config|
115
+ config.on_error do |e|
116
+ received_error = e
117
+ end
118
+ end
119
+ client.start(socket)
120
+
121
+ expect(received_error).to be_a_kind_of(Errno::ECONNREFUSED)
122
+
123
+ status, headers, body = parse_raw_response(socket.string)
124
+ expect(status).to eq('HTTP/1.1 500 Internal Server Error')
125
+ expect(headers['Content-Type']).to eq('text/plain; charset=utf-8')
126
+ expect(body).to eq('Internal Server Error')
127
+ end
128
+
129
+ it 'returns custom response on error' do
130
+ socket = StringIO.new
131
+ received_error = nil
132
+
133
+ client = ThreadedProxy::Client.new('http://localhost:9999') do |config|
134
+ config.on_error do |e, response|
135
+ response.render status: 404, text: 'Custom error'
136
+ received_error = e
137
+ end
138
+ end
139
+ client.start(socket)
140
+
141
+ status, headers, body = parse_raw_response(socket.string)
142
+ expect(status).to eq('HTTP/1.1 404 Not Found')
143
+ expect(headers['Content-Type']).to eq('text/plain; charset=utf-8')
144
+ expect(body).to eq('Custom error')
145
+ expect(received_error).to be_a_kind_of(Errno::ECONNREFUSED)
146
+ end
147
+ end
148
+
149
+ describe 'on_response' do
150
+ it 'proxies a request and lets caller send response' do
151
+ socket = StringIO.new
152
+
153
+ client = ThreadedProxy::Client.new("http://localhost:#{BACKEND_STUB_PORT}/get") do |config|
154
+ config.on_response do |client_response, response|
155
+ response.render status: 200, json: { body: client_response.body }, headers: { 'x-passed': 'yes' }
156
+ end
157
+ end
158
+ client.start(socket)
159
+
160
+ status, headers, body = parse_raw_response(socket.string)
161
+
162
+ parsed_body = JSON.parse(body)
163
+
164
+ expect(status).to eq('HTTP/1.1 200 OK')
165
+ expect(headers['Content-Type']).to eq('application/json; charset=utf-8')
166
+ expect(headers['x-passed']).to eq('yes')
167
+ expect(parsed_body['body']).to eq('Received request: /get')
168
+ end
169
+
170
+ it 'accepts IO objects as the body' do
171
+ socket = StringIO.new
172
+
173
+ client = ThreadedProxy::Client.new("http://localhost:#{BACKEND_STUB_PORT}/get") do |config|
174
+ config.on_response do |_client_response, response|
175
+ response.render status: 200, body: StringIO.new('this is IO')
176
+ end
177
+ end
178
+ client.start(socket)
179
+
180
+ status, _headers, body = parse_raw_response(socket.string)
181
+ expect(status).to eq('HTTP/1.1 200 OK')
182
+ expect(body).to eq('this is IO')
183
+ end
184
+
185
+ it 'accepts json body' do
186
+ socket = StringIO.new
187
+
188
+ client = ThreadedProxy::Client.new("http://localhost:#{BACKEND_STUB_PORT}/get") do |config|
189
+ config.on_response do |_client_response, response|
190
+ response.render status: 200, json: { key: 'value' }
191
+ end
192
+ end
193
+ client.start(socket)
194
+
195
+ status, headers, body = parse_raw_response(socket.string)
196
+
197
+ parsed_body = JSON.parse(body)
198
+
199
+ expect(status).to eq('HTTP/1.1 200 OK')
200
+ expect(headers['Content-Type']).to eq('application/json; charset=utf-8')
201
+ expect(parsed_body['key']).to eq('value')
202
+ end
203
+
204
+ it 'redirects to a URL' do
205
+ socket = StringIO.new
206
+
207
+ client = ThreadedProxy::Client.new("http://localhost:#{BACKEND_STUB_PORT}/get") do |config|
208
+ config.on_response do |_client_response, response|
209
+ response.redirect_to('http://example.com')
210
+ end
211
+ end
212
+ client.start(socket)
213
+
214
+ status, headers, _body = parse_raw_response(socket.string)
215
+
216
+ expect(status).to eq('HTTP/1.1 302 Found')
217
+ expect(headers['Location']).to eq('http://example.com')
218
+ end
219
+
220
+ it 'handles errors in on_response' do
221
+ socket = StringIO.new
222
+ received_error = nil
223
+
224
+ client = ThreadedProxy::Client.new("http://localhost:#{BACKEND_STUB_PORT}/get") do |config|
225
+ config.on_response do |_client_response, _response|
226
+ raise 'error in on_response'
227
+ end
228
+
229
+ config.on_error do |e|
230
+ received_error = e
231
+ end
232
+ end
233
+
234
+ client.start(socket)
235
+
236
+ status, headers, body = parse_raw_response(socket.string)
237
+
238
+ expect(status).to eq('HTTP/1.1 500 Internal Server Error')
239
+ expect(headers['Content-Type']).to eq('text/plain; charset=utf-8')
240
+ expect(body).to eq('Internal Server Error')
241
+ expect(received_error.message).to eq('error in on_response')
242
+ end
243
+
244
+ it 'errors if on_response reads the body but does not render a response' do
245
+ socket = StringIO.new
246
+
247
+ client = ThreadedProxy::Client.new("http://localhost:#{BACKEND_STUB_PORT}/get") do |config|
248
+ config.on_response do |client_response, _response|
249
+ client_response.body
250
+ end
251
+ end
252
+
253
+ expect { client.start(socket) }.to raise_error(ThreadedProxy::ResponseBodyAlreadyConsumedError)
254
+ end
255
+ end
256
+ end
64
257
  end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails-threaded-proxy'
4
+
5
+ class TestController
6
+ include ThreadedProxy::Controller
7
+
8
+ attr_accessor :request
9
+ end
10
+
11
+ RSpec.describe ThreadedProxy::Controller do
12
+ let(:request) { double(env: {}) }
13
+ let(:controller) do
14
+ TestController.new.tap do |controller|
15
+ controller.request = request
16
+ end
17
+ end
18
+
19
+ describe '#proxy_options_from_request' do
20
+ subject { controller.send(:proxy_options_from_request) }
21
+ let(:body_stream) { StringIO.new('HELLO') }
22
+
23
+ describe 'when the request is chunked' do
24
+ let(:request) { double(body_stream:, env: { 'HTTP_TRANSFER_ENCODING' => 'chunked' }) }
25
+
26
+ it 'sets the Transfer-Encoding header' do
27
+ expect(subject).to include(headers: { 'Transfer-Encoding' => 'chunked' },
28
+ body: body_stream)
29
+ end
30
+ end
31
+
32
+ describe 'when the request is not chunked' do
33
+ let(:request) { double(body_stream:, env: { 'CONTENT_LENGTH' => '5', 'CONTENT_TYPE' => 'application/json' }) }
34
+
35
+ it 'sets the Content-Length header' do
36
+ expect(subject).to include(headers: { 'content-length' => '5',
37
+ 'Content-Type' => 'application/json' },
38
+ body: body_stream)
39
+ end
40
+ end
41
+
42
+ describe 'when the request is not chunked and has no content-length' do
43
+ let(:request) { double(body_stream:, env: {}) }
44
+
45
+ it 'raises an error' do
46
+ expect { subject }.to raise_error('Cannot proxy a non-chunked POST request without content-length')
47
+ end
48
+ end
49
+ end
50
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails-threaded-proxy
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.1
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michael Nutt
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2024-10-17 00:00:00.000000000 Z
11
+ date: 2024-10-18 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: actionpack
@@ -160,9 +160,11 @@ files:
160
160
  - lib/threaded_proxy/client.rb
161
161
  - lib/threaded_proxy/controller.rb
162
162
  - lib/threaded_proxy/http.rb
163
+ - lib/threaded_proxy/socket_responder.rb
163
164
  - rails-threaded-proxy.gemspec
164
165
  - spec/spec_helper.rb
165
166
  - spec/threaded_proxy/client_spec.rb
167
+ - spec/threaded_proxy/controller_spec.rb
166
168
  homepage: http://github.com/mnutt/rails-threaded-proxy
167
169
  licenses:
168
170
  - MIT