rails-threaded-proxy 0.4.0 → 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: 2aae2ccc6678cd9afd9e3068a996bfe1d163f0569512a9e37b958c749aeb8096
4
- data.tar.gz: 14079d5dfc8a7319c54469d859d2cb5e055ba4fae94d80cebda8cb9968bcd076
3
+ metadata.gz: 8d77c14092a49dc2e231f233da21a934600a5159aabbc6bbb40294404c8e49d7
4
+ data.tar.gz: 8590d4359939c617098a036435c340623de538fcb5a65bbd15fe1b72726be86b
5
5
  SHA512:
6
- metadata.gz: eb6a536989c9b554b79ce943ed143e65a1985b7d535dac729cc7b90f01961eda02325134201e6a027d34775cafde12aa084264cce5a9c59c4c88b9b9a31b147f
7
- data.tar.gz: a4c6056afd3971cf18cd9d2ab5e2e3739b0c76a922c655024d1e34f00069b8cbe42f376c1611c040948798b767663e1a0eb7ad7ec6753af90b4e0fc3727808f9
6
+ metadata.gz: 7704567ef587afce79dbf21ce20f67add2e04c87cbc1efb7fbb3491cc7a95e01e05a8b7881917a64073caca7f4769c07814d22038f06fce045aa80b07f00b501
7
+ data.tar.gz: 025e8d6eda736b0ffa5c8f8e97d47941f64b06081b4a3a2164c6288a337b50d7325d64971c89fab35622f9c383dad0941584f1e1e4bf80d2a9c99bc4be20501b
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.4.0
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,19 +2,18 @@
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.0 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.0".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-15"
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
- s.executables = ["bundle".freeze, "htmldiff".freeze, "jeweler".freeze, "ldiff".freeze, "nokogiri".freeze, "racc".freeze, "rackup".freeze, "rake".freeze, "rdoc".freeze, "ri".freeze, "rspec".freeze, "rubocop".freeze, "semver".freeze]
18
17
  s.extra_rdoc_files = [
19
18
  "LICENSE",
20
19
  "README.md"
@@ -30,28 +29,17 @@ Gem::Specification.new do |s|
30
29
  "README.md",
31
30
  "Rakefile",
32
31
  "VERSION",
33
- "bin/bundle",
34
- "bin/htmldiff",
35
- "bin/jeweler",
36
- "bin/ldiff",
37
- "bin/nokogiri",
38
- "bin/racc",
39
- "bin/rackup",
40
- "bin/rake",
41
- "bin/rdoc",
42
- "bin/ri",
43
- "bin/rspec",
44
- "bin/rubocop",
45
- "bin/semver",
46
32
  "lib/rails-threaded-proxy.rb",
47
33
  "lib/threaded-proxy.rb",
48
34
  "lib/threaded_proxy.rb",
49
35
  "lib/threaded_proxy/client.rb",
50
36
  "lib/threaded_proxy/controller.rb",
51
37
  "lib/threaded_proxy/http.rb",
38
+ "lib/threaded_proxy/socket_responder.rb",
52
39
  "rails-threaded-proxy.gemspec",
53
40
  "spec/spec_helper.rb",
54
- "spec/threaded_proxy/client_spec.rb"
41
+ "spec/threaded_proxy/client_spec.rb",
42
+ "spec/threaded_proxy/controller_spec.rb"
55
43
  ]
56
44
  s.homepage = "http://github.com/mnutt/rails-threaded-proxy".freeze
57
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.0
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-15 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
@@ -138,20 +138,7 @@ dependencies:
138
138
  version: '0'
139
139
  description: Threaded reverse proxy for Ruby on Rails
140
140
  email: michael@nuttnet.net
141
- executables:
142
- - bundle
143
- - htmldiff
144
- - jeweler
145
- - ldiff
146
- - nokogiri
147
- - racc
148
- - rackup
149
- - rake
150
- - rdoc
151
- - ri
152
- - rspec
153
- - rubocop
154
- - semver
141
+ executables: []
155
142
  extensions: []
156
143
  extra_rdoc_files:
157
144
  - LICENSE
@@ -167,28 +154,17 @@ files:
167
154
  - README.md
168
155
  - Rakefile
169
156
  - VERSION
170
- - bin/bundle
171
- - bin/htmldiff
172
- - bin/jeweler
173
- - bin/ldiff
174
- - bin/nokogiri
175
- - bin/racc
176
- - bin/rackup
177
- - bin/rake
178
- - bin/rdoc
179
- - bin/ri
180
- - bin/rspec
181
- - bin/rubocop
182
- - bin/semver
183
157
  - lib/rails-threaded-proxy.rb
184
158
  - lib/threaded-proxy.rb
185
159
  - lib/threaded_proxy.rb
186
160
  - lib/threaded_proxy/client.rb
187
161
  - lib/threaded_proxy/controller.rb
188
162
  - lib/threaded_proxy/http.rb
163
+ - lib/threaded_proxy/socket_responder.rb
189
164
  - rails-threaded-proxy.gemspec
190
165
  - spec/spec_helper.rb
191
166
  - spec/threaded_proxy/client_spec.rb
167
+ - spec/threaded_proxy/controller_spec.rb
192
168
  homepage: http://github.com/mnutt/rails-threaded-proxy
193
169
  licenses:
194
170
  - MIT
data/bin/bundle DELETED
@@ -1,109 +0,0 @@
1
- #!/usr/bin/env ruby
2
- # frozen_string_literal: true
3
-
4
- #
5
- # This file was generated by Bundler.
6
- #
7
- # The application 'bundle' is installed as part of a gem, and
8
- # this file is here to facilitate running it.
9
- #
10
-
11
- require "rubygems"
12
-
13
- m = Module.new do
14
- module_function
15
-
16
- def invoked_as_script?
17
- File.expand_path($0) == File.expand_path(__FILE__)
18
- end
19
-
20
- def env_var_version
21
- ENV["BUNDLER_VERSION"]
22
- end
23
-
24
- def cli_arg_version
25
- return unless invoked_as_script? # don't want to hijack other binstubs
26
- return unless "update".start_with?(ARGV.first || " ") # must be running `bundle update`
27
- bundler_version = nil
28
- update_index = nil
29
- ARGV.each_with_index do |a, i|
30
- if update_index && update_index.succ == i && a.match?(Gem::Version::ANCHORED_VERSION_PATTERN)
31
- bundler_version = a
32
- end
33
- next unless a =~ /\A--bundler(?:[= ](#{Gem::Version::VERSION_PATTERN}))?\z/
34
- bundler_version = $1
35
- update_index = i
36
- end
37
- bundler_version
38
- end
39
-
40
- def gemfile
41
- gemfile = ENV["BUNDLE_GEMFILE"]
42
- return gemfile if gemfile && !gemfile.empty?
43
-
44
- File.expand_path("../Gemfile", __dir__)
45
- end
46
-
47
- def lockfile
48
- lockfile =
49
- case File.basename(gemfile)
50
- when "gems.rb" then gemfile.sub(/\.rb$/, ".locked")
51
- else "#{gemfile}.lock"
52
- end
53
- File.expand_path(lockfile)
54
- end
55
-
56
- def lockfile_version
57
- return unless File.file?(lockfile)
58
- lockfile_contents = File.read(lockfile)
59
- return unless lockfile_contents =~ /\n\nBUNDLED WITH\n\s{2,}(#{Gem::Version::VERSION_PATTERN})\n/
60
- Regexp.last_match(1)
61
- end
62
-
63
- def bundler_requirement
64
- @bundler_requirement ||=
65
- env_var_version ||
66
- cli_arg_version ||
67
- bundler_requirement_for(lockfile_version)
68
- end
69
-
70
- def bundler_requirement_for(version)
71
- return "#{Gem::Requirement.default}.a" unless version
72
-
73
- bundler_gem_version = Gem::Version.new(version)
74
-
75
- bundler_gem_version.approximate_recommendation
76
- end
77
-
78
- def load_bundler!
79
- ENV["BUNDLE_GEMFILE"] ||= gemfile
80
-
81
- activate_bundler
82
- end
83
-
84
- def activate_bundler
85
- gem_error = activation_error_handling do
86
- gem "bundler", bundler_requirement
87
- end
88
- return if gem_error.nil?
89
- require_error = activation_error_handling do
90
- require "bundler/version"
91
- end
92
- return if require_error.nil? && Gem::Requirement.new(bundler_requirement).satisfied_by?(Gem::Version.new(Bundler::VERSION))
93
- warn "Activating bundler (#{bundler_requirement}) failed:\n#{gem_error.message}\n\nTo install the version of bundler this project requires, run `gem install bundler -v '#{bundler_requirement}'`"
94
- exit 42
95
- end
96
-
97
- def activation_error_handling
98
- yield
99
- nil
100
- rescue StandardError, LoadError => e
101
- e
102
- end
103
- end
104
-
105
- m.load_bundler!
106
-
107
- if m.invoked_as_script?
108
- load Gem.bin_path("bundler", "bundle")
109
- end
data/bin/htmldiff DELETED
@@ -1,27 +0,0 @@
1
- #!/usr/bin/env ruby
2
- # frozen_string_literal: true
3
-
4
- #
5
- # This file was generated by Bundler.
6
- #
7
- # The application 'htmldiff' is installed as part of a gem, and
8
- # this file is here to facilitate running it.
9
- #
10
-
11
- ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
12
-
13
- bundle_binstub = File.expand_path("bundle", __dir__)
14
-
15
- if File.file?(bundle_binstub)
16
- if File.read(bundle_binstub, 300).include?("This file was generated by Bundler")
17
- load(bundle_binstub)
18
- else
19
- abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run.
20
- Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.")
21
- end
22
- end
23
-
24
- require "rubygems"
25
- require "bundler/setup"
26
-
27
- load Gem.bin_path("diff-lcs", "htmldiff")
data/bin/jeweler DELETED
@@ -1,27 +0,0 @@
1
- #!/usr/bin/env ruby
2
- # frozen_string_literal: true
3
-
4
- #
5
- # This file was generated by Bundler.
6
- #
7
- # The application 'jeweler' is installed as part of a gem, and
8
- # this file is here to facilitate running it.
9
- #
10
-
11
- ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
12
-
13
- bundle_binstub = File.expand_path("bundle", __dir__)
14
-
15
- if File.file?(bundle_binstub)
16
- if File.read(bundle_binstub, 300).include?("This file was generated by Bundler")
17
- load(bundle_binstub)
18
- else
19
- abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run.
20
- Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.")
21
- end
22
- end
23
-
24
- require "rubygems"
25
- require "bundler/setup"
26
-
27
- load Gem.bin_path("jeweler", "jeweler")
data/bin/ldiff DELETED
@@ -1,27 +0,0 @@
1
- #!/usr/bin/env ruby
2
- # frozen_string_literal: true
3
-
4
- #
5
- # This file was generated by Bundler.
6
- #
7
- # The application 'ldiff' is installed as part of a gem, and
8
- # this file is here to facilitate running it.
9
- #
10
-
11
- ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
12
-
13
- bundle_binstub = File.expand_path("bundle", __dir__)
14
-
15
- if File.file?(bundle_binstub)
16
- if File.read(bundle_binstub, 300).include?("This file was generated by Bundler")
17
- load(bundle_binstub)
18
- else
19
- abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run.
20
- Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.")
21
- end
22
- end
23
-
24
- require "rubygems"
25
- require "bundler/setup"
26
-
27
- load Gem.bin_path("diff-lcs", "ldiff")
data/bin/nokogiri DELETED
@@ -1,27 +0,0 @@
1
- #!/usr/bin/env ruby
2
- # frozen_string_literal: true
3
-
4
- #
5
- # This file was generated by Bundler.
6
- #
7
- # The application 'nokogiri' is installed as part of a gem, and
8
- # this file is here to facilitate running it.
9
- #
10
-
11
- ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
12
-
13
- bundle_binstub = File.expand_path("bundle", __dir__)
14
-
15
- if File.file?(bundle_binstub)
16
- if File.read(bundle_binstub, 300).include?("This file was generated by Bundler")
17
- load(bundle_binstub)
18
- else
19
- abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run.
20
- Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.")
21
- end
22
- end
23
-
24
- require "rubygems"
25
- require "bundler/setup"
26
-
27
- load Gem.bin_path("nokogiri", "nokogiri")
data/bin/racc DELETED
@@ -1,27 +0,0 @@
1
- #!/usr/bin/env ruby
2
- # frozen_string_literal: true
3
-
4
- #
5
- # This file was generated by Bundler.
6
- #
7
- # The application 'racc' is installed as part of a gem, and
8
- # this file is here to facilitate running it.
9
- #
10
-
11
- ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
12
-
13
- bundle_binstub = File.expand_path("bundle", __dir__)
14
-
15
- if File.file?(bundle_binstub)
16
- if File.read(bundle_binstub, 300).include?("This file was generated by Bundler")
17
- load(bundle_binstub)
18
- else
19
- abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run.
20
- Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.")
21
- end
22
- end
23
-
24
- require "rubygems"
25
- require "bundler/setup"
26
-
27
- load Gem.bin_path("racc", "racc")
data/bin/rackup DELETED
@@ -1,27 +0,0 @@
1
- #!/usr/bin/env ruby
2
- # frozen_string_literal: true
3
-
4
- #
5
- # This file was generated by Bundler.
6
- #
7
- # The application 'rackup' is installed as part of a gem, and
8
- # this file is here to facilitate running it.
9
- #
10
-
11
- ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
12
-
13
- bundle_binstub = File.expand_path("bundle", __dir__)
14
-
15
- if File.file?(bundle_binstub)
16
- if File.read(bundle_binstub, 300).include?("This file was generated by Bundler")
17
- load(bundle_binstub)
18
- else
19
- abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run.
20
- Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.")
21
- end
22
- end
23
-
24
- require "rubygems"
25
- require "bundler/setup"
26
-
27
- load Gem.bin_path("rack", "rackup")
data/bin/rake DELETED
@@ -1,27 +0,0 @@
1
- #!/usr/bin/env ruby
2
- # frozen_string_literal: true
3
-
4
- #
5
- # This file was generated by Bundler.
6
- #
7
- # The application 'rake' is installed as part of a gem, and
8
- # this file is here to facilitate running it.
9
- #
10
-
11
- ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
12
-
13
- bundle_binstub = File.expand_path("bundle", __dir__)
14
-
15
- if File.file?(bundle_binstub)
16
- if File.read(bundle_binstub, 300).include?("This file was generated by Bundler")
17
- load(bundle_binstub)
18
- else
19
- abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run.
20
- Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.")
21
- end
22
- end
23
-
24
- require "rubygems"
25
- require "bundler/setup"
26
-
27
- load Gem.bin_path("rake", "rake")
data/bin/rdoc DELETED
@@ -1,27 +0,0 @@
1
- #!/usr/bin/env ruby
2
- # frozen_string_literal: true
3
-
4
- #
5
- # This file was generated by Bundler.
6
- #
7
- # The application 'rdoc' is installed as part of a gem, and
8
- # this file is here to facilitate running it.
9
- #
10
-
11
- ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
12
-
13
- bundle_binstub = File.expand_path("bundle", __dir__)
14
-
15
- if File.file?(bundle_binstub)
16
- if File.read(bundle_binstub, 300).include?("This file was generated by Bundler")
17
- load(bundle_binstub)
18
- else
19
- abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run.
20
- Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.")
21
- end
22
- end
23
-
24
- require "rubygems"
25
- require "bundler/setup"
26
-
27
- load Gem.bin_path("rdoc", "rdoc")
data/bin/ri DELETED
@@ -1,27 +0,0 @@
1
- #!/usr/bin/env ruby
2
- # frozen_string_literal: true
3
-
4
- #
5
- # This file was generated by Bundler.
6
- #
7
- # The application 'ri' is installed as part of a gem, and
8
- # this file is here to facilitate running it.
9
- #
10
-
11
- ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
12
-
13
- bundle_binstub = File.expand_path("bundle", __dir__)
14
-
15
- if File.file?(bundle_binstub)
16
- if File.read(bundle_binstub, 300).include?("This file was generated by Bundler")
17
- load(bundle_binstub)
18
- else
19
- abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run.
20
- Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.")
21
- end
22
- end
23
-
24
- require "rubygems"
25
- require "bundler/setup"
26
-
27
- load Gem.bin_path("rdoc", "ri")
data/bin/rspec DELETED
@@ -1,27 +0,0 @@
1
- #!/usr/bin/env ruby
2
- # frozen_string_literal: true
3
-
4
- #
5
- # This file was generated by Bundler.
6
- #
7
- # The application 'rspec' is installed as part of a gem, and
8
- # this file is here to facilitate running it.
9
- #
10
-
11
- ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
12
-
13
- bundle_binstub = File.expand_path("bundle", __dir__)
14
-
15
- if File.file?(bundle_binstub)
16
- if File.read(bundle_binstub, 300).include?("This file was generated by Bundler")
17
- load(bundle_binstub)
18
- else
19
- abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run.
20
- Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.")
21
- end
22
- end
23
-
24
- require "rubygems"
25
- require "bundler/setup"
26
-
27
- load Gem.bin_path("rspec-core", "rspec")
data/bin/rubocop DELETED
@@ -1,27 +0,0 @@
1
- #!/usr/bin/env ruby
2
- # frozen_string_literal: true
3
-
4
- #
5
- # This file was generated by Bundler.
6
- #
7
- # The application 'rubocop' is installed as part of a gem, and
8
- # this file is here to facilitate running it.
9
- #
10
-
11
- ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__)
12
-
13
- bundle_binstub = File.expand_path('bundle', __dir__)
14
-
15
- if File.file?(bundle_binstub)
16
- if File.read(bundle_binstub, 300).include?('This file was generated by Bundler')
17
- load(bundle_binstub)
18
- else
19
- abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run.
20
- Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.")
21
- end
22
- end
23
-
24
- require 'rubygems'
25
- require 'bundler/setup'
26
-
27
- load Gem.bin_path('rubocop', 'rubocop')
data/bin/semver DELETED
@@ -1,27 +0,0 @@
1
- #!/usr/bin/env ruby
2
- # frozen_string_literal: true
3
-
4
- #
5
- # This file was generated by Bundler.
6
- #
7
- # The application 'semver' is installed as part of a gem, and
8
- # this file is here to facilitate running it.
9
- #
10
-
11
- ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
12
-
13
- bundle_binstub = File.expand_path("bundle", __dir__)
14
-
15
- if File.file?(bundle_binstub)
16
- if File.read(bundle_binstub, 300).include?("This file was generated by Bundler")
17
- load(bundle_binstub)
18
- else
19
- abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run.
20
- Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.")
21
- end
22
- end
23
-
24
- require "rubygems"
25
- require "bundler/setup"
26
-
27
- load Gem.bin_path("semver2", "semver")