httpx 0.0.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.
Files changed (46) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +191 -0
  3. data/README.md +119 -0
  4. data/lib/httpx.rb +50 -0
  5. data/lib/httpx/buffer.rb +34 -0
  6. data/lib/httpx/callbacks.rb +32 -0
  7. data/lib/httpx/chainable.rb +51 -0
  8. data/lib/httpx/channel.rb +222 -0
  9. data/lib/httpx/channel/http1.rb +220 -0
  10. data/lib/httpx/channel/http2.rb +224 -0
  11. data/lib/httpx/client.rb +173 -0
  12. data/lib/httpx/connection.rb +74 -0
  13. data/lib/httpx/errors.rb +7 -0
  14. data/lib/httpx/extensions.rb +52 -0
  15. data/lib/httpx/headers.rb +152 -0
  16. data/lib/httpx/io.rb +240 -0
  17. data/lib/httpx/loggable.rb +11 -0
  18. data/lib/httpx/options.rb +138 -0
  19. data/lib/httpx/plugins/authentication.rb +14 -0
  20. data/lib/httpx/plugins/basic_authentication.rb +20 -0
  21. data/lib/httpx/plugins/compression.rb +123 -0
  22. data/lib/httpx/plugins/compression/brotli.rb +55 -0
  23. data/lib/httpx/plugins/compression/deflate.rb +50 -0
  24. data/lib/httpx/plugins/compression/gzip.rb +59 -0
  25. data/lib/httpx/plugins/cookies.rb +63 -0
  26. data/lib/httpx/plugins/digest_authentication.rb +141 -0
  27. data/lib/httpx/plugins/follow_redirects.rb +72 -0
  28. data/lib/httpx/plugins/h2c.rb +85 -0
  29. data/lib/httpx/plugins/proxy.rb +108 -0
  30. data/lib/httpx/plugins/proxy/http.rb +115 -0
  31. data/lib/httpx/plugins/proxy/socks4.rb +110 -0
  32. data/lib/httpx/plugins/proxy/socks5.rb +152 -0
  33. data/lib/httpx/plugins/push_promise.rb +67 -0
  34. data/lib/httpx/plugins/stream.rb +33 -0
  35. data/lib/httpx/registry.rb +88 -0
  36. data/lib/httpx/request.rb +222 -0
  37. data/lib/httpx/response.rb +225 -0
  38. data/lib/httpx/selector.rb +155 -0
  39. data/lib/httpx/timeout.rb +68 -0
  40. data/lib/httpx/transcoder.rb +12 -0
  41. data/lib/httpx/transcoder/body.rb +56 -0
  42. data/lib/httpx/transcoder/chunker.rb +38 -0
  43. data/lib/httpx/transcoder/form.rb +41 -0
  44. data/lib/httpx/transcoder/json.rb +36 -0
  45. data/lib/httpx/version.rb +5 -0
  46. metadata +150 -0
@@ -0,0 +1,222 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "forwardable"
4
+ require "httpx/io"
5
+ require "httpx/buffer"
6
+
7
+ module HTTPX
8
+ # The Channel entity can be watched for IO events.
9
+ #
10
+ # It contains the +io+ object to read/write from, and knows what to do when it can.
11
+ #
12
+ # It defers connecting until absolutely necessary. Connection should be triggered from
13
+ # the IO selector (until then, any request will be queued).
14
+ #
15
+ # A channel boots up its parser after connection is established. All pending requests
16
+ # will be redirected there after connection.
17
+ #
18
+ # A channel can be prevented from closing by the parser, that is, if there are pending
19
+ # requests. This will signal that the channel was prematurely closed, due to a possible
20
+ # number of conditions:
21
+ #
22
+ # * Remote peer closed the connection ("Connection: close");
23
+ # * Remote peer doesn't support pipelining;
24
+ #
25
+ # A channel may also route requests for a different host for which the +io+ was connected
26
+ # to, provided that the IP is the same and the port and scheme as well. This will allow to
27
+ # share the same socket to send HTTP/2 requests to different hosts.
28
+ # TODO: For this to succeed, the certificates sent by the servers to the client must be
29
+ # identical (or match both hosts).
30
+ #
31
+ class Channel
32
+ extend Forwardable
33
+ include Registry
34
+ include Loggable
35
+ include Callbacks
36
+
37
+ require "httpx/channel/http2"
38
+ require "httpx/channel/http1"
39
+
40
+ BUFFER_SIZE = 1 << 14
41
+
42
+ class << self
43
+ def by(uri, options)
44
+ io = case uri.scheme
45
+ when "http"
46
+ IO.registry("tcp").new(uri.host, uri.port, options)
47
+ when "https"
48
+ IO.registry("ssl").new(uri.host, uri.port, options)
49
+ else
50
+ raise Error, "#{uri}: #{uri.scheme}: unrecognized channel"
51
+ end
52
+ new(io, options)
53
+ end
54
+ end
55
+
56
+ def_delegator :@io, :closed?
57
+
58
+ def_delegator :@write_buffer, :empty?
59
+
60
+ def initialize(io, options)
61
+ @io = io
62
+ @options = Options.new(options)
63
+ @window_size = @options.window_size
64
+ @read_buffer = Buffer.new(BUFFER_SIZE)
65
+ @write_buffer = Buffer.new(BUFFER_SIZE)
66
+ @pending = []
67
+ @state = :idle
68
+ end
69
+
70
+ def match?(uri)
71
+ ip = begin
72
+ TCPSocket.getaddress(uri.host)
73
+ rescue StandardError
74
+ uri.host
75
+ end
76
+
77
+ ip == @io.ip &&
78
+ uri.port == @io.port &&
79
+ uri.scheme == @io.scheme
80
+ end
81
+
82
+ def interests
83
+ return :w if @state == :idle
84
+ readable = !@read_buffer.full?
85
+ writable = !@write_buffer.empty?
86
+ if readable
87
+ writable ? :rw : :r
88
+ else
89
+ writable ? :w : :r
90
+ end
91
+ end
92
+
93
+ def to_io
94
+ case @state
95
+ when :idle
96
+ transition(:open)
97
+ end
98
+ @io.to_io
99
+ end
100
+
101
+ def close(hard = false)
102
+ pr = @parser
103
+ transition(:closing)
104
+ if hard || (pr && pr.empty?)
105
+ pr.close
106
+ @parser = nil
107
+ else
108
+ transition(:idle)
109
+ @parser = pr
110
+ parser.reenqueue!
111
+ return
112
+ end
113
+ end
114
+
115
+ def reset
116
+ transition(:closing)
117
+ transition(:closed)
118
+ emit(:close)
119
+ end
120
+
121
+ def send(request, **args)
122
+ if @parser && !@write_buffer.full?
123
+ parser.send(request, **args)
124
+ else
125
+ @pending << [request, args]
126
+ end
127
+ end
128
+
129
+ def call
130
+ case @state
131
+ when :closed
132
+ return
133
+ when :closing
134
+ dwrite
135
+ transition(:closed)
136
+ emit(:close)
137
+ else
138
+ catch(:called) do
139
+ dread
140
+ dwrite
141
+ parser.consume
142
+ end
143
+ end
144
+ nil
145
+ end
146
+
147
+ def upgrade_parser(protocol)
148
+ @parser.reset if @parser
149
+ @parser = build_parser(protocol)
150
+ end
151
+
152
+ private
153
+
154
+ def dread(wsize = @window_size)
155
+ loop do
156
+ siz = @io.read(wsize, @read_buffer)
157
+ throw(:close, self) unless siz
158
+ return if siz.zero?
159
+ log { "READ: #{siz} bytes..." }
160
+ parser << @read_buffer.to_s
161
+ end
162
+ end
163
+
164
+ def dwrite
165
+ loop do
166
+ return if @write_buffer.empty?
167
+ siz = @io.write(@write_buffer)
168
+ throw(:close, self) unless siz
169
+ log { "WRITE: #{siz} bytes..." }
170
+ return if siz.zero?
171
+ end
172
+ end
173
+
174
+ def send_pending
175
+ while !@write_buffer.full? && (req_args = @pending.shift)
176
+ request, args = req_args
177
+ parser.send(request, **args)
178
+ end
179
+ end
180
+
181
+ def parser
182
+ @parser ||= build_parser
183
+ end
184
+
185
+ def build_parser(protocol = @io.protocol)
186
+ parser = registry(protocol).new(@write_buffer, @options)
187
+ parser.on(:response) do |*args|
188
+ emit(:response, *args)
189
+ end
190
+ parser.on(:promise) do |*args|
191
+ emit(:promise, *args)
192
+ end
193
+ # parser.inherit_callbacks(self)
194
+ parser.on(:complete) { throw(:close, self) }
195
+ parser.on(:close) do
196
+ transition(:closed)
197
+ emit(:close)
198
+ end
199
+ parser
200
+ end
201
+
202
+ def transition(nextstate)
203
+ case nextstate
204
+ # when :idle
205
+
206
+ when :open
207
+ return if @state == :closed
208
+ @io.connect
209
+ return if @io.closed?
210
+ send_pending
211
+ when :closing
212
+ return unless @state == :open
213
+ when :closed
214
+ return unless @state == :closing
215
+ return unless @write_buffer.empty?
216
+ @io.close
217
+ @read_buffer.clear
218
+ end
219
+ @state = nextstate
220
+ end
221
+ end
222
+ end
@@ -0,0 +1,220 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "http_parser"
4
+
5
+ module HTTPX
6
+ class Channel::HTTP1
7
+ include Callbacks
8
+ include Loggable
9
+
10
+ CRLF = "\r\n"
11
+
12
+ def initialize(buffer, options)
13
+ @options = Options.new(options)
14
+ @max_concurrent_requests = @options.max_concurrent_requests
15
+ @retries = options.max_retries
16
+ @parser = HTTP::Parser.new(self)
17
+ @parser.header_value_type = :arrays
18
+ @buffer = buffer
19
+ @version = [1, 1]
20
+ @pending = []
21
+ @requests = []
22
+ @has_response = false
23
+ end
24
+
25
+ def reset
26
+ @parser.reset!
27
+ @has_response = false
28
+ end
29
+
30
+ def close
31
+ reset
32
+ emit(:close)
33
+ end
34
+
35
+ def empty?
36
+ # this means that for every request there's an available
37
+ # partial response, so there are no in-flight requests waiting.
38
+ @requests.empty? || @requests.all? { |request| !request.response.nil? }
39
+ end
40
+
41
+ def <<(data)
42
+ @parser << data
43
+ dispatch if @has_response
44
+ end
45
+
46
+ def send(request, **)
47
+ if @requests.size >= @max_concurrent_requests
48
+ @pending << request
49
+ return
50
+ end
51
+ @requests << request unless @requests.include?(request)
52
+ handle(request)
53
+ end
54
+
55
+ def reenqueue!
56
+ requests = @requests.dup
57
+ @requests.clear
58
+ requests.each do |request|
59
+ send(request)
60
+ end
61
+ end
62
+
63
+ def consume
64
+ @requests.each do |request|
65
+ handle(request)
66
+ end
67
+ end
68
+
69
+ # HTTP Parser callbacks
70
+ #
71
+ # must be public methods, or else they won't be reachable
72
+
73
+ def on_message_begin
74
+ log(2) { "parsing begins" }
75
+ end
76
+
77
+ def on_headers_complete(h)
78
+ return on_trailer_headers_complete(h) if @parser_trailers
79
+ # Wait for fix: https://github.com/tmm1/http_parser.rb/issues/52
80
+ # callback is called 2 times when chunked
81
+ request = @requests.first
82
+ return if request.response
83
+
84
+ log(2) { "headers received" }
85
+ headers = @options.headers_class.new(h)
86
+ response = @options.response_class.new(@requests.last,
87
+ @parser.status_code,
88
+ @parser.http_version.join("."),
89
+ headers, @options)
90
+ log { "-> HEADLINE: #{response.status} HTTP/#{@parser.http_version.join(".")}" }
91
+ log { response.headers.each.map { |f, v| "-> HEADER: #{f}: #{v}" }.join("\n") }
92
+
93
+ request.response = response
94
+
95
+ @has_response = true if response.complete?
96
+ end
97
+
98
+ def on_body(chunk)
99
+ log { "-> DATA: #{chunk.bytesize} bytes..." }
100
+ log(2) { "-> #{chunk.inspect}" }
101
+ response = @requests.first.response
102
+
103
+ response << chunk
104
+
105
+ dispatch if response.complete?
106
+ end
107
+
108
+ def on_message_complete
109
+ log(2) { "parsing complete" }
110
+ request = @requests.first
111
+ response = request.response
112
+
113
+ if !@parser_trailers && response.headers.key?("trailer")
114
+ @parser_trailers = true
115
+ # this is needed, because the parser can't accept further headers.
116
+ # we need to reset it and artificially move it to receive headers state,
117
+ # hence the bogus headline
118
+ #
119
+ @parser.reset!
120
+ @parser << "#{request.verb.to_s.upcase} #{request.path} HTTP/#{response.version}#{CRLF}"
121
+ else
122
+ @has_response = true
123
+ end
124
+ end
125
+
126
+ def on_trailer_headers_complete(h)
127
+ response = @requests.first.response
128
+
129
+ response.merge_headers(h)
130
+ end
131
+
132
+ def dispatch
133
+ request = @requests.first
134
+ return handle(request) if request.expects?
135
+
136
+ @requests.shift
137
+ response = request.response
138
+ emit(:response, request, response)
139
+
140
+ if @parser.upgrade?
141
+ response << @parser.upgrade_data
142
+ throw(:called)
143
+ end
144
+ close
145
+ send(@pending.shift) unless @pending.empty?
146
+ return unless response.headers["connection"] == "close"
147
+ unless @requests.empty?
148
+ @requests.map { |r| r.transition(:idle) }
149
+ # server doesn't handle pipelining, and probably
150
+ # doesn't support keep-alive. Fallback to send only
151
+ # 1 keep alive request.
152
+ @max_concurrent_requests = 1
153
+ end
154
+ emit(:complete)
155
+ end
156
+
157
+ private
158
+
159
+ def set_request_headers(request)
160
+ request.headers["host"] ||= request.authority
161
+ request.headers["connection"] ||= "keep-alive"
162
+ if !request.headers.key?("content-length") &&
163
+ request.body.bytesize == Float::INFINITY
164
+ request.chunk!
165
+ end
166
+ end
167
+
168
+ def headline_uri(request)
169
+ request.path
170
+ end
171
+
172
+ def handle(request)
173
+ @has_response = false
174
+ set_request_headers(request)
175
+ catch(:buffer_full) do
176
+ request.transition(:headers)
177
+ join_headers(request) if request.state == :headers
178
+ request.transition(:body)
179
+ join_body(request) if request.state == :body
180
+ request.transition(:done)
181
+ end
182
+ end
183
+
184
+ def join_headers(request)
185
+ buffer = +""
186
+ buffer << "#{request.verb.to_s.upcase} #{headline_uri(request)} HTTP/#{@version.join(".")}" << CRLF
187
+ log { "<- HEADLINE: #{buffer.chomp.inspect}" }
188
+ @buffer << buffer
189
+ buffer.clear
190
+ request.headers.each do |field, value|
191
+ buffer << "#{capitalized(field)}: #{value}" << CRLF
192
+ log { "<- HEADER: #{buffer.chomp}" }
193
+ @buffer << buffer
194
+ buffer.clear
195
+ end
196
+ log { "<- " }
197
+ @buffer << CRLF
198
+ end
199
+
200
+ def join_body(request)
201
+ return if request.empty?
202
+ while (chunk = request.drain_body)
203
+ log { "<- DATA: #{chunk.bytesize} bytes..." }
204
+ log(2) { "<- #{chunk.inspect}" }
205
+ @buffer << chunk
206
+ throw(:buffer_full, request) if @buffer.full?
207
+ end
208
+ end
209
+
210
+ UPCASED = {
211
+ "www-authenticate" => "WWW-Authenticate",
212
+ "http2-settings" => "HTTP2-Settings",
213
+ }.freeze
214
+
215
+ def capitalized(field)
216
+ UPCASED[field] || field.to_s.split("-").map(&:capitalize).join("-")
217
+ end
218
+ end
219
+ Channel.register "http/1.1", Channel::HTTP1
220
+ end
@@ -0,0 +1,224 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "http/2"
4
+
5
+ module HTTPX
6
+ class Channel::HTTP2
7
+ include Callbacks
8
+ include Loggable
9
+
10
+ attr_reader :streams, :pending
11
+
12
+ def initialize(buffer, options)
13
+ @options = Options.new(options)
14
+ @max_concurrent_requests = @options.max_concurrent_requests
15
+ init_connection
16
+ @retries = options.max_retries
17
+ @pending = []
18
+ @streams = {}
19
+ @drains = {}
20
+ @buffer = buffer
21
+ end
22
+
23
+ def close
24
+ @connection.goaway
25
+ end
26
+
27
+ def empty?
28
+ @connection.state == :closed || @streams.empty?
29
+ end
30
+
31
+ def <<(data)
32
+ @connection << data
33
+ end
34
+
35
+ def send(request, **)
36
+ if @connection.active_stream_count >= @max_concurrent_requests
37
+ @pending << request
38
+ return
39
+ end
40
+ unless (stream = @streams[request])
41
+ stream = @connection.new_stream
42
+ handle_stream(stream, request)
43
+ @streams[request] = stream
44
+ end
45
+ handle(request, stream)
46
+ end
47
+
48
+ def reenqueue!
49
+ requests = @streams.keys
50
+ @streams.clear
51
+ init_connection
52
+ requests.each do |request|
53
+ send(request)
54
+ end
55
+ end
56
+
57
+ def consume
58
+ @streams.each do |request, stream|
59
+ handle(request, stream)
60
+ end
61
+ end
62
+
63
+ private
64
+
65
+ def headline_uri(request)
66
+ request.path
67
+ end
68
+
69
+ def set_request_headers(request); end
70
+
71
+ def handle(request, stream)
72
+ catch(:buffer_full) do
73
+ request.transition(:headers)
74
+ join_headers(stream, request) if request.state == :headers
75
+ request.transition(:body)
76
+ join_body(stream, request) if request.state == :body
77
+ request.transition(:done)
78
+ end
79
+ end
80
+
81
+ def init_connection
82
+ @connection = HTTP2::Client.new(@options.http2_settings)
83
+ @connection.on(:frame, &method(:on_frame))
84
+ @connection.on(:frame_sent, &method(:on_frame_sent))
85
+ @connection.on(:frame_received, &method(:on_frame_received))
86
+ @connection.on(:promise, &method(:on_promise))
87
+ @connection.on(:altsvc, &method(:on_altsvc))
88
+ @connection.on(:settings_ack, &method(:on_settings))
89
+ @connection.on(:goaway, &method(:on_close))
90
+ end
91
+
92
+ def handle_stream(stream, request)
93
+ stream.on(:close, &method(:on_stream_close).curry[stream, request])
94
+ stream.on(:half_close) do
95
+ log(2, "#{stream.id}: ") { "waiting for response..." }
96
+ end
97
+ # stream.on(:altsvc)
98
+ stream.on(:headers, &method(:on_stream_headers).curry[stream, request])
99
+ stream.on(:data, &method(:on_stream_data).curry[stream, request])
100
+ end
101
+
102
+ def join_headers(stream, request)
103
+ set_request_headers(request)
104
+ headers = {}
105
+ headers[":scheme"] = request.scheme
106
+ headers[":method"] = request.verb.to_s.upcase
107
+ headers[":path"] = headline_uri(request)
108
+ headers[":authority"] = request.authority
109
+ headers = headers.merge(request.headers)
110
+ log(1, "#{stream.id}: ") do
111
+ headers.map { |k, v| "-> HEADER: #{k}: #{v}" }.join("\n")
112
+ end
113
+ stream.headers(headers, end_stream: request.empty?)
114
+ end
115
+
116
+ def join_body(stream, request)
117
+ chunk = @drains.delete(request) || request.drain_body
118
+ while chunk
119
+ next_chunk = request.drain_body
120
+ log(1, "#{stream.id}: ") { "-> DATA: #{chunk.bytesize} bytes..." }
121
+ log(2, "#{stream.id}: ") { "-> #{chunk.inspect}" }
122
+ stream.data(chunk, end_stream: !next_chunk)
123
+ if next_chunk && @buffer.full?
124
+ @drains[request] = next_chunk
125
+ throw(:buffer_full)
126
+ end
127
+ chunk = next_chunk
128
+ end
129
+ end
130
+
131
+ ######
132
+ # HTTP/2 Callbacks
133
+ ######
134
+
135
+ def on_stream_headers(stream, request, h)
136
+ log(stream.id) do
137
+ h.map { |k, v| "<- HEADER: #{k}: #{v}" }.join("\n")
138
+ end
139
+ _, status = h.shift
140
+ headers = @options.headers_class.new(h)
141
+ response = @options.response_class.new(request, status, "2.0", headers, @options)
142
+ request.response = response
143
+ @streams[request] = stream
144
+ end
145
+
146
+ def on_stream_data(stream, request, data)
147
+ log(1, "#{stream.id}: ") { "<- DATA: #{data.bytesize} bytes..." }
148
+ log(2, "#{stream.id}: ") { "<- #{data.inspect}" }
149
+ request.response << data
150
+ end
151
+
152
+ def on_stream_close(stream, request, error)
153
+ return handle(request, stream) if request.expects?
154
+ response = request.response || ErrorResponse.new(error, @retries)
155
+ emit(:response, request, response)
156
+ log(2, "#{stream.id}: ") { "closing stream" }
157
+
158
+ @streams.delete(request)
159
+ send(@pending.shift) unless @pending.empty?
160
+ end
161
+
162
+ def on_frame(bytes)
163
+ @buffer << bytes
164
+ end
165
+
166
+ def on_settings(*)
167
+ @max_concurrent_requests = [@max_concurrent_requests,
168
+ @connection.remote_settings[:settings_max_concurrent_streams]].min
169
+ end
170
+
171
+ def on_close(*)
172
+ return unless @connection.state == :closed && @connection.active_stream_count.zero?
173
+ emit(:complete)
174
+ end
175
+
176
+ def on_frame_sent(frame)
177
+ log(2, "#{frame[:stream]}: ") { "frame was sent!" }
178
+ log(2, "#{frame[:stream]}: ") do
179
+ case frame[:type]
180
+ when :data
181
+ frame.merge(payload: frame[:payload].bytesize).inspect
182
+ when :headers
183
+ "\e[33m#{frame.inspect}\e[0m"
184
+ else
185
+ frame.inspect
186
+ end
187
+ end
188
+ end
189
+
190
+ def on_frame_received(frame)
191
+ log(2, "#{frame[:stream]}: ") { "frame was received!" }
192
+ log(2, "#{frame[:stream]}: ") do
193
+ case frame[:type]
194
+ when :data
195
+ frame.merge(payload: frame[:payload].bytesize).inspect
196
+ else
197
+ frame.inspect
198
+ end
199
+ end
200
+ end
201
+
202
+ def on_altsvc(frame)
203
+ log(2, "#{frame[:stream]}: ") { "altsvc frame was received" }
204
+ log(2, "#{frame[:stream]}: ") { frame.inspect }
205
+ end
206
+
207
+ def on_promise(stream)
208
+ emit(:promise, self, stream)
209
+ end
210
+
211
+ def respond_to_missing?(meth, *args)
212
+ @connection.respond_to?(meth, *args) || super
213
+ end
214
+
215
+ def method_missing(meth, *args, &blk)
216
+ if @connection.respond_to?(meth)
217
+ @connection.__send__(meth, *args, &blk)
218
+ else
219
+ super
220
+ end
221
+ end
222
+ end
223
+ Channel.register "h2", Channel::HTTP2
224
+ end