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.
- checksums.yaml +7 -0
- data/LICENSE.txt +191 -0
- data/README.md +119 -0
- data/lib/httpx.rb +50 -0
- data/lib/httpx/buffer.rb +34 -0
- data/lib/httpx/callbacks.rb +32 -0
- data/lib/httpx/chainable.rb +51 -0
- data/lib/httpx/channel.rb +222 -0
- data/lib/httpx/channel/http1.rb +220 -0
- data/lib/httpx/channel/http2.rb +224 -0
- data/lib/httpx/client.rb +173 -0
- data/lib/httpx/connection.rb +74 -0
- data/lib/httpx/errors.rb +7 -0
- data/lib/httpx/extensions.rb +52 -0
- data/lib/httpx/headers.rb +152 -0
- data/lib/httpx/io.rb +240 -0
- data/lib/httpx/loggable.rb +11 -0
- data/lib/httpx/options.rb +138 -0
- data/lib/httpx/plugins/authentication.rb +14 -0
- data/lib/httpx/plugins/basic_authentication.rb +20 -0
- data/lib/httpx/plugins/compression.rb +123 -0
- data/lib/httpx/plugins/compression/brotli.rb +55 -0
- data/lib/httpx/plugins/compression/deflate.rb +50 -0
- data/lib/httpx/plugins/compression/gzip.rb +59 -0
- data/lib/httpx/plugins/cookies.rb +63 -0
- data/lib/httpx/plugins/digest_authentication.rb +141 -0
- data/lib/httpx/plugins/follow_redirects.rb +72 -0
- data/lib/httpx/plugins/h2c.rb +85 -0
- data/lib/httpx/plugins/proxy.rb +108 -0
- data/lib/httpx/plugins/proxy/http.rb +115 -0
- data/lib/httpx/plugins/proxy/socks4.rb +110 -0
- data/lib/httpx/plugins/proxy/socks5.rb +152 -0
- data/lib/httpx/plugins/push_promise.rb +67 -0
- data/lib/httpx/plugins/stream.rb +33 -0
- data/lib/httpx/registry.rb +88 -0
- data/lib/httpx/request.rb +222 -0
- data/lib/httpx/response.rb +225 -0
- data/lib/httpx/selector.rb +155 -0
- data/lib/httpx/timeout.rb +68 -0
- data/lib/httpx/transcoder.rb +12 -0
- data/lib/httpx/transcoder/body.rb +56 -0
- data/lib/httpx/transcoder/chunker.rb +38 -0
- data/lib/httpx/transcoder/form.rb +41 -0
- data/lib/httpx/transcoder/json.rb +36 -0
- data/lib/httpx/version.rb +5 -0
- metadata +150 -0
data/lib/httpx/client.rb
ADDED
@@ -0,0 +1,173 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module HTTPX
|
4
|
+
class Client
|
5
|
+
include Loggable
|
6
|
+
include Chainable
|
7
|
+
|
8
|
+
def initialize(options = {})
|
9
|
+
@options = self.class.default_options.merge(options)
|
10
|
+
@connection = Connection.new(@options)
|
11
|
+
@responses = {}
|
12
|
+
return unless block_given?
|
13
|
+
begin
|
14
|
+
@keep_open = true
|
15
|
+
yield self
|
16
|
+
ensure
|
17
|
+
@keep_open = false
|
18
|
+
close
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def close
|
23
|
+
@connection.close
|
24
|
+
end
|
25
|
+
|
26
|
+
def request(*args, keep_open: @keep_open, **options)
|
27
|
+
requests = __build_reqs(*args, **options)
|
28
|
+
responses = __send_reqs(*requests, **options)
|
29
|
+
return responses.first if responses.size == 1
|
30
|
+
responses
|
31
|
+
ensure
|
32
|
+
close unless keep_open
|
33
|
+
end
|
34
|
+
|
35
|
+
private
|
36
|
+
|
37
|
+
def on_response(request, response)
|
38
|
+
@responses[request] = response
|
39
|
+
end
|
40
|
+
|
41
|
+
def on_promise(_, stream)
|
42
|
+
log(2, "#{stream.id}: ") { "refusing stream!" }
|
43
|
+
stream.refuse
|
44
|
+
# TODO: policy for handling promises
|
45
|
+
end
|
46
|
+
|
47
|
+
def fetch_response(request)
|
48
|
+
response = @responses.delete(request)
|
49
|
+
if response.is_a?(ErrorResponse) && response.retryable?
|
50
|
+
channel = find_channel(request)
|
51
|
+
channel.send(request, retries: response.retries - 1)
|
52
|
+
return
|
53
|
+
end
|
54
|
+
response
|
55
|
+
end
|
56
|
+
|
57
|
+
def find_channel(request, **options)
|
58
|
+
uri = URI(request.uri)
|
59
|
+
@connection.find_channel(uri) || begin
|
60
|
+
channel = @connection.build_channel(uri, **options)
|
61
|
+
set_channel_callbacks(channel)
|
62
|
+
channel
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
def set_channel_callbacks(channel)
|
67
|
+
channel.on(:response, &method(:on_response))
|
68
|
+
channel.on(:promise, &method(:on_promise))
|
69
|
+
end
|
70
|
+
|
71
|
+
def __build_reqs(*args, **options)
|
72
|
+
case args.size
|
73
|
+
when 1
|
74
|
+
reqs = args.first
|
75
|
+
reqs.map do |verb, uri|
|
76
|
+
__build_req(verb, uri, options)
|
77
|
+
end
|
78
|
+
when 2, 3
|
79
|
+
verb, uris = args
|
80
|
+
if uris.respond_to?(:each)
|
81
|
+
uris.map do |uri|
|
82
|
+
__build_req(verb, uri, options)
|
83
|
+
end
|
84
|
+
else
|
85
|
+
[__build_req(verb, uris, options)]
|
86
|
+
end
|
87
|
+
else
|
88
|
+
raise ArgumentError, "unsupported number of arguments"
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
def __send_reqs(*requests, **options)
|
93
|
+
requests.each do |request|
|
94
|
+
channel = find_channel(request, **options)
|
95
|
+
channel.send(request)
|
96
|
+
end
|
97
|
+
responses = []
|
98
|
+
|
99
|
+
# guarantee ordered responses
|
100
|
+
loop do
|
101
|
+
begin
|
102
|
+
request = requests.first
|
103
|
+
@connection.next_tick until (response = fetch_response(request))
|
104
|
+
|
105
|
+
responses << response
|
106
|
+
requests.shift
|
107
|
+
|
108
|
+
break if requests.empty?
|
109
|
+
rescue TimeoutError => e
|
110
|
+
responses << ErrorResponse.new(e.message, 0) while requests.shift
|
111
|
+
@connection.reset
|
112
|
+
break
|
113
|
+
end
|
114
|
+
end
|
115
|
+
requests.size == 1 ? responses.first : responses
|
116
|
+
end
|
117
|
+
|
118
|
+
def __build_req(verb, uri, options = {})
|
119
|
+
rklass = @options.request_class
|
120
|
+
rklass.new(verb, uri, @options.merge(options))
|
121
|
+
end
|
122
|
+
|
123
|
+
@default_options = Options.new
|
124
|
+
@plugins = []
|
125
|
+
|
126
|
+
class << self
|
127
|
+
attr_reader :default_options
|
128
|
+
|
129
|
+
def inherited(klass)
|
130
|
+
super
|
131
|
+
klass.instance_variable_set(:@default_options, @default_options.dup)
|
132
|
+
klass.instance_variable_set(:@plugins, @plugins.dup)
|
133
|
+
end
|
134
|
+
|
135
|
+
def plugin(pl, *args, &block)
|
136
|
+
# raise Error, "Cannot add a plugin to a frozen config" if frozen?
|
137
|
+
pl = Plugins.load_plugin(pl) if pl.is_a?(Symbol)
|
138
|
+
unless @plugins.include?(pl)
|
139
|
+
@plugins << pl
|
140
|
+
pl.load_dependencies(self, *args, &block) if pl.respond_to?(:load_dependencies)
|
141
|
+
include(pl::InstanceMethods) if defined?(pl::InstanceMethods)
|
142
|
+
extend(pl::ClassMethods) if defined?(pl::ClassMethods)
|
143
|
+
if defined?(pl::OptionsMethods) || defined?(pl::OptionsClassMethods)
|
144
|
+
options_klass = Class.new(@default_options.class)
|
145
|
+
options_klass.extend(pl::OptionsClassMethods) if defined?(pl::OptionsClassMethods)
|
146
|
+
options_klass.__send__(:include, pl::OptionsMethods) if defined?(pl::OptionsMethods)
|
147
|
+
@default_options = options_klass.new
|
148
|
+
end
|
149
|
+
opts = default_options
|
150
|
+
opts.request_class.__send__(:include, pl::RequestMethods) if defined?(pl::RequestMethods)
|
151
|
+
opts.request_class.extend(pl::RequestClassMethods) if defined?(pl::RequestClassMethods)
|
152
|
+
opts.response_class.__send__(:include, pl::ResponseMethods) if defined?(pl::ResponseMethods)
|
153
|
+
opts.response_class.extend(pl::ResponseClassMethods) if defined?(pl::ResponseClassMethods)
|
154
|
+
opts.headers_class.__send__(:include, pl::HeadersMethods) if defined?(pl::HeadersMethods)
|
155
|
+
opts.headers_class.extend(pl::HeadersClassMethods) if defined?(pl::HeadersClassMethods)
|
156
|
+
opts.request_body_class.__send__(:include, pl::RequestBodyMethods) if defined?(pl::RequestBodyMethods)
|
157
|
+
opts.request_body_class.extend(pl::RequestBodyClassMethods) if defined?(pl::RequestBodyClassMethods)
|
158
|
+
opts.response_body_class.__send__(:include, pl::ResponseBodyMethods) if defined?(pl::ResponseBodyMethods)
|
159
|
+
opts.response_body_class.extend(pl::ResponseBodyClassMethods) if defined?(pl::ResponseBodyClassMethods)
|
160
|
+
pl.configure(self, *args, &block) if pl.respond_to?(:configure)
|
161
|
+
end
|
162
|
+
self
|
163
|
+
end
|
164
|
+
|
165
|
+
def plugins(pls)
|
166
|
+
pls.each do |pl, *args|
|
167
|
+
plugin(pl, *args)
|
168
|
+
end
|
169
|
+
self
|
170
|
+
end
|
171
|
+
end
|
172
|
+
end
|
173
|
+
end
|
@@ -0,0 +1,74 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "httpx/selector"
|
4
|
+
require "httpx/channel"
|
5
|
+
|
6
|
+
module HTTPX
|
7
|
+
class Connection
|
8
|
+
def initialize(options)
|
9
|
+
@options = Options.new(options)
|
10
|
+
@timeout = options.timeout
|
11
|
+
@selector = Selector.new
|
12
|
+
@channels = []
|
13
|
+
end
|
14
|
+
|
15
|
+
def running?
|
16
|
+
!@channels.empty?
|
17
|
+
end
|
18
|
+
|
19
|
+
def next_tick(timeout: @timeout.timeout)
|
20
|
+
@selector.select(timeout) do |monitor|
|
21
|
+
if (channel = monitor.value)
|
22
|
+
consume(channel)
|
23
|
+
end
|
24
|
+
monitor.interests = channel.interests
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
def close(channel = nil)
|
29
|
+
if channel
|
30
|
+
channel.close
|
31
|
+
else
|
32
|
+
@channels.each(&:close)
|
33
|
+
next_tick until @selector.empty?
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
def reset
|
38
|
+
@channels.each(&:reset)
|
39
|
+
end
|
40
|
+
|
41
|
+
def build_channel(uri, **options)
|
42
|
+
channel = Channel.by(uri, @options.merge(options))
|
43
|
+
register_channel(channel)
|
44
|
+
channel
|
45
|
+
end
|
46
|
+
|
47
|
+
# opens a channel to the IP reachable through +uri+.
|
48
|
+
# Many hostnames are reachable through the same IP, so we try to
|
49
|
+
# maximize pipelining by opening as few channels as possible.
|
50
|
+
#
|
51
|
+
def find_channel(uri)
|
52
|
+
@channels.find do |channel|
|
53
|
+
channel.match?(uri)
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
private
|
58
|
+
|
59
|
+
def register_channel(channel)
|
60
|
+
monitor = @selector.register(channel, :w)
|
61
|
+
monitor.value = channel
|
62
|
+
channel.on(:close) do
|
63
|
+
@channels.delete(channel)
|
64
|
+
@selector.deregister(channel)
|
65
|
+
end
|
66
|
+
@channels << channel
|
67
|
+
end
|
68
|
+
|
69
|
+
def consume(channel)
|
70
|
+
ch = catch(:close) { channel.call }
|
71
|
+
close(ch) if ch
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
data/lib/httpx/errors.rb
ADDED
@@ -0,0 +1,52 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
unless Method.method_defined?(:curry)
|
4
|
+
|
5
|
+
# Backport
|
6
|
+
#
|
7
|
+
# Ruby 2.1 and lower implement curry only for Procs.
|
8
|
+
#
|
9
|
+
# Why not using Refinements? Because they don't work for Method (tested with ruby 2.1.9).
|
10
|
+
#
|
11
|
+
module CurryMethods # :nodoc:
|
12
|
+
# Backport for the Method#curry method, which is part of ruby core since 2.2 .
|
13
|
+
#
|
14
|
+
def curry(*args)
|
15
|
+
to_proc.curry(*args)
|
16
|
+
end
|
17
|
+
end
|
18
|
+
Method.__send__(:include, CurryMethods)
|
19
|
+
end
|
20
|
+
|
21
|
+
unless String.method_defined?(:+@)
|
22
|
+
# Backport for +"", to initialize unfrozen strings from the string literal.
|
23
|
+
#
|
24
|
+
module LiteralStringExtensions
|
25
|
+
def +@
|
26
|
+
frozen? ? dup : self
|
27
|
+
end
|
28
|
+
end
|
29
|
+
String.__send__(:include, LiteralStringExtensions)
|
30
|
+
end
|
31
|
+
|
32
|
+
unless Numeric.method_defined?(:positive?)
|
33
|
+
# Ruby 2.3 Backport (Numeric#positive?)
|
34
|
+
#
|
35
|
+
module PosMethods
|
36
|
+
def positive?
|
37
|
+
self > 0
|
38
|
+
end
|
39
|
+
end
|
40
|
+
Numeric.__send__(:include, PosMethods)
|
41
|
+
end
|
42
|
+
|
43
|
+
unless Numeric.method_defined?(:negative?)
|
44
|
+
# Ruby 2.3 Backport (Numeric#negative?)
|
45
|
+
#
|
46
|
+
module NegMethods
|
47
|
+
def negative?
|
48
|
+
self < 0
|
49
|
+
end
|
50
|
+
end
|
51
|
+
Numeric.__send__(:include, NegMethods)
|
52
|
+
end
|
@@ -0,0 +1,152 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module HTTPX
|
4
|
+
class Headers
|
5
|
+
EMPTY = [].freeze # :nodoc:
|
6
|
+
|
7
|
+
class << self
|
8
|
+
def new(h = nil)
|
9
|
+
return h if h.is_a?(self)
|
10
|
+
super
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
def initialize(h = nil)
|
15
|
+
@headers = {}
|
16
|
+
return unless h
|
17
|
+
h.each do |field, value|
|
18
|
+
array_value(value).each do |v|
|
19
|
+
add(downcased(field), v)
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
# cloned initialization
|
25
|
+
def initialize_clone(orig)
|
26
|
+
super
|
27
|
+
@headers = orig.instance_variable_get(:@headers).clone
|
28
|
+
end
|
29
|
+
|
30
|
+
# dupped initialization
|
31
|
+
def initialize_dup(orig)
|
32
|
+
super
|
33
|
+
@headers = orig.instance_variable_get(:@headers).dup
|
34
|
+
end
|
35
|
+
|
36
|
+
# freezes the headers hash
|
37
|
+
def freeze
|
38
|
+
@headers.freeze
|
39
|
+
super
|
40
|
+
end
|
41
|
+
|
42
|
+
# merges headers with another header-quack.
|
43
|
+
# the merge rule is, if the header already exists,
|
44
|
+
# ignore what the +other+ headers has. Otherwise, set
|
45
|
+
#
|
46
|
+
def merge(other)
|
47
|
+
# TODO: deep-copy
|
48
|
+
headers = dup
|
49
|
+
other.each do |field, value|
|
50
|
+
headers[field] = value
|
51
|
+
end
|
52
|
+
headers
|
53
|
+
end
|
54
|
+
|
55
|
+
# returns the comma-separated values of the header field
|
56
|
+
# identified by +field+, or nil otherwise.
|
57
|
+
#
|
58
|
+
def [](field)
|
59
|
+
a = @headers[downcased(field)] || return
|
60
|
+
a.join(",")
|
61
|
+
end
|
62
|
+
|
63
|
+
# sets +value+ (if not nil) as single value for the +field+ header.
|
64
|
+
#
|
65
|
+
def []=(field, value)
|
66
|
+
return unless value
|
67
|
+
@headers[downcased(field)] = array_value(value)
|
68
|
+
end
|
69
|
+
|
70
|
+
# deletes all values associated with +field+ header.
|
71
|
+
#
|
72
|
+
def delete(field)
|
73
|
+
canonical = downcased(field)
|
74
|
+
@headers.delete(canonical) if @headers.key?(canonical)
|
75
|
+
end
|
76
|
+
|
77
|
+
# adds additional +value+ to the existing, for header +field+.
|
78
|
+
#
|
79
|
+
def add(field, value)
|
80
|
+
(@headers[downcased(field)] ||= []) << String(value)
|
81
|
+
end
|
82
|
+
|
83
|
+
# helper to be used when adding an header field as a value to another field
|
84
|
+
#
|
85
|
+
# h2_headers.add_header("vary", "accept-encoding")
|
86
|
+
# h2_headers["vary"] #=> "accept-encoding"
|
87
|
+
# h1_headers.add_header("vary", "accept-encoding")
|
88
|
+
# h1_headers["vary"] #=> "Accept-Encoding"
|
89
|
+
#
|
90
|
+
alias_method :add_header, :add
|
91
|
+
|
92
|
+
# returns the enumerable headers store in pairs of header field + the values in
|
93
|
+
# the comma-separated string format
|
94
|
+
#
|
95
|
+
def each
|
96
|
+
return enum_for(__method__) { @headers.size } unless block_given?
|
97
|
+
@headers.each do |field, value|
|
98
|
+
yield(field, value.join(", ")) unless value.empty?
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
def ==(other)
|
103
|
+
to_hash == Headers.new(other).to_hash
|
104
|
+
end
|
105
|
+
|
106
|
+
# the headers store in Hash format
|
107
|
+
def to_hash
|
108
|
+
Hash[to_a]
|
109
|
+
end
|
110
|
+
|
111
|
+
# the headers store in array of pairs format
|
112
|
+
def to_a
|
113
|
+
Array(each)
|
114
|
+
end
|
115
|
+
|
116
|
+
# headers as string
|
117
|
+
def to_s
|
118
|
+
@headers.to_s
|
119
|
+
end
|
120
|
+
|
121
|
+
# this is internal API and doesn't abide to other public API
|
122
|
+
# guarantees, like downcasing strings.
|
123
|
+
# Please do not use this outside of core!
|
124
|
+
#
|
125
|
+
def key?(downcased_key)
|
126
|
+
@headers.key?(downcased_key)
|
127
|
+
end
|
128
|
+
|
129
|
+
# returns the values for the +field+ header in array format.
|
130
|
+
# This method is more internal, and for this reason doesn't try
|
131
|
+
# to "correct" the user input, i.e. it doesn't downcase the key.
|
132
|
+
#
|
133
|
+
def get(field)
|
134
|
+
@headers[field] || EMPTY
|
135
|
+
end
|
136
|
+
|
137
|
+
private
|
138
|
+
|
139
|
+
def array_value(value)
|
140
|
+
case value
|
141
|
+
when Array
|
142
|
+
value.map { |val| String(val) }
|
143
|
+
else
|
144
|
+
[String(value)]
|
145
|
+
end
|
146
|
+
end
|
147
|
+
|
148
|
+
def downcased(field)
|
149
|
+
String(field).downcase
|
150
|
+
end
|
151
|
+
end
|
152
|
+
end
|