httpx 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
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,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HTTPX
4
+ module Plugins
5
+ module PushPromise
6
+ PUSH_OPTIONS = { http2_settings: { settings_enable_push: 1 },
7
+ max_concurrent_requests: 1 }.freeze
8
+
9
+ module ResponseMethods
10
+ def pushed?
11
+ @__pushed
12
+ end
13
+
14
+ def mark_as_pushed!
15
+ @__pushed = true
16
+ end
17
+ end
18
+
19
+ module InstanceMethods
20
+ def initialize(opts = {})
21
+ super(PUSH_OPTIONS.merge(opts))
22
+ @promise_headers = {}
23
+ end
24
+
25
+ private
26
+
27
+ def on_promise(parser, stream)
28
+ stream.on(:promise_headers) do |h|
29
+ __on_promise_request(parser, stream, h)
30
+ end
31
+ stream.on(:headers) do |h|
32
+ __on_promise_response(parser, stream, h)
33
+ end
34
+ end
35
+
36
+ def __on_promise_request(parser, stream, h)
37
+ log(1, "#{stream.id}: ") do
38
+ h.map { |k, v| "-> PROMISE HEADER: #{k}: #{v}" }.join("\n")
39
+ end
40
+ headers = @options.headers_class.new(h)
41
+ path = headers[":path"]
42
+ authority = headers[":authority"]
43
+ request = parser.pending.find { |r| r.authority == authority && r.path == path }
44
+ if request
45
+ request.merge_headers(headers)
46
+ @promise_headers[stream] = request
47
+ parser.pending.delete(request)
48
+ else
49
+ stream.refuse
50
+ end
51
+ end
52
+
53
+ def __on_promise_response(parser, stream, h)
54
+ request = @promise_headers.delete(stream)
55
+ return unless request
56
+ parser.__send__(:on_stream_headers, stream, request, h)
57
+ request.transition(:done)
58
+ response = request.response
59
+ response.mark_as_pushed!
60
+ stream.on(:data, &parser.method(:on_stream_data).curry[stream, request])
61
+ stream.on(:close, &parser.method(:on_stream_close).curry[stream, request])
62
+ end
63
+ end
64
+ end
65
+ register_plugin(:push_promise, PushPromise)
66
+ end
67
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HTTPX
4
+ module Plugins
5
+ module Stream
6
+ module InstanceMethods
7
+ def stream
8
+ headers("accept" => "text/event-stream",
9
+ "cache-control" => "no-cache")
10
+ end
11
+ end
12
+
13
+ module ResponseMethods
14
+ def complete?
15
+ super ||
16
+ stream? &&
17
+ @stream_complete
18
+ end
19
+
20
+ def stream?
21
+ @headers["content-type"].start_with?("text/event-stream")
22
+ end
23
+
24
+ def <<(data)
25
+ res = super
26
+ @stream_complete = true if String(data).end_with?("\n\n")
27
+ res
28
+ end
29
+ end
30
+ end
31
+ register_plugin :stream, Stream
32
+ end
33
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HTTPX
4
+ # Adds a general-purpose registry API to a class. It is designed to be a
5
+ # configuration-level API, i.e. the registry is global to the class and
6
+ # should be set on **boot time**.
7
+ #
8
+ # It is used internally to associate tags with handlers.
9
+ #
10
+ # ## Register/Fetch
11
+ #
12
+ # One is strongly advised to register handlers when creating the class.
13
+ #
14
+ # There is an instance-level method to retrieve from the registry based
15
+ # on the tag:
16
+ #
17
+ # class Server
18
+ # include HTTPX::Registry
19
+ #
20
+ # register "tcp", TCPHandler
21
+ # register "ssl", SSLHandlers
22
+ # ...
23
+ #
24
+ #
25
+ # def handle(uri)
26
+ # scheme = uri.scheme
27
+ # handler = registry(scheme) #=> TCPHandler
28
+ # handler.handle
29
+ # end
30
+ # end
31
+ #
32
+ module Registry
33
+ # Base Registry Error
34
+ Error = Class.new(Error)
35
+
36
+ def self.extended(klass)
37
+ super
38
+ klass.extend(ClassMethods)
39
+ end
40
+
41
+ def self.included(klass)
42
+ super
43
+ klass.extend(ClassMethods)
44
+ klass.__send__(:include, InstanceMethods)
45
+ end
46
+
47
+ # Class Methods
48
+ module ClassMethods
49
+ def inherited(klass)
50
+ super
51
+ klass.instance_variable_set(:@registry, @registry.dup)
52
+ end
53
+
54
+ # @param [Object] tag the handler identifier in the registry
55
+ # @return [Symbol, String, Object] the corresponding handler (if Symbol or String,
56
+ # will assume it referes to an autoloaded module, and will load-and-return it).
57
+ #
58
+ def registry(tag = nil)
59
+ @registry ||= {}
60
+ return @registry if tag.nil?
61
+ handler = @registry.fetch(tag)
62
+ raise(Error, "#{tag} is not registered in #{self}") unless handler
63
+ case handler
64
+ when Symbol, String
65
+ const_get(handler)
66
+ else
67
+ handler
68
+ end
69
+ end
70
+
71
+ # @param [Object] tag the identifier for the handler in the registry
72
+ # @return [Symbol, String, Object] the handler (if Symbol or String, it is
73
+ # assumed to be an autoloaded module, to be loaded later)
74
+ #
75
+ def register(tag, handler)
76
+ registry[tag] = handler
77
+ end
78
+ end
79
+
80
+ # Instance Methods
81
+ module InstanceMethods
82
+ # delegates to HTTPX::Registry#registry
83
+ def registry(tag)
84
+ self.class.registry(tag)
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,222 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "forwardable"
4
+
5
+ module HTTPX
6
+ class Request
7
+ extend Forwardable
8
+
9
+ METHODS = [
10
+ # RFC 2616: Hypertext Transfer Protocol -- HTTP/1.1
11
+ :options, :get, :head, :post, :put, :delete, :trace, :connect,
12
+
13
+ # RFC 2518: HTTP Extensions for Distributed Authoring -- WEBDAV
14
+ :propfind, :proppatch, :mkcol, :copy, :move, :lock, :unlock,
15
+
16
+ # RFC 3648: WebDAV Ordered Collections Protocol
17
+ :orderpatch,
18
+
19
+ # RFC 3744: WebDAV Access Control Protocol
20
+ :acl,
21
+
22
+ # RFC 6352: vCard Extensions to WebDAV -- CardDAV
23
+ :report,
24
+
25
+ # RFC 5789: PATCH Method for HTTP
26
+ :patch,
27
+
28
+ # draft-reschke-webdav-search: WebDAV Search
29
+ :search
30
+ ].freeze
31
+
32
+ USER_AGENT = "httpx.rb/#{VERSION}"
33
+
34
+ attr_reader :verb, :uri, :headers, :body, :state
35
+
36
+ attr_accessor :response
37
+
38
+ def_delegator :@body, :<<
39
+
40
+ def_delegator :@body, :empty?
41
+
42
+ def_delegator :@body, :chunk!
43
+
44
+ def initialize(verb, uri, options = {})
45
+ @verb = verb.to_s.downcase.to_sym
46
+ @uri = URI(uri)
47
+ @options = Options.new(options)
48
+
49
+ raise(Error, "unknown method: #{verb}") unless METHODS.include?(@verb)
50
+
51
+ @headers = @options.headers_class.new(@options.headers)
52
+ @headers["user-agent"] ||= USER_AGENT
53
+ @headers["accept"] ||= "*/*"
54
+
55
+ @body = @options.request_body_class.new(@headers, @options)
56
+ @state = :idle
57
+ end
58
+
59
+ def merge_headers(h)
60
+ @headers = @headers.merge(h)
61
+ end
62
+
63
+ def scheme
64
+ @uri.scheme
65
+ end
66
+
67
+ def path
68
+ path = uri.path
69
+ path << "/" if path.empty?
70
+ path << "?#{query}" unless query.empty?
71
+ path
72
+ end
73
+
74
+ def authority
75
+ host = @uri.host
76
+ port_string = @uri.port == @uri.default_port ? nil : ":#{@uri.port}"
77
+ "#{host}#{port_string}"
78
+ end
79
+
80
+ def query
81
+ return @query if defined?(@query)
82
+ query = []
83
+ if (q = @options.params)
84
+ query << URI.encode_www_form(q)
85
+ end
86
+ query << @uri.query if @uri.query
87
+ @query = query.join("&")
88
+ end
89
+
90
+ def drain_body
91
+ return nil if @body.nil?
92
+ @drainer ||= @body.each
93
+ chunk = @drainer.next
94
+ chunk.dup
95
+ rescue StopIteration
96
+ nil
97
+ end
98
+
99
+ def inspect
100
+ "#<Request #{@verb.to_s.upcase} #{path} @headers=#{@headers.to_hash} @body=#{@body}>"
101
+ end
102
+
103
+ class Body
104
+ class << self
105
+ def new(*, options)
106
+ return options.body if options.body.is_a?(self)
107
+ super
108
+ end
109
+ end
110
+
111
+ def initialize(headers, options)
112
+ @headers = headers
113
+ @body = if options.body
114
+ Transcoder.registry("body").encode(options.body)
115
+ elsif options.form
116
+ Transcoder.registry("form").encode(options.form)
117
+ elsif options.json
118
+ Transcoder.registry("json").encode(options.json)
119
+ end
120
+ return if @body.nil?
121
+ @headers["content-type"] ||= @body.content_type
122
+ @headers["content-length"] = @body.bytesize unless unbounded_body?
123
+ end
124
+
125
+ def each(&block)
126
+ return enum_for(__method__) unless block_given?
127
+ return if @body.nil?
128
+ body = stream(@body)
129
+ if body.respond_to?(:read)
130
+ ::IO.copy_stream(body, ProcIO.new(block))
131
+ elsif body.respond_to?(:each)
132
+ body.each(&block)
133
+ else
134
+ block[body.to_s]
135
+ end
136
+ end
137
+
138
+ def empty?
139
+ return true if @body.nil?
140
+ return false if chunked?
141
+ bytesize.zero?
142
+ end
143
+
144
+ def bytesize
145
+ return 0 if @body.nil?
146
+ if @body.respond_to?(:bytesize)
147
+ @body.bytesize
148
+ elsif @body.respond_to?(:size)
149
+ @body.size
150
+ else
151
+ raise Error, "cannot determine size of body: #{@body.inspect}"
152
+ end
153
+ end
154
+
155
+ def stream(body)
156
+ encoded = body
157
+ encoded = Transcoder.registry("chunker").encode(body) if chunked?
158
+ encoded
159
+ end
160
+
161
+ def unbounded_body?
162
+ chunked? || @body.bytesize == Float::INFINITY
163
+ end
164
+
165
+ def chunked?
166
+ @headers["transfer-encoding"] == "chunked"
167
+ end
168
+
169
+ def chunk!
170
+ @headers.add("transfer-encoding", "chunked")
171
+ end
172
+ end
173
+
174
+ def transition(nextstate)
175
+ case nextstate
176
+ when :idle
177
+ @response = nil
178
+ when :headers
179
+ return unless @state == :idle
180
+ when :body
181
+ return unless @state == :headers ||
182
+ @state == :expect
183
+
184
+ if @headers.key?("expect")
185
+ unless @response
186
+ @state = :expect
187
+ return
188
+ end
189
+
190
+ case @response.status
191
+ when 100
192
+ # deallocate
193
+ @response = nil
194
+ when 417
195
+ @response = ErrorResponse.new("Expectation Failed", 0)
196
+ return
197
+ end
198
+ end
199
+ when :done
200
+ return if @state == :expect
201
+ end
202
+ @state = nextstate
203
+ nil
204
+ end
205
+
206
+ def expects?
207
+ @headers["expect"] == "100-continue" &&
208
+ @response && @response.status == 100
209
+ end
210
+
211
+ class ProcIO
212
+ def initialize(block)
213
+ @block = block
214
+ end
215
+
216
+ def write(data)
217
+ @block.call(data)
218
+ data.bytesize
219
+ end
220
+ end
221
+ end
222
+ end
@@ -0,0 +1,225 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+ require "tempfile"
5
+ require "fileutils"
6
+ require "forwardable"
7
+
8
+ module HTTPX
9
+ class Response
10
+ extend Forwardable
11
+
12
+ attr_reader :status, :headers, :body, :version
13
+
14
+ def_delegator :@body, :to_s
15
+
16
+ def_delegator :@body, :read
17
+
18
+ def_delegator :@body, :copy_to
19
+
20
+ def_delegator :@body, :close
21
+
22
+ def_delegator :@request, :uri
23
+
24
+ def initialize(request, status, version, headers, options = {})
25
+ @options = Options.new(options)
26
+ @version = version
27
+ @request = request
28
+ @status = Integer(status)
29
+ @headers = @options.headers_class.new(headers)
30
+ @body = @options.response_body_class.new(self, threshold_size: @options.body_threshold_size,
31
+ window_size: @options.window_size)
32
+ end
33
+
34
+ def merge_headers(h)
35
+ @headers = @headers.merge(h)
36
+ end
37
+
38
+ def <<(data)
39
+ @body.write(data)
40
+ end
41
+
42
+ def bodyless?
43
+ @request.verb == :head ||
44
+ @status < 200 ||
45
+ @status == 201 ||
46
+ @status == 204 ||
47
+ @status == 205 ||
48
+ @status == 304
49
+ end
50
+
51
+ def content_type
52
+ ContentType.parse(@headers["content-type"])
53
+ end
54
+
55
+ def complete?
56
+ bodyless? || (@request.verb == :connect && @status == 200)
57
+ end
58
+
59
+ def inspect
60
+ "#<Response:#{object_id} @status=#{@status} @headers=#{@headers}>"
61
+ end
62
+
63
+ class Body
64
+ def initialize(response, threshold_size:, window_size: 1 << 14)
65
+ @response = response
66
+ @headers = response.headers
67
+ @threshold_size = threshold_size
68
+ @window_size = window_size
69
+ @encoding = response.content_type.charset || Encoding::BINARY
70
+ @length = 0
71
+ @buffer = nil
72
+ @state = :idle
73
+ end
74
+
75
+ def write(chunk)
76
+ @length += chunk.bytesize
77
+ transition
78
+ @buffer.write(chunk)
79
+ end
80
+
81
+ def read(*args)
82
+ return unless @buffer
83
+ @buffer.read(*args)
84
+ end
85
+
86
+ def bytesize
87
+ @length
88
+ end
89
+
90
+ def each
91
+ return enum_for(__method__) unless block_given?
92
+ begin
93
+ unless @state == :idle
94
+ rewind
95
+ while (chunk = @buffer.read(@window_size))
96
+ yield(chunk)
97
+ end
98
+ end
99
+ ensure
100
+ close
101
+ end
102
+ end
103
+
104
+ def to_s
105
+ rewind
106
+ return @buffer.read.force_encoding(@encoding) if @buffer
107
+ ""
108
+ ensure
109
+ close
110
+ end
111
+ alias_method :to_str, :to_s
112
+
113
+ def empty?
114
+ @length.zero?
115
+ end
116
+
117
+ def copy_to(dest)
118
+ return unless @buffer
119
+ if dest.respond_to?(:path) && @buffer.respond_to?(:path)
120
+ FileUtils.mv(@buffer.path, dest.path)
121
+ else
122
+ @buffer.rewind
123
+ ::IO.copy_stream(@buffer, dest)
124
+ end
125
+ end
126
+
127
+ # closes/cleans the buffer, resets everything
128
+ def close
129
+ return if @state == :idle
130
+ @buffer.close
131
+ @buffer.unlink if @buffer.respond_to?(:unlink)
132
+ @buffer = nil
133
+ @length = 0
134
+ @state = :idle
135
+ end
136
+
137
+ def ==(other)
138
+ to_s == other.to_s
139
+ end
140
+
141
+ private
142
+
143
+ def rewind
144
+ return if @state == :idle
145
+ @buffer.rewind
146
+ end
147
+
148
+ def transition
149
+ case @state
150
+ when :idle
151
+ if @length > @threshold_size
152
+ @state = :buffer
153
+ @buffer = Tempfile.new("httpx", encoding: @encoding, mode: File::RDWR)
154
+ else
155
+ @state = :memory
156
+ @buffer = StringIO.new("".b, File::RDWR)
157
+ end
158
+ when :memory
159
+ if @length > @threshold_size
160
+ aux = @buffer
161
+ @buffer = Tempfile.new("palanca", encoding: @encoding, mode: File::RDWR)
162
+ aux.rewind
163
+ ::IO.copy_stream(aux, @buffer)
164
+ # TODO: remove this if/when minor ruby is 2.3
165
+ # (this looks like a bug from older versions)
166
+ @buffer.pos = aux.pos #######################
167
+ #############################################
168
+ aux.close
169
+ @state = :buffer
170
+ end
171
+ end
172
+
173
+ return unless %i[memory buffer].include?(@state)
174
+ end
175
+ end
176
+ end
177
+
178
+ class ContentType
179
+ MIME_TYPE_RE = %r{^([^/]+/[^;]+)(?:$|;)}
180
+ CHARSET_RE = /;\s*charset=([^;]+)/i
181
+
182
+ attr_reader :mime_type, :charset
183
+
184
+ def initialize(mime_type, charset)
185
+ @mime_type = mime_type
186
+ @charset = charset
187
+ end
188
+
189
+ class << self
190
+ # Parse string and return ContentType struct
191
+ def parse(str)
192
+ new(mime_type(str), charset(str))
193
+ end
194
+
195
+ private
196
+
197
+ # :nodoc:
198
+ def mime_type(str)
199
+ m = str.to_s[MIME_TYPE_RE, 1]
200
+ m && m.strip.downcase
201
+ end
202
+
203
+ # :nodoc:
204
+ def charset(str)
205
+ m = str.to_s[CHARSET_RE, 1]
206
+ m && m.strip.delete('"')
207
+ end
208
+ end
209
+ end
210
+
211
+ class ErrorResponse
212
+ attr_reader :error, :retries
213
+
214
+ alias_method :status, :error
215
+
216
+ def initialize(error, retries)
217
+ @error = error
218
+ @retries = retries
219
+ end
220
+
221
+ def retryable?
222
+ @retries.positive?
223
+ end
224
+ end
225
+ end