loading
Generated 2020-03-28T15:54:08+00:00

All Files ( 91.83% covered at 54937.22 hits/line )

56 files in total.
3561 relevant lines, 3270 lines covered and 291 lines missed. ( 91.83% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line
lib/httpx.rb 100.00 % 59 28 28 0 21.54
lib/httpx/adapters/faraday.rb 98.02 % 223 101 99 2 7.05
lib/httpx/altsvc.rb 92.86 % 93 42 39 3 17.29
lib/httpx/buffer.rb 100.00 % 38 19 19 0 128.26
lib/httpx/callbacks.rb 100.00 % 29 15 15 0 503477.47
lib/httpx/chainable.rb 83.33 % 80 36 30 6 49.33
lib/httpx/connection.rb 89.78 % 418 225 202 23 294185.02
lib/httpx/connection/http1.rb 89.54 % 261 153 137 16 84060.09
lib/httpx/connection/http2.rb 84.70 % 301 183 155 28 46370.76
lib/httpx/errors.rb 100.00 % 52 29 29 0 3.55
lib/httpx/headers.rb 95.52 % 170 67 64 3 96988.01
lib/httpx/io.rb 100.00 % 17 12 12 0 1.00
lib/httpx/io/ssl.rb 93.62 % 133 47 44 3 809.06
lib/httpx/io/tcp.rb 91.25 % 175 80 73 7 219443.40
lib/httpx/io/udp.rb 100.00 % 69 22 22 0 20.45
lib/httpx/io/unix.rb 71.88 % 60 32 23 9 0.72
lib/httpx/options.rb 100.00 % 192 96 96 0 3527.02
lib/httpx/parser/http1.rb 94.44 % 181 108 102 6 262.98
lib/httpx/plugins/authentication.rb 100.00 % 20 7 7 0 1.43
lib/httpx/plugins/basic_authentication.rb 100.00 % 25 11 11 0 1.45
lib/httpx/plugins/compression.rb 93.10 % 168 87 81 6 15.54
lib/httpx/plugins/compression/brotli.rb 100.00 % 55 29 29 0 1.72
lib/httpx/plugins/compression/deflate.rb 100.00 % 50 27 27 0 3.33
lib/httpx/plugins/compression/gzip.rb 100.00 % 69 36 36 0 2.78
lib/httpx/plugins/cookies.rb 90.63 % 126 64 58 6 8.67
lib/httpx/plugins/digest_authentication.rb 96.43 % 157 84 81 3 14.88
lib/httpx/plugins/expect.rb 88.57 % 66 35 31 4 708.34
lib/httpx/plugins/follow_redirects.rb 100.00 % 101 49 49 0 5924.53
lib/httpx/plugins/h2c.rb 96.55 % 112 58 56 2 1.28
lib/httpx/plugins/multipart.rb 92.00 % 57 25 23 2 10.64
lib/httpx/plugins/persistent.rb 100.00 % 31 8 8 0 1.00
lib/httpx/plugins/proxy.rb 85.38 % 235 130 111 19 818.38
lib/httpx/plugins/proxy/http.rb 100.00 % 118 67 67 0 4.51
lib/httpx/plugins/proxy/socks4.rb 87.32 % 120 71 62 9 4.61
lib/httpx/plugins/proxy/socks5.rb 90.20 % 172 102 92 10 119.78
lib/httpx/plugins/proxy/ssh.rb 88.46 % 91 52 46 6 1.50
lib/httpx/plugins/push_promise.rb 100.00 % 80 40 40 0 1.33
lib/httpx/plugins/retries.rb 100.00 % 117 55 55 0 44473.29
lib/httpx/pool.rb 90.91 % 182 110 100 10 156830.36
lib/httpx/registry.rb 100.00 % 90 27 27 0 378.30
lib/httpx/request.rb 96.69 % 265 121 117 4 133699.85
lib/httpx/resolver.rb 94.64 % 106 56 53 3 89.25
lib/httpx/resolver/https.rb 79.07 % 208 129 102 27 1692.46
lib/httpx/resolver/native.rb 77.06 % 309 170 131 39 22523.18
lib/httpx/resolver/options.rb 83.33 % 25 12 10 2 72.08
lib/httpx/resolver/resolver_mixin.rb 92.68 % 72 41 38 3 105.00
lib/httpx/resolver/system.rb 96.15 % 49 26 25 1 1.96
lib/httpx/response.rb 98.62 % 284 145 143 2 96.58
lib/httpx/selector.rb 82.09 % 135 67 55 12 493111.39
lib/httpx/session.rb 94.67 % 264 150 142 8 17418.57
lib/httpx/timeout.rb 92.59 % 61 27 25 2 802.74
lib/httpx/transcoder.rb 100.00 % 12 7 7 0 1.00
lib/httpx/transcoder/body.rb 88.24 % 60 34 30 4 23.59
lib/httpx/transcoder/chunker.rb 100.00 % 118 69 69 0 90.81
lib/httpx/transcoder/form.rb 94.74 % 37 19 18 1 0.95
lib/httpx/transcoder/json.rb 100.00 % 36 19 19 0 2.68

lib/httpx.rb

100.0% lines covered

28 relevant lines. 28 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require "httpx/version"
  3. 1 require "httpx/extensions"
  4. 1 require "httpx/errors"
  5. 1 require "httpx/altsvc"
  6. 1 require "httpx/callbacks"
  7. 1 require "httpx/loggable"
  8. 1 require "httpx/registry"
  9. 1 require "httpx/transcoder"
  10. 1 require "httpx/options"
  11. 1 require "httpx/timeout"
  12. 1 require "httpx/pool"
  13. 1 require "httpx/headers"
  14. 1 require "httpx/request"
  15. 1 require "httpx/response"
  16. 1 require "httpx/chainable"
  17. 1 require "httpx/session"
  18. # Top-Level Namespace
  19. #
  20. 1 module HTTPX
  21. # All plugins should be stored under this module/namespace. Can register and load
  22. # plugins.
  23. #
  24. 1 module Plugins
  25. 1 @plugins = {}
  26. # Loads a plugin based on a name. If the plugin hasn't been loaded, tries to load
  27. # it from the load path under "httpx/plugins/" directory.
  28. #
  29. 1 def self.load_plugin(name)
  30. 174 h = @plugins
  31. 174 unless (plugin = h[name])
  32. 19 require "httpx/plugins/#{name}"
  33. 19 raise "Plugin #{name} hasn't been registered" unless (plugin = h[name])
  34. end
  35. 174 plugin
  36. end
  37. # Registers a plugin (+mod+) in the central store indexed by +name+.
  38. #
  39. 1 def self.register_plugin(name, mod)
  40. 20 @plugins[name] = mod
  41. end
  42. end
  43. skipped # :nocov:
  44. skipped def self.const_missing(const_name)
  45. skipped super unless const_name == :Client
  46. skipped warn "DEPRECATION WARNING: the class #{self}::Client is deprecated. Use #{self}::Session instead."
  47. skipped Session
  48. skipped end
  49. skipped # :nocov:
  50. 1 extend Chainable
  51. end

lib/httpx/adapters/faraday.rb

98.02% lines covered

101 relevant lines. 99 lines covered and 2 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require "httpx"
  3. 1 require "faraday"
  4. 1 module Faraday
  5. 1 class Adapter
  6. 1 class HTTPX < Faraday::Adapter
  7. skipped # :nocov:
  8. skipped SSL_ERROR = if defined?(Faraday::SSLError)
  9. skipped Faraday::SSLError
  10. skipped else
  11. skipped Faraday::Error::SSLError
  12. skipped end
  13. skipped
  14. skipped CONNECTION_FAILED_ERROR = if defined?(Faraday::ConnectionFailed)
  15. skipped Faraday::ConnectionFailed
  16. skipped else
  17. skipped Faraday::Error::ConnectionFailed
  18. skipped end
  19. skipped # :nocov:
  20. 1 module RequestMixin
  21. 1 private
  22. 1 def build_request(env)
  23. 20 meth = env[:method]
  24. request_options = {
  25. 20 headers: env.request_headers,
  26. body: env.body,
  27. }
  28. 20 [meth, env.url, request_options]
  29. end
  30. 1 def options_from_env(env)
  31. timeout_options = {
  32. 19 connect_timeout: env.request.open_timeout,
  33. operation_timeout: env.request.timeout,
  34. 38 }.reject { |_, v| v.nil? }
  35. options = {
  36. 19 ssl: {},
  37. timeout: timeout_options,
  38. }
  39. 19 options[:ssl][:verify_mode] = OpenSSL::SSL::VERIFY_PEER if env.ssl.verify
  40. 19 options[:ssl][:ca_file] = env.ssl.ca_file if env.ssl.ca_file
  41. 19 options[:ssl][:ca_path] = env.ssl.ca_path if env.ssl.ca_path
  42. 19 options[:ssl][:cert_store] = env.ssl.cert_store if env.ssl.cert_store
  43. 19 options[:ssl][:cert] = env.ssl.client_cert if env.ssl.client_cert
  44. 19 options[:ssl][:key] = env.ssl.client_key if env.ssl.client_key
  45. 19 options[:ssl][:ssl_version] = env.ssl.version if env.ssl.version
  46. 19 options[:ssl][:verify_depth] = env.ssl.verify_depth if env.ssl.verify_depth
  47. 19 options[:ssl][:min_version] = env.ssl.min_version if env.ssl.min_version
  48. 19 options[:ssl][:max_version] = env.ssl.max_version if env.ssl.max_version
  49. 19 options
  50. end
  51. end
  52. 1 include RequestMixin
  53. 1 class Session < ::HTTPX::Session
  54. 1 plugin(:compression)
  55. 1 plugin(:persistent)
  56. skipped # :nocov:
  57. skipped module ReasonPlugin
  58. skipped if RUBY_VERSION < "2.5"
  59. skipped def self.load_dependencies(*)
  60. skipped require "webrick"
  61. skipped end
  62. skipped else
  63. skipped def self.load_dependencies(*)
  64. skipped require "net/http/status"
  65. skipped end
  66. skipped end
  67. skipped module ResponseMethods
  68. skipped if RUBY_VERSION < "2.5"
  69. skipped def reason
  70. skipped WEBrick::HTTPStatus::StatusMessage.fetch(@status)
  71. skipped end
  72. skipped else
  73. skipped def reason
  74. skipped Net::HTTP::STATUS_CODES.fetch(@status)
  75. skipped end
  76. skipped end
  77. skipped end
  78. skipped end
  79. skipped # :nocov:
  80. 1 plugin(ReasonPlugin)
  81. end
  82. 1 class ParallelManager
  83. 1 class ResponseHandler
  84. 1 attr_reader :env
  85. 1 def initialize(env)
  86. 2 @env = env
  87. end
  88. 1 def on_response(&blk)
  89. 4 if block_given?
  90. 2 @on_response = lambda do |response|
  91. 2 blk.call(response)
  92. end
  93. 2 self
  94. else
  95. 2 @on_response
  96. end
  97. end
  98. 1 def on_complete(&blk)
  99. 4 if block_given?
  100. 2 @on_complete = blk
  101. 2 self
  102. else
  103. 2 @on_complete
  104. end
  105. end
  106. 1 def respond_to_missing?(meth)
  107. @env.respond_to?(meth)
  108. end
  109. 1 def method_missing(meth, *args, &blk)
  110. 4 if @env && @env.respond_to?(meth)
  111. 4 @env.__send__(meth, *args, &blk)
  112. else
  113. super
  114. end
  115. end
  116. end
  117. 1 include RequestMixin
  118. 1 def initialize
  119. 1 @session = Session.new
  120. 1 @handlers = []
  121. end
  122. 1 def enqueue(request)
  123. 2 handler = ResponseHandler.new(request)
  124. 2 @handlers << handler
  125. 2 handler
  126. end
  127. 1 def run
  128. 3 requests = @handlers.map { |handler| build_request(handler.env) }
  129. 1 env = @handlers.last.env
  130. 1 proxy_options = { uri: env.request.proxy }
  131. 1 session = @session.with(options_from_env(env))
  132. 1 session = session.plugin(:proxy).with(proxy: proxy_options) if env.request.proxy
  133. 1 responses = session.request(requests)
  134. 1 Array(responses).each_with_index do |response, index|
  135. 2 handler = @handlers[index]
  136. 2 handler.on_response.call(response)
  137. 2 handler.on_complete.call(handler.env)
  138. end
  139. end
  140. end
  141. 1 self.supports_parallel = true
  142. 1 class << self
  143. 1 def setup_parallel_manager
  144. 1 ParallelManager.new
  145. end
  146. end
  147. 1 def initialize(app)
  148. 19 super(app)
  149. 19 @session = Session.new
  150. end
  151. 1 def call(env)
  152. 20 super
  153. 20 if parallel?(env)
  154. 2 handler = env[:parallel_manager].enqueue(env)
  155. 2 handler.on_response do |response|
  156. 2 save_response(env, response.status, response.body.to_s, response.headers, response.reason) do |response_headers|
  157. 2 response_headers.merge!(response.headers)
  158. end
  159. end
  160. 2 return handler
  161. end
  162. 18 meth, uri, request_options = build_request(env)
  163. 18 session = @session.with(options_from_env(env))
  164. 18 session = session.plugin(:proxy).with(proxy: proxy_options) if env.request.proxy
  165. 18 response = session.__send__(meth, uri, **request_options)
  166. 18 response.raise_for_status unless response.is_a?(::HTTPX::Response)
  167. 16 save_response(env, response.status, response.body.to_s, response.headers, response.reason) do |response_headers|
  168. 16 response_headers.merge!(response.headers)
  169. end
  170. 16 @app.call(env)
  171. rescue OpenSSL::SSL::SSLError => e
  172. 1 raise SSL_ERROR, e
  173. rescue Errno::ECONNABORTED,
  174. Errno::ECONNREFUSED,
  175. Errno::ECONNRESET,
  176. Errno::EHOSTUNREACH,
  177. Errno::EINVAL,
  178. Errno::ENETUNREACH,
  179. Errno::EPIPE => e
  180. 1 raise CONNECTION_FAILED_ERROR, e
  181. end
  182. 1 private
  183. 1 def parallel?(env)
  184. 20 env[:parallel_manager]
  185. end
  186. end
  187. 1 register_middleware httpx: HTTPX
  188. end
  189. end

lib/httpx/altsvc.rb

92.86% lines covered

42 relevant lines. 39 lines covered and 3 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require "strscan"
  3. 1 module HTTPX
  4. 1 module AltSvc
  5. 1 @altsvc_mutex = Mutex.new
  6. 3 @altsvcs = Hash.new { |h, k| h[k] = [] }
  7. 1 module_function
  8. 1 def cached_altsvc(origin)
  9. 26 now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
  10. 26 @altsvc_mutex.synchronize do
  11. 26 lookup(origin, now)
  12. end
  13. end
  14. 1 def cached_altsvc_set(origin, entry)
  15. 7 now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
  16. 7 @altsvc_mutex.synchronize do
  17. 12 return if @altsvcs[origin].any? { |altsvc| altsvc["origin"] == entry["origin"] }
  18. 2 entry["TTL"] = Integer(entry["ma"]) + now if entry.key?("ma")
  19. 2 @altsvcs[origin] << entry
  20. 2 entry
  21. end
  22. end
  23. 1 def lookup(origin, ttl)
  24. 26 return [] unless @altsvcs.key?(origin)
  25. @altsvcs[origin] = @altsvcs[origin].select do |entry|
  26. !entry.key?("TTL") || entry["TTL"] > ttl
  27. end
  28. @altsvcs[origin].reject { |entry| entry["noop"] }
  29. end
  30. 1 def emit(request, response)
  31. # Alt-Svc
  32. 271 return unless response.headers.key?("alt-svc")
  33. 7 origin = request.origin
  34. 7 host = request.uri.host
  35. 7 parse(response.headers["alt-svc"]) do |alt_origin, alt_params|
  36. 7 alt_origin.host ||= host
  37. 7 yield(alt_origin, origin, alt_params)
  38. end
  39. end
  40. 1 def parse(altsvc)
  41. 21 return enum_for(__method__, altsvc) unless block_given?
  42. 14 scanner = StringScanner.new(altsvc)
  43. 14 until scanner.eos?
  44. 16 alt_origin = scanner.scan(/[^=]+=("[^"]+"|[^;,]+)/)
  45. 16 alt_params = []
  46. 16 loop do
  47. 19 alt_param = scanner.scan(/[^=]+=("[^"]+"|[^;,]+)/)
  48. 19 alt_params << alt_param.strip if alt_param
  49. 19 scanner.skip(/;/)
  50. 19 break if scanner.eos? || scanner.scan(/ *, */)
  51. end
  52. 32 alt_params = Hash[alt_params.map { |field| field.split("=") }]
  53. 16 yield(parse_altsvc_origin(alt_origin), alt_params)
  54. end
  55. end
  56. skipped # :nocov:
  57. skipped if RUBY_VERSION < "2.2"
  58. skipped def parse_altsvc_origin(alt_origin)
  59. skipped alt_proto, alt_origin = alt_origin.split("=")
  60. skipped alt_origin = alt_origin[1..-2] if alt_origin.start_with?("\"") && alt_origin.end_with?("\"")
  61. skipped if alt_origin.start_with?(":")
  62. skipped alt_origin = "dummy#{alt_origin}"
  63. skipped uri = URI.parse(alt_origin)
  64. skipped uri.host = nil
  65. skipped uri
  66. skipped else
  67. skipped URI.parse("#{alt_proto}://#{alt_origin}")
  68. skipped end
  69. skipped end
  70. skipped else
  71. skipped def parse_altsvc_origin(alt_origin)
  72. skipped alt_proto, alt_origin = alt_origin.split("=")
  73. skipped alt_origin = alt_origin[1..-2] if alt_origin.start_with?("\"") && alt_origin.end_with?("\"")
  74. skipped URI.parse("#{alt_proto}://#{alt_origin}")
  75. skipped end
  76. skipped end
  77. skipped # :nocov:
  78. end
  79. end

lib/httpx/buffer.rb

100.0% lines covered

19 relevant lines. 19 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require "forwardable"
  3. 1 module HTTPX
  4. 1 class Buffer
  5. 1 extend Forwardable
  6. 1 def_delegator :@buffer, :<<
  7. 1 def_delegator :@buffer, :to_s
  8. 1 def_delegator :@buffer, :to_str
  9. 1 def_delegator :@buffer, :empty?
  10. 1 def_delegator :@buffer, :bytesize
  11. 1 def_delegator :@buffer, :clear
  12. 1 def_delegator :@buffer, :replace
  13. 1 attr_reader :limit
  14. 1 def initialize(limit)
  15. 577 @buffer = "".b
  16. 577 @limit = limit
  17. end
  18. 1 def full?
  19. 669 @buffer.bytesize >= @limit
  20. end
  21. 1 def shift!(fin)
  22. 599 @buffer = @buffer.byteslice(fin..-1)
  23. end
  24. end
  25. end

lib/httpx/callbacks.rb

100.0% lines covered

15 relevant lines. 15 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module HTTPX
  3. 1 module Callbacks
  4. 1 def on(type, &action)
  5. 5024 callbacks(type) << action
  6. end
  7. 1 def once(event, &block)
  8. 14 on(event) do |*args, &callback|
  9. 6 block.call(*args, &callback)
  10. 1 :delete
  11. end
  12. end
  13. 1 def emit(type, *args)
  14. 1882859 callbacks(type).delete_if { |pr| pr[*args] == :delete }
  15. end
  16. 1 protected
  17. 1 def callbacks(type = nil)
  18. 1886149 return @callbacks unless type
  19. 1891953 @callbacks ||= Hash.new { |h, k| h[k] = [] }
  20. 1886149 @callbacks[type]
  21. end
  22. end
  23. end

lib/httpx/chainable.rb

83.33% lines covered

36 relevant lines. 30 lines covered and 6 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module HTTPX
  3. 1 module Chainable
  4. 1 %i[head get post put delete trace options connect patch].each do |meth|
  5. 9 define_method meth do |*uri, **options|
  6. 243 request(meth, uri, **options)
  7. end
  8. end
  9. 1 def request(verb, uri, **options)
  10. 79 branch(default_options).request(verb, uri, **options)
  11. end
  12. skipped # :nocov:
  13. skipped def timeout(**args)
  14. skipped warn ":#{__method__} is deprecated, use :with_timeout instead"
  15. skipped branch(default_options.with(timeout: args))
  16. skipped end
  17. skipped
  18. skipped def headers(headers)
  19. skipped warn ":#{__method__} is deprecated, use :with_headers instead"
  20. skipped branch(default_options.with(headers: headers))
  21. skipped end
  22. skipped # :nocov:
  23. 1 def accept(type)
  24. 2 with(headers: { "accept" => String(type) })
  25. end
  26. 1 def wrap(&blk)
  27. 2 branch(default_options).wrap(&blk)
  28. end
  29. 1 def plugin(*args, **opts)
  30. 119 klass = is_a?(Session) ? self.class : Session
  31. 119 klass = Class.new(klass)
  32. 119 klass.instance_variable_set(:@default_options, klass.default_options.merge(default_options))
  33. 119 klass.plugin(*args, **opts).new
  34. end
  35. # deprecated
  36. 1 def plugins(*args, **opts)
  37. klass = is_a?(Session) ? self.class : Session
  38. klass = Class.new(klass)
  39. klass.instance_variable_set(:@default_options, klass.default_options.merge(default_options))
  40. klass.plugins(*args, **opts).new
  41. end
  42. 1 def with(options, &blk)
  43. 102 branch(default_options.merge(options), &blk)
  44. end
  45. 1 private
  46. 1 def default_options
  47. 330 @options || Options.new
  48. end
  49. # :nodoc:
  50. 1 def branch(options, &blk)
  51. 211 return self.class.new(options, &blk) if is_a?(Session)
  52. 102 Session.new(options, &blk)
  53. end
  54. 1 def method_missing(meth, *args, **options)
  55. 68 if meth =~ /\Awith_(.+)/
  56. 68 option = Regexp.last_match(1).to_sym
  57. 68 with(option => (args.first || options))
  58. else
  59. super
  60. end
  61. end
  62. 1 def respond_to_missing?(meth, *args)
  63. default_options.respond_to?(meth, *args) || super
  64. end
  65. end
  66. end

lib/httpx/connection.rb

89.78% lines covered

225 relevant lines. 202 lines covered and 23 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require "resolv"
  3. 1 require "forwardable"
  4. 1 require "httpx/io"
  5. 1 require "httpx/buffer"
  6. 1 module HTTPX
  7. # The Connection can be watched for IO events.
  8. #
  9. # It contains the +io+ object to read/write from, and knows what to do when it can.
  10. #
  11. # It defers connecting until absolutely necessary. Connection should be triggered from
  12. # the IO selector (until then, any request will be queued).
  13. #
  14. # A connection boots up its parser after connection is established. All pending requests
  15. # will be redirected there after connection.
  16. #
  17. # A connection can be prevented from closing by the parser, that is, if there are pending
  18. # requests. This will signal that the connection was prematurely closed, due to a possible
  19. # number of conditions:
  20. #
  21. # * Remote peer closed the connection ("Connection: close");
  22. # * Remote peer doesn't support pipelining;
  23. #
  24. # A connection may also route requests for a different host for which the +io+ was connected
  25. # to, provided that the IP is the same and the port and scheme as well. This will allow to
  26. # share the same socket to send HTTP/2 requests to different hosts.
  27. #
  28. 1 class Connection
  29. 1 extend Forwardable
  30. 1 include Registry
  31. 1 include Loggable
  32. 1 include Callbacks
  33. 1 using URIExtensions
  34. 1 require "httpx/connection/http2"
  35. 1 require "httpx/connection/http1"
  36. 1 BUFFER_SIZE = 1 << 14
  37. 1 def_delegator :@io, :closed?
  38. 1 def_delegator :@write_buffer, :empty?
  39. 1 attr_reader :origin, :state, :pending, :options
  40. 1 attr_writer :timers
  41. 1 def initialize(type, uri, options)
  42. 277 @type = type
  43. 277 @origins = [uri.origin]
  44. 277 @origin = URI(uri.origin)
  45. 277 @options = Options.new(options)
  46. 277 @window_size = @options.window_size
  47. 277 @read_buffer = Buffer.new(BUFFER_SIZE)
  48. 277 @write_buffer = Buffer.new(BUFFER_SIZE)
  49. 277 @pending = []
  50. 277 on(:error, &method(:on_error))
  51. 277 if @options.io
  52. # if there's an already open IO, get its
  53. # peer address, and force-initiate the parser
  54. 4 transition(:already_open)
  55. 4 @io = IO.registry(@type).new(@origin, nil, @options)
  56. 4 parser
  57. else
  58. 273 transition(:idle)
  59. end
  60. end
  61. # this is a semi-private method, to be used by the resolver
  62. # to initiate the io object.
  63. 1 def addresses=(addrs)
  64. 271 @io ||= IO.registry(@type).new(@origin, addrs, @options) # rubocop:disable Naming/MemoizedInstanceVariableName
  65. end
  66. 1 def addresses
  67. 308 @io && @io.addresses
  68. end
  69. 1 def match?(uri, options)
  70. 160 return false if @state == :closing || @state == :closed
  71. 77 return false if exhausted?
  72. (
  73. (
  74. 77 @origins.include?(uri.origin) &&
  75. # if there is more than one origin to match, it means that this connection
  76. # was the result of coalescing. To prevent blind trust in the case where the
  77. # origin came from an ORIGIN frame, we're going to verify the hostname with the
  78. # SSL certificate
  79. 51 (@origins.size == 1 || @origin == uri.origin || (@io && @io.verify_hostname(uri.host)))
  80. ) || match_altsvcs?(uri)
  81. ) && @options == options
  82. end
  83. 1 def mergeable?(connection)
  84. 107 return false if @state == :closing || @state == :closed || !@io
  85. 35 return false if exhausted?
  86. 35 !(@io.addresses & connection.addresses).empty? && @options == connection.options
  87. end
  88. # coalescable connections need to be mergeable!
  89. # but internally, #mergeable? is called before #coalescable?
  90. 1 def coalescable?(connection)
  91. 1 if @io.protocol == "h2" &&
  92. @origin.scheme == "https" &&
  93. connection.origin.scheme == "https"
  94. 1 @io.verify_hostname(connection.origin.host)
  95. else
  96. @origin == connection.origin
  97. end
  98. end
  99. 1 def create_idle
  100. 1 self.class.new(@type, @origin, @options)
  101. end
  102. 1 def merge(connection)
  103. 3 @origins += connection.instance_variable_get(:@origins)
  104. 3 connection.purge_pending do |req|
  105. 2 send(req)
  106. end
  107. end
  108. 1 def unmerge(connection)
  109. @origins -= connection.instance_variable_get(:@origins)
  110. purge_pending do |request|
  111. request.uri.origin == connection.origin && begin
  112. request.transition(:idle)
  113. connection.send(request)
  114. true
  115. end
  116. end
  117. end
  118. 1 def purge_pending
  119. 4 pendings = []
  120. 4 pendings << @parser.pending if @parser
  121. 4 pendings << @pending
  122. 4 pendings.each do |pending|
  123. 7 pending.reject! do |request|
  124. 2 yield request
  125. end
  126. end
  127. end
  128. # checks if this is connection is an alternative service of
  129. # +uri+
  130. 1 def match_altsvcs?(uri)
  131. 138 @origins.any? { |origin| uri.altsvc_match?(origin) } ||
  132. AltSvc.cached_altsvc(@origin).any? do |altsvc|
  133. origin = altsvc["origin"]
  134. origin.altsvc_match?(uri.origin)
  135. end
  136. end
  137. 1 def connecting?
  138. 13 @state == :idle
  139. end
  140. 1 def inflight?
  141. 243 @parser && !@parser.empty? && !@write_buffer.empty?
  142. end
  143. 1 def interests
  144. 2505575 return :w if @state == :idle
  145. 2502739 :rw
  146. end
  147. 1 def to_io
  148. 9992759 case @state
  149. when :idle
  150. 6235 transition(:open)
  151. end
  152. 9992759 @io.to_io
  153. end
  154. 1 def close
  155. 247 @parser.close if @parser
  156. 247 transition(:closing)
  157. end
  158. 1 def reset
  159. 37 transition(:closing)
  160. 37 transition(:closed)
  161. 37 emit(:close)
  162. end
  163. 1 def send(request)
  164. 303 if @parser && !@write_buffer.full?
  165. 32 request.headers["alt-used"] = @origin.authority if match_altsvcs?(request.uri)
  166. 32 parser.send(request)
  167. else
  168. 271 @pending << request
  169. end
  170. end
  171. 1 def call
  172. 2505575 case @state
  173. when :closed
  174. return
  175. when :closing
  176. 216 dwrite
  177. 216 transition(:closed)
  178. 216 emit(:close)
  179. when :open
  180. 2502504 consume
  181. end
  182. nil
  183. end
  184. 1 def timeout
  185. 2861754 return @timeout if defined?(@timeout)
  186. 2861754 return @options.timeout.connect_timeout if @state == :idle
  187. 2858514 @options.timeout.operation_timeout
  188. end
  189. 1 private
  190. 1 def exhausted?
  191. 112 @parser && parser.exhausted?
  192. end
  193. 1 def consume
  194. 2502526 catch(:called) do
  195. 2502526 dread
  196. 2502404 dwrite
  197. 2502404 parser.consume
  198. end
  199. end
  200. 1 def dread(wsize = @window_size)
  201. 2502526 loop do
  202. 2503751 siz = @io.read(wsize, @read_buffer)
  203. 2503751 unless siz
  204. 1 ex = EOFError.new("descriptor closed")
  205. 1 ex.set_backtrace(caller)
  206. 1 on_error(ex)
  207. 1 return
  208. end
  209. 2503750 return if siz.zero?
  210. 1356 log { "READ: #{siz} bytes..." }
  211. 1349 parser << @read_buffer.to_s
  212. 1227 return if @state == :closing || @state == :closed
  213. end
  214. end
  215. 1 def dwrite
  216. 2502620 loop do
  217. 2503103 return if @write_buffer.empty?
  218. 581 siz = @io.write(@write_buffer)
  219. 581 unless siz
  220. ex = EOFError.new("descriptor closed")
  221. ex.set_backtrace(caller)
  222. on_error(ex)
  223. return
  224. end
  225. 585 log { "WRITE: #{siz} bytes..." }
  226. 581 return if siz.zero?
  227. 581 return if @state == :closing || @state == :closed
  228. end
  229. end
  230. 1 def send_pending
  231. 801 while !@write_buffer.full? && (request = @pending.shift)
  232. 269 parser.send(request)
  233. end
  234. end
  235. 1 def parser
  236. 2504225 @parser ||= build_parser
  237. end
  238. 1 def build_parser(protocol = @io.protocol)
  239. 261 parser = registry(protocol).new(@write_buffer, @options)
  240. 261 set_parser_callbacks(parser)
  241. 261 parser
  242. end
  243. 1 def set_parser_callbacks(parser)
  244. 264 parser.on(:response) do |request, response|
  245. 271 AltSvc.emit(request, response) do |alt_origin, origin, alt_params|
  246. 7 emit(:altsvc, alt_origin, origin, alt_params)
  247. end
  248. 271 request.emit(:response, response)
  249. end
  250. 264 parser.on(:altsvc) do |alt_origin, origin, alt_params|
  251. emit(:altsvc, alt_origin, origin, alt_params)
  252. end
  253. 264 parser.on(:promise) do |request, stream|
  254. 2 request.emit(:promise, parser, stream)
  255. end
  256. 264 parser.on(:exhausted) do
  257. 1 emit(:exhausted)
  258. end
  259. 264 parser.on(:origin) do |origin|
  260. @origins << origin
  261. end
  262. 264 parser.on(:close) do
  263. 141 transition(:closing)
  264. end
  265. 264 parser.on(:reset) do
  266. 114 transition(:closing)
  267. 114 unless parser.empty?
  268. 3 transition(:closed)
  269. 3 emit(:reset)
  270. 3 transition(:idle)
  271. 3 transition(:open)
  272. end
  273. end
  274. 264 parser.on(:timeout) do |tout|
  275. @timeout = tout
  276. end
  277. 264 parser.on(:error) do |request, ex|
  278. 30 case ex
  279. when MisdirectedRequestError
  280. emit(:uncoalesce, request.uri)
  281. else
  282. 30 response = ErrorResponse.new(request, ex, @options)
  283. 30 request.emit(:response, response)
  284. end
  285. end
  286. end
  287. 1 def transition(nextstate)
  288. 7347 case nextstate
  289. when :open
  290. 6247 return if @state == :closed
  291. 6247 total_timeout
  292. 6247 @io.connect
  293. 6242 return unless @io.connected?
  294. 262 send_pending
  295. 262 emit(:open)
  296. when :closing
  297. 543 return unless @state == :open
  298. when :closed
  299. 256 return unless @state == :closing
  300. 251 return unless @write_buffer.empty?
  301. 235 if @total_timeout
  302. 14 @total_timeout.cancel
  303. 14 remove_instance_variable(:@total_timeout)
  304. end
  305. 235 @io.close
  306. 235 @read_buffer.clear
  307. 235 @parser.reset if @parser
  308. 235 remove_instance_variable(:@timeout) if defined?(@timeout)
  309. when :already_open
  310. 4 nextstate = :open
  311. 4 send_pending
  312. end
  313. 1055 @state = nextstate
  314. rescue Errno::EHOSTUNREACH
  315. # at this point, all addresses from the IO object have failed
  316. reset
  317. emit(:unreachable)
  318. throw(:jump_tick)
  319. rescue Errno::ECONNREFUSED,
  320. Errno::EADDRNOTAVAIL,
  321. Errno::EHOSTUNREACH,
  322. OpenSSL::SSL::SSLError => e
  323. # connect errors, exit gracefully
  324. 5 handle_error(e)
  325. 5 @state = :closed
  326. 5 emit(:close)
  327. end
  328. 1 def on_error(ex)
  329. 37 handle_error(ex)
  330. 37 reset
  331. end
  332. 1 def handle_error(error)
  333. 42 if error.instance_of?(TimeoutError)
  334. 3 if @timeout
  335. @timeout -= error.timeout
  336. return unless @timeout <= 0
  337. end
  338. 3 error = error.to_connection_error if connecting?
  339. end
  340. 42 parser.handle_error(error) if @parser && parser.respond_to?(:handle_error)
  341. 94 while (request = @pending.shift)
  342. 10 request.emit(:response, ErrorResponse.new(request, error, @options))
  343. end
  344. end
  345. 1 def total_timeout
  346. 6247 total = @options.timeout.total_timeout
  347. 6247 return unless total
  348. 59 @total_timeout ||= @timers.after(total) do
  349. 28 ex = TotalTimeoutError.new(total, "Timed out after #{total} seconds")
  350. 28 ex.set_backtrace(caller)
  351. 28 @parser.close if @parser
  352. 28 on_error(ex)
  353. end
  354. end
  355. end
  356. end

lib/httpx/connection/http1.rb

89.54% lines covered

153 relevant lines. 137 lines covered and 16 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require "httpx/parser/http1"
  3. 1 module HTTPX
  4. 1 class Connection::HTTP1
  5. 1 include Callbacks
  6. 1 include Loggable
  7. 1 MAX_REQUESTS = 100
  8. 1 CRLF = "\r\n"
  9. 1 attr_reader :pending
  10. 1 def initialize(buffer, options)
  11. 140 @options = Options.new(options)
  12. 140 @max_concurrent_requests = @options.max_concurrent_requests || MAX_REQUESTS
  13. 140 @max_requests = @options.max_requests || MAX_REQUESTS
  14. 140 @parser = Parser::HTTP1.new(self)
  15. 140 @buffer = buffer
  16. 140 @version = [1, 1]
  17. 140 @pending = []
  18. 140 @requests = []
  19. end
  20. 1 def reset
  21. 279 @max_requests = @options.max_requests || MAX_REQUESTS
  22. 279 @parser.reset!
  23. end
  24. 1 def close
  25. 140 reset
  26. 140 emit(:close)
  27. end
  28. 1 def exhausted?
  29. 3 !@max_requests.positive?
  30. end
  31. 1 def empty?
  32. # this means that for every request there's an available
  33. # partial response, so there are no in-flight requests waiting.
  34. 256 @requests.empty? || @requests.all? { |request| !request.response.nil? }
  35. end
  36. 1 def <<(data)
  37. 442 @parser << data
  38. end
  39. 1 def send(request)
  40. 141 unless @max_requests.positive?
  41. @pending << request
  42. return
  43. end
  44. 141 return if @requests.include?(request)
  45. 141 @requests << request
  46. 141 @pipelining = true if @requests.size > 1
  47. end
  48. 1 def consume
  49. 910115 requests_limit = [@max_concurrent_requests, @max_requests, @requests.size].min
  50. 910115 @requests.each_with_index do |request, idx|
  51. 929200 break if idx >= requests_limit
  52. 918011 handle(request)
  53. end
  54. end
  55. # HTTP Parser callbacks
  56. #
  57. # must be public methods, or else they won't be reachable
  58. 1 def on_start
  59. 133 log(level: 2) { "parsing begins" }
  60. end
  61. 1 def on_headers(h)
  62. 132 @request = @requests.first
  63. 132 return if @request.response
  64. 133 log(level: 2) { "headers received" }
  65. 132 headers = @request.options.headers_class.new(h)
  66. 132 response = @request.options.response_class.new(@request,
  67. @parser.status_code,
  68. @parser.http_version.join("."),
  69. headers)
  70. 133 log(color: :yellow) { "-> HEADLINE: #{response.status} HTTP/#{@parser.http_version.join(".")}" }
  71. 141 log(color: :yellow) { response.headers.each.map { |f, v| "-> HEADER: #{f}: #{v}" }.join("\n") }
  72. 132 @request.response = response
  73. 132 on_complete if response.complete?
  74. end
  75. 1 def on_trailers(h)
  76. return unless @request
  77. response = @request.response
  78. log(level: 2) { "trailer headers received" }
  79. log(color: :yellow) { h.each.map { |f, v| "-> HEADER: #{f}: #{v}" }.join("\n") }
  80. response.merge_headers(h)
  81. end
  82. 1 def on_data(chunk)
  83. 199 return unless @request
  84. 200 log(color: :green) { "-> DATA: #{chunk.bytesize} bytes..." }
  85. 200 log(level: 2, color: :green) { "-> #{chunk.inspect}" }
  86. 199 response = @request.response
  87. 199 response << chunk
  88. end
  89. 1 def on_complete
  90. 132 return unless @request
  91. 133 log(level: 2) { "parsing complete" }
  92. 132 dispatch
  93. end
  94. 1 def dispatch
  95. 132 if @request.expects?
  96. 6 @parser.reset!
  97. 6 return handle(@request)
  98. end
  99. 126 request = @request
  100. 126 @request = nil
  101. 126 @requests.shift
  102. 126 response = request.response
  103. 126 emit(:response, request, response)
  104. 125 if @parser.upgrade?
  105. 1 response << @parser.upgrade_data
  106. 1 throw(:called)
  107. end
  108. 124 @parser.reset!
  109. 124 @max_requests -= 1
  110. 124 manage_connection(response)
  111. 10 send(@pending.shift) unless @pending.empty?
  112. end
  113. 1 def handle_error(ex)
  114. 15 if @pipelining
  115. disable
  116. else
  117. 15 @requests.each do |request|
  118. 15 emit(:error, request, ex)
  119. end
  120. end
  121. end
  122. 1 private
  123. 1 def manage_connection(response)
  124. 124 connection = response.headers["connection"]
  125. 124 case connection
  126. when /keep\-alive/i
  127. 5 keep_alive = response.headers["keep-alive"]
  128. 5 return unless keep_alive
  129. parameters = Hash[keep_alive.split(/ *, */).map do |pair|
  130. pair.split(/ *= */)
  131. end]
  132. @max_requests = parameters["max"].to_i if parameters.key?("max")
  133. if parameters.key?("timeout")
  134. keep_alive_timeout = parameters["timeout"].to_i
  135. emit(:timeout, keep_alive_timeout)
  136. end
  137. when /close/i
  138. 114 disable
  139. when nil
  140. # In HTTP/1.1, it's keep alive by default
  141. 5 return if response.version == "1.1"
  142. disable
  143. end
  144. end
  145. 1 def disable
  146. 114 disable_pipelining
  147. 114 emit(:reset)
  148. 114 throw(:called)
  149. end
  150. 1 def disable_pipelining
  151. 114 return if @requests.empty?
  152. 6 @requests.each { |r| r.transition(:idle) }
  153. # server doesn't handle pipelining, and probably
  154. # doesn't support keep-alive. Fallback to send only
  155. # 1 keep alive request.
  156. 3 @max_concurrent_requests = 1
  157. 3 @pipelining = false
  158. end
  159. 1 def set_request_headers(request)
  160. 918017 request.headers["host"] ||= request.authority
  161. 918017 request.headers["connection"] ||= "keep-alive"
  162. 918017 if !request.headers.key?("content-length") &&
  163. request.body.bytesize == Float::INFINITY
  164. request.chunk!
  165. end
  166. end
  167. 1 def headline_uri(request)
  168. 137 request.path
  169. end
  170. 1 def handle(request)
  171. 918017 set_request_headers(request)
  172. 918017 catch(:buffer_full) do
  173. 918017 request.transition(:headers)
  174. 918017 join_headers(request) if request.state == :headers
  175. 918017 request.transition(:body)
  176. 918017 join_body(request) if request.state == :body
  177. 918009 request.transition(:done)
  178. end
  179. end
  180. 1 def join_headers(request)
  181. 141 buffer = +""
  182. 141 buffer << "#{request.verb.to_s.upcase} #{headline_uri(request)} HTTP/#{@version.join(".")}" << CRLF
  183. 142 log(color: :yellow) { "<- HEADLINE: #{buffer.chomp.inspect}" }
  184. 141 @buffer << buffer
  185. 141 buffer.clear
  186. 141 request.headers.each do |field, value|
  187. 702 buffer << "#{capitalized(field)}: #{value}" << CRLF
  188. 706 log(color: :yellow) { "<- HEADER: #{buffer.chomp}" }
  189. 702 @buffer << buffer
  190. 702 buffer.clear
  191. end
  192. 142 log { "<- " }
  193. 141 @buffer << CRLF
  194. end
  195. 1 def join_body(request)
  196. 149 return if request.empty?
  197. 176 while (chunk = request.drain_body)
  198. 82 log(color: :green) { "<- DATA: #{chunk.bytesize} bytes..." }
  199. 82 log(level: 2, color: :green) { "<- #{chunk.inspect}" }
  200. 82 @buffer << chunk
  201. 82 throw(:buffer_full, request) if @buffer.full?
  202. end
  203. end
  204. 1 UPCASED = {
  205. "www-authenticate" => "WWW-Authenticate",
  206. "http2-settings" => "HTTP2-Settings",
  207. }.freeze
  208. 1 def capitalized(field)
  209. 702 UPCASED[field] || field.to_s.split("-").map(&:capitalize).join("-")
  210. end
  211. end
  212. 1 Connection.register "http/1.1", Connection::HTTP1
  213. end

lib/httpx/connection/http2.rb

84.7% lines covered

183 relevant lines. 155 lines covered and 28 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require "io/wait"
  3. 1 require "http/2/next"
  4. 1 module HTTPX
  5. 1 class Connection::HTTP2
  6. 1 include Callbacks
  7. 1 include Loggable
  8. 1 MAX_CONCURRENT_REQUESTS = HTTP2Next::DEFAULT_MAX_CONCURRENT_STREAMS
  9. 1 Error = Class.new(Error) do
  10. 1 def initialize(id, code)
  11. super("stream #{id} closed with error: #{code}")
  12. end
  13. end
  14. 1 attr_reader :streams, :pending
  15. 1 def initialize(buffer, options)
  16. 128 @options = Options.new(options)
  17. 128 @max_concurrent_requests = @options.max_concurrent_requests || MAX_CONCURRENT_REQUESTS
  18. 128 @max_requests = @options.max_requests || 0
  19. 128 @pending = []
  20. 128 @streams = {}
  21. 128 @drains = {}
  22. 128 @buffer = buffer
  23. 128 @handshake_completed = false
  24. 128 init_connection
  25. end
  26. 1 def reset
  27. 97 init_connection
  28. end
  29. 1 def close
  30. 131 @connection.goaway unless @connection.state == :closed
  31. end
  32. 1 def empty?
  33. 109 @connection.state == :closed || @streams.empty?
  34. end
  35. 1 def exhausted?
  36. 238 @connection.active_stream_count >= @max_requests
  37. end
  38. 1 def <<(data)
  39. 897 @connection << data
  40. end
  41. 1 def send(request)
  42. 298 if !@handshake_completed ||
  43. @streams.size >= @max_concurrent_requests ||
  44. @streams.size >= @max_requests
  45. 138 @pending << request
  46. 138 return
  47. end
  48. 160 unless (stream = @streams[request])
  49. 160 stream = @connection.new_stream
  50. 160 handle_stream(stream, request)
  51. 160 @streams[request] = stream
  52. 160 @max_requests -= 1
  53. end
  54. 160 handle(request, stream)
  55. 160 true
  56. rescue HTTP2Next::Error::StreamLimitExceeded
  57. @pending.unshift(request)
  58. emit(:exhausted)
  59. end
  60. 1 def consume
  61. 1592279 @streams.each do |request, stream|
  62. 982091 handle(request, stream)
  63. end
  64. end
  65. 1 def handle_error(ex)
  66. 17 @streams.each_key do |request|
  67. 15 emit(:error, request, ex)
  68. end
  69. 17 @pending.each do |request|
  70. emit(:error, request, ex)
  71. end
  72. end
  73. 1 private
  74. 1 def send_pending
  75. 385 while (request = @pending.shift)
  76. 134 break unless send(request)
  77. end
  78. end
  79. 1 def headline_uri(request)
  80. 160 request.path
  81. end
  82. 1 def set_request_headers(request); end
  83. 1 def handle(request, stream)
  84. 982251 catch(:buffer_full) do
  85. 982251 request.transition(:headers)
  86. 982251 join_headers(stream, request) if request.state == :headers
  87. 982251 request.transition(:body)
  88. 982251 join_body(stream, request) if request.state == :body
  89. 982243 request.transition(:done)
  90. end
  91. end
  92. 1 def init_connection
  93. 225 @connection = HTTP2Next::Client.new(@options.http2_settings)
  94. 225 @connection.max_streams = @max_requests if @connection.respond_to?(:max_streams=) && @max_requests.positive?
  95. 225 @connection.on(:frame, &method(:on_frame))
  96. 225 @connection.on(:frame_sent, &method(:on_frame_sent))
  97. 225 @connection.on(:frame_received, &method(:on_frame_received))
  98. 225 @connection.on(:origin, &method(:on_origin))
  99. 225 @connection.on(:promise, &method(:on_promise))
  100. 225 @connection.on(:altsvc) { |frame| on_altsvc(frame[:origin], frame) }
  101. 225 @connection.on(:settings_ack, &method(:on_settings))
  102. 225 @connection.on(:goaway, &method(:on_close))
  103. #
  104. # Some servers initiate HTTP/2 negotiation right away, some don't.
  105. # As such, we have to check the socket buffer. If there is something
  106. # to read, the server initiated the negotiation. If not, we have to
  107. # initiate it.
  108. #
  109. 225 @connection.send_connection_preface
  110. end
  111. 1 def handle_stream(stream, request)
  112. 161 stream.on(:close, &method(:on_stream_close).curry[stream, request])
  113. 161 stream.on(:half_close) do
  114. 161 log(level: 2, label: "#{stream.id}: ") { "waiting for response..." }
  115. end
  116. 161 stream.on(:altsvc, &method(:on_altsvc).curry[request.origin])
  117. 161 stream.on(:headers, &method(:on_stream_headers).curry[stream, request])
  118. 161 stream.on(:data, &method(:on_stream_data).curry[stream, request])
  119. end
  120. 1 def join_headers(stream, request)
  121. 160 set_request_headers(request)
  122. 160 headers = {}
  123. 160 headers[":scheme"] = request.scheme
  124. 160 headers[":method"] = request.verb.to_s.upcase
  125. 160 headers[":path"] = headline_uri(request)
  126. 160 headers[":authority"] = request.authority
  127. 160 headers = headers.merge(request.headers)
  128. 160 log(level: 1, label: "#{stream.id}: ", color: :yellow) do
  129. 7 headers.map { |k, v| "-> HEADER: #{k}: #{v}" }.join("\n")
  130. end
  131. 160 stream.headers(headers, end_stream: request.empty?)
  132. end
  133. 1 def join_body(stream, request)
  134. 167 return if request.empty?
  135. 57 chunk = @drains.delete(request) || request.drain_body
  136. 57 while chunk
  137. 69 next_chunk = request.drain_body
  138. 69 log(level: 1, label: "#{stream.id}: ", color: :green) { "-> DATA: #{chunk.bytesize} bytes..." }
  139. 69 log(level: 2, label: "#{stream.id}: ", color: :green) { "-> #{chunk.inspect}" }
  140. 69 stream.data(chunk, end_stream: !next_chunk)
  141. 69 if next_chunk && @buffer.full?
  142. 8 @drains[request] = next_chunk
  143. 8 throw(:buffer_full)
  144. end
  145. 61 chunk = next_chunk
  146. end
  147. end
  148. ######
  149. # HTTP/2 Callbacks
  150. ######
  151. 1 def on_stream_headers(stream, request, h)
  152. 153 log(label: "#{stream.id}:", color: :yellow) do
  153. 9 h.map { |k, v| "<- HEADER: #{k}: #{v}" }.join("\n")
  154. end
  155. 153 _, status = h.shift
  156. 153 headers = request.options.headers_class.new(h)
  157. 153 response = request.options.response_class.new(request, status, "2.0", headers)
  158. 153 request.response = response
  159. 153 @streams[request] = stream
  160. end
  161. 1 def on_stream_data(stream, request, data)
  162. 258 log(level: 1, label: "#{stream.id}: ", color: :green) { "<- DATA: #{data.bytesize} bytes..." }
  163. 258 log(level: 2, label: "#{stream.id}: ", color: :green) { "<- #{data.inspect}" }
  164. 256 request.response << data
  165. end
  166. 1 def on_stream_close(stream, request, error)
  167. 147 return handle(request, stream) if request.expects?
  168. 147 if error && error != :no_error
  169. ex = Error.new(stream.id, error)
  170. ex.set_backtrace(caller)
  171. emit(:error, request, ex)
  172. else
  173. 147 response = request.response
  174. 147 if response.status == 421
  175. ex = MisdirectedRequestError.new(response)
  176. ex.set_backtrace(caller)
  177. emit(:error, request, ex)
  178. else
  179. 147 emit(:response, request, response)
  180. end
  181. end
  182. 148 log(level: 2, label: "#{stream.id}: ") { "closing stream" }
  183. 147 @streams.delete(request)
  184. 147 send(@pending.shift) unless @pending.empty?
  185. 147 return unless @streams.empty? && exhausted?
  186. 3 close
  187. 3 emit(:close)
  188. 3 emit(:exhausted) unless @pending.empty?
  189. end
  190. 1 def on_frame(bytes)
  191. 942 @buffer << bytes
  192. end
  193. 1 def on_settings(*)
  194. 127 @handshake_completed = true
  195. 127 @max_requests = @connection.remote_settings[:settings_max_concurrent_streams] if @max_requests.zero?
  196. 127 @max_concurrent_requests = [@max_concurrent_requests, @max_requests].min
  197. 127 send_pending
  198. end
  199. 1 def on_close(_last_frame, error, _payload)
  200. if error && error != :no_error
  201. ex = Error.new(0, error)
  202. ex.set_backtrace(caller)
  203. @streams.each_key do |request|
  204. emit(:error, request, ex)
  205. end
  206. end
  207. return unless @connection.state == :closed && @streams.size.zero?
  208. emit(:close)
  209. end
  210. 1 def on_frame_sent(frame)
  211. 722 log(level: 2, label: "#{frame[:stream]}: ") { "frame was sent!" }
  212. 717 log(level: 2, label: "#{frame[:stream]}: ", color: :blue) do
  213. 5 case frame[:type]
  214. when :data
  215. frame.merge(payload: frame[:payload].bytesize).inspect
  216. else
  217. 5 frame.inspect
  218. end
  219. end
  220. end
  221. 1 def on_frame_received(frame)
  222. 682 log(level: 2, label: "#{frame[:stream]}: ") { "frame was received!" }
  223. 677 log(level: 2, label: "#{frame[:stream]}: ", color: :magenta) do
  224. 5 case frame[:type]
  225. when :data
  226. 2 frame.merge(payload: frame[:payload].bytesize).inspect
  227. else
  228. 3 frame.inspect
  229. end
  230. end
  231. end
  232. 1 def on_altsvc(origin, frame)
  233. log(level: 2, label: "#{frame[:stream]}: ") { "altsvc frame was received" }
  234. log(level: 2, label: "#{frame[:stream]}: ") { frame.inspect }
  235. alt_origin = URI.parse("#{frame[:proto]}://#{frame[:host]}:#{frame[:port]}")
  236. params = { "ma" => frame[:max_age] }
  237. emit(:altsvc, origin, alt_origin, origin, params)
  238. end
  239. 1 def on_promise(stream)
  240. 2 emit(:promise, @streams.key(stream.parent), stream)
  241. end
  242. 1 def on_origin(origin)
  243. emit(:origin, origin)
  244. end
  245. 1 def respond_to_missing?(meth, *args)
  246. @connection.respond_to?(meth, *args) || super
  247. end
  248. 1 def method_missing(meth, *args, &blk)
  249. if @connection.respond_to?(meth)
  250. @connection.__send__(meth, *args, &blk)
  251. else
  252. super
  253. end
  254. end
  255. end
  256. 1 Connection.register "h2", Connection::HTTP2
  257. end

lib/httpx/errors.rb

100.0% lines covered

29 relevant lines. 29 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module HTTPX
  3. 1 Error = Class.new(StandardError)
  4. 1 UnsupportedSchemeError = Class.new(Error)
  5. 1 TimeoutError = Class.new(Error) do
  6. 1 attr_reader :timeout
  7. 1 def initialize(timeout, message)
  8. 35 @timeout = timeout
  9. 35 super(message)
  10. end
  11. 1 def to_connection_error
  12. 1 ex = ConnectTimeoutError.new(@timeout, message)
  13. 1 ex.set_backtrace(backtrace)
  14. 1 ex
  15. end
  16. end
  17. 1 TotalTimeoutError = Class.new(TimeoutError)
  18. 1 ConnectTimeoutError = Class.new(TimeoutError)
  19. 1 ResolveError = Class.new(Error)
  20. 1 NativeResolveError = Class.new(ResolveError) do
  21. 1 attr_reader :connection, :host
  22. 1 def initialize(connection, host, message = "Can't resolve #{host}")
  23. 2 @connection = connection
  24. 2 @host = host
  25. 2 super(message)
  26. end
  27. end
  28. 1 HTTPError = Class.new(Error) do
  29. 1 attr_reader :response
  30. 1 def initialize(response)
  31. 2 @response = response
  32. 2 super("HTTP Error: #{@response.status}")
  33. end
  34. 1 def status
  35. 2 @response.status
  36. end
  37. end
  38. 1 MisdirectedRequestError = Class.new(HTTPError)
  39. end

lib/httpx/headers.rb

95.52% lines covered

67 relevant lines. 64 lines covered and 3 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module HTTPX
  3. 1 class Headers
  4. 1 EMPTY = [].freeze # :nodoc:
  5. 1 class << self
  6. 1 def new(headers = nil)
  7. 2935 return headers if headers.is_a?(self)
  8. 893 super
  9. end
  10. end
  11. 1 def initialize(headers = nil)
  12. 893 @headers = {}
  13. 893 return unless headers
  14. 892 headers.each do |field, value|
  15. 2403 array_value(value).each do |v|
  16. 2414 add(downcased(field), v)
  17. end
  18. end
  19. end
  20. # cloned initialization
  21. 1 def initialize_clone(orig)
  22. super
  23. @headers = orig.instance_variable_get(:@headers).clone
  24. end
  25. # dupped initialization
  26. 1 def initialize_dup(orig)
  27. 1200 super
  28. 1200 @headers = orig.instance_variable_get(:@headers).dup
  29. end
  30. # freezes the headers hash
  31. 1 def freeze
  32. 201 @headers.freeze
  33. 201 super
  34. end
  35. 1 def same_headers?(headers)
  36. 71 @headers.empty? || begin
  37. 34 headers.each do |k, v|
  38. 91 return false unless v == self[k]
  39. end
  40. 15 true
  41. end
  42. end
  43. # merges headers with another header-quack.
  44. # the merge rule is, if the header already exists,
  45. # ignore what the +other+ headers has. Otherwise, set
  46. #
  47. 1 def merge(other)
  48. 968 headers = dup
  49. 968 other.each do |field, value|
  50. 533 headers[field] = value
  51. end
  52. 968 headers
  53. end
  54. # returns the comma-separated values of the header field
  55. # identified by +field+, or nil otherwise.
  56. #
  57. 1 def [](field)
  58. 1838215 a = @headers[downcased(field)] || return
  59. 1836616 a.join(",")
  60. end
  61. # sets +value+ (if not nil) as single value for the +field+ header.
  62. #
  63. 1 def []=(field, value)
  64. 1616 return unless value
  65. 1616 @headers[downcased(field)] = array_value(value)
  66. end
  67. # deletes all values associated with +field+ header.
  68. #
  69. 1 def delete(field)
  70. 13 canonical = downcased(field)
  71. 13 @headers.delete(canonical) if @headers.key?(canonical)
  72. end
  73. # adds additional +value+ to the existing, for header +field+.
  74. #
  75. 1 def add(field, value)
  76. 2427 (@headers[downcased(field)] ||= []) << String(value)
  77. end
  78. # helper to be used when adding an header field as a value to another field
  79. #
  80. # h2_headers.add_header("vary", "accept-encoding")
  81. # h2_headers["vary"] #=> "accept-encoding"
  82. # h1_headers.add_header("vary", "accept-encoding")
  83. # h1_headers["vary"] #=> "Accept-Encoding"
  84. #
  85. 1 alias_method :add_header, :add
  86. # returns the enumerable headers store in pairs of header field + the values in
  87. # the comma-separated string format
  88. #
  89. 1 def each
  90. 1730 return enum_for(__method__) { @headers.size } unless block_given?
  91. 1562 @headers.each do |field, value|
  92. 2166 yield(field, value.join(", ")) unless value.empty?
  93. end
  94. end
  95. 1 def ==(other)
  96. 1 to_hash == Headers.new(other).to_hash
  97. end
  98. # the headers store in Hash format
  99. 1 def to_hash
  100. 163 Hash[to_a]
  101. end
  102. # the headers store in array of pairs format
  103. 1 def to_a
  104. 166 Array(each)
  105. end
  106. # headers as string
  107. 1 def to_s
  108. @headers.to_s
  109. end
  110. skipped # :nocov:
  111. skipped def inspect
  112. skipped to_hash.inspect
  113. skipped end
  114. skipped # :nocov:
  115. # this is internal API and doesn't abide to other public API
  116. # guarantees, like downcasing strings.
  117. # Please do not use this outside of core!
  118. #
  119. 1 def key?(downcased_key)
  120. 940191 @headers.key?(downcased_key)
  121. end
  122. # returns the values for the +field+ header in array format.
  123. # This method is more internal, and for this reason doesn't try
  124. # to "correct" the user input, i.e. it doesn't downcase the key.
  125. #
  126. 1 def get(field)
  127. 39 @headers[field] || EMPTY
  128. end
  129. 1 private
  130. 1 def array_value(value)
  131. 4019 case value
  132. when Array
  133. 2247 value.map { |val| String(val).strip }
  134. else
  135. 2911 [String(value).strip]
  136. end
  137. end
  138. 1 def downcased(field)
  139. 1844685 String(field).downcase
  140. end
  141. end
  142. end

lib/httpx/io.rb

100.0% lines covered

12 relevant lines. 12 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require "socket"
  3. 1 require "httpx/io/tcp"
  4. 1 require "httpx/io/ssl"
  5. 1 require "httpx/io/unix"
  6. 1 require "httpx/io/udp"
  7. 1 module HTTPX
  8. 1 module IO
  9. 1 extend Registry
  10. 1 register "tcp", TCP
  11. 1 register "ssl", SSL
  12. 1 register "udp", UDP
  13. 1 register "unix", HTTPX::UNIX
  14. end
  15. end

lib/httpx/io/ssl.rb

93.62% lines covered

47 relevant lines. 44 lines covered and 3 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require "openssl"
  3. 1 module HTTPX
  4. 1 class SSL < TCP
  5. 1 TLS_OPTIONS = if OpenSSL::SSL::SSLContext.instance_methods.include?(:alpn_protocols)
  6. 1 { alpn_protocols: %w[h2 http/1.1] }
  7. else
  8. skipped # :nocov:
  9. skipped {}
  10. skipped # :nocov:
  11. end
  12. 1 def initialize(_, _, options)
  13. 136 @ctx = OpenSSL::SSL::SSLContext.new
  14. 136 ctx_options = TLS_OPTIONS.merge(options.ssl)
  15. 136 @ctx.set_params(ctx_options) unless ctx_options.empty?
  16. 136 super
  17. 136 @state = :negotiated if @keep_open
  18. end
  19. 1 def protocol
  20. 133 @io.alpn_protocol || super
  21. rescue StandardError
  22. super
  23. end
  24. 1 def verify_hostname(host)
  25. 5 return false if @ctx.verify_mode == OpenSSL::SSL::VERIFY_NONE
  26. 5 return false if !@io.respond_to?(:peer_cert) || @io.peer_cert.nil?
  27. 4 OpenSSL::SSL.verify_certificate_identity(@io.peer_cert, host)
  28. end
  29. 1 def close
  30. 100 super
  31. # allow reconnections
  32. # connect only works if initial @io is a socket
  33. 100 @io = @io.io if @io.respond_to?(:io)
  34. 100 @negotiated = false
  35. end
  36. 1 def connected?
  37. 5923 @state == :negotiated
  38. end
  39. 1 def connect
  40. 5926 super
  41. 5925 if @keep_open
  42. @state = :negotiated
  43. return
  44. end
  45. 5925 return if @state == :negotiated ||
  46. @state != :connected
  47. 5796 unless @io.is_a?(OpenSSL::SSL::SSLSocket)
  48. 131 @io = OpenSSL::SSL::SSLSocket.new(@io, @ctx)
  49. 131 @io.hostname = @hostname
  50. 131 @io.sync_close = true
  51. end
  52. 5796 @io.connect_nonblock
  53. 129 @io.post_connection_check(@hostname) if @ctx.verify_mode != OpenSSL::SSL::VERIFY_NONE
  54. 129 transition(:negotiated)
  55. rescue ::IO::WaitReadable,
  56. ::IO::WaitWritable
  57. end
  58. skipped # :nocov:
  59. skipped if RUBY_VERSION < "2.3"
  60. skipped def read(*)
  61. skipped super
  62. skipped rescue ::IO::WaitWritable
  63. skipped 0
  64. skipped end
  65. skipped
  66. skipped def write(*)
  67. skipped super
  68. skipped rescue ::IO::WaitReadable
  69. skipped 0
  70. skipped end
  71. skipped else
  72. skipped if OpenSSL::VERSION < "2.0.6"
  73. skipped def read(size, buffer)
  74. skipped @io.read_nonblock(size, buffer)
  75. skipped buffer.bytesize
  76. skipped rescue ::IO::WaitReadable,
  77. skipped ::IO::WaitWritable
  78. skipped 0
  79. skipped rescue EOFError
  80. skipped nil
  81. skipped end
  82. skipped end
  83. skipped end
  84. skipped # :nocov:
  85. skipped # :nocov:
  86. skipped def inspect
  87. skipped id = @io.closed? ? "closed" : @io.to_io.fileno
  88. skipped "#<SSL(fd: #{id}): #{@ip}:#{@port} state: #{@state}>"
  89. skipped end
  90. skipped # :nocov:
  91. 1 private
  92. 1 def transition(nextstate)
  93. 354 case nextstate
  94. when :negotiated
  95. 129 return unless @state == :connected
  96. when :closed
  97. 98 return unless @state == :negotiated ||
  98. @state == :connected
  99. end
  100. 354 do_transition(nextstate)
  101. end
  102. 1 def log_transition_state(nextstate)
  103. 3 return super unless nextstate == :negotiated
  104. 1 server_cert = @io.peer_cert
  105. 1 "#{super}\n\n" \
  106. "SSL connection using #{@io.ssl_version} / #{Array(@io.cipher).first}\n" \
  107. "ALPN, server accepted to use #{protocol}\n" \
  108. "Server certificate:\n" \
  109. " subject: #{server_cert.subject}\n" \
  110. " start date: #{server_cert.not_before}\n" \
  111. " expire date: #{server_cert.not_after}\n" \
  112. " issuer: #{server_cert.issuer}\n" \
  113. " SSL certificate verify ok."
  114. end
  115. end
  116. end

lib/httpx/io/tcp.rb

91.25% lines covered

80 relevant lines. 73 lines covered and 7 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require "resolv"
  3. 1 require "ipaddr"
  4. 1 module HTTPX
  5. 1 class TCP
  6. 1 include Loggable
  7. 1 attr_reader :ip, :port
  8. 1 attr_reader :addresses
  9. 1 alias_method :host, :ip
  10. 1 def initialize(origin, addresses, options)
  11. 274 @state = :idle
  12. 274 @hostname = origin.host
  13. 274 @addresses = addresses
  14. 274 @options = Options.new(options)
  15. 274 @fallback_protocol = @options.fallback_protocol
  16. 274 @port = origin.port
  17. 274 if @options.io
  18. 4 @io = case @options.io
  19. when Hash
  20. @options.io[origin.authority]
  21. else
  22. 4 @options.io
  23. end
  24. 4 _, _, _, @ip = @io.addr
  25. 4 @addresses ||= [@ip]
  26. 4 @ip_index = @addresses.size - 1
  27. 4 unless @io.nil?
  28. 4 @keep_open = true
  29. 4 @state = :connected
  30. end
  31. else
  32. 270 @ip_index = @addresses.size - 1
  33. 270 @ip = @addresses[@ip_index]
  34. end
  35. 274 @io ||= build_socket
  36. end
  37. 1 def to_io
  38. 10016179 @io.to_io
  39. end
  40. 1 def protocol
  41. 128 @fallback_protocol
  42. end
  43. 1 def connect
  44. 6266 return unless closed?
  45. begin
  46. 592 if @io.closed?
  47. 3 transition(:idle)
  48. 3 @io = build_socket
  49. end
  50. 592 @io.connect_nonblock(Socket.sockaddr_in(@port, @ip.to_s))
  51. rescue Errno::EISCONN
  52. end
  53. 264 transition(:connected)
  54. rescue Errno::EHOSTUNREACH => e
  55. raise e if @ip_index <= 0
  56. @ip_index -= 1
  57. retry
  58. rescue Errno::ETIMEDOUT => e
  59. raise ConnectTimeout, e.message if @ip_index <= 0
  60. @ip_index -= 1
  61. retry
  62. rescue Errno::EINPROGRESS,
  63. Errno::EALREADY,
  64. ::IO::WaitReadable
  65. end
  66. 1 if RUBY_VERSION < "2.3"
  67. skipped # :nocov:
  68. skipped def read(size, buffer)
  69. skipped @io.read_nonblock(size, buffer)
  70. skipped buffer.bytesize
  71. skipped rescue ::IO::WaitReadable
  72. skipped 0
  73. skipped rescue EOFError
  74. skipped nil
  75. skipped end
  76. skipped
  77. skipped def write(buffer)
  78. skipped siz = @io.write_nonblock(buffer)
  79. skipped buffer.shift!(siz)
  80. skipped siz
  81. skipped rescue ::IO::WaitWritable
  82. skipped 0
  83. skipped rescue EOFError
  84. skipped nil
  85. skipped end
  86. skipped # :nocov:
  87. else
  88. 1 def read(size, buffer)
  89. 2503751 ret = @io.read_nonblock(size, buffer, exception: false)
  90. 2503751 return 0 if ret == :wait_readable
  91. 1350 return if ret.nil?
  92. 1349 buffer.bytesize
  93. end
  94. 1 def write(buffer)
  95. 581 siz = @io.write_nonblock(buffer, exception: false)
  96. 581 return 0 if siz == :wait_writable
  97. 581 return if siz.nil?
  98. 581 buffer.shift!(siz)
  99. 581 siz
  100. end
  101. end
  102. 1 def close
  103. 235 return if @keep_open || closed?
  104. begin
  105. 231 @io.close
  106. ensure
  107. 231 transition(:closed)
  108. end
  109. end
  110. 1 def connected?
  111. 339 @state == :connected
  112. end
  113. 1 def closed?
  114. 2512156 @state == :idle || @state == :closed
  115. end
  116. skipped # :nocov:
  117. skipped def inspect
  118. skipped id = @io.closed? ? "closed" : @io.fileno
  119. skipped "#<TCP(fd: #{id}): #{@ip}:#{@port} (state: #{@state})>"
  120. skipped end
  121. skipped # :nocov:
  122. 1 private
  123. 1 def build_socket
  124. 269 Socket.new(@ip.family, :STREAM, 0)
  125. end
  126. 1 def transition(nextstate)
  127. 274 case nextstate
  128. # when :idle
  129. when :connected
  130. 138 return unless @state == :idle
  131. when :closed
  132. 133 return unless @state == :connected
  133. end
  134. 274 do_transition(nextstate)
  135. end
  136. 1 def do_transition(nextstate)
  137. 633 log(level: 1) { log_transition_state(nextstate) }
  138. 628 @state = nextstate
  139. end
  140. 1 def log_transition_state(nextstate)
  141. 5 case nextstate
  142. when :connected
  143. 2 "Connected to #{@hostname} (#{@ip}) port #{@port} (##{@io.fileno})"
  144. else
  145. 3 "#{@ip}:#{@port} #{@state} -> #{nextstate}"
  146. end
  147. end
  148. end
  149. end

lib/httpx/io/udp.rb

100.0% lines covered

22 relevant lines. 22 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require "socket"
  3. 1 require "ipaddr"
  4. 1 module HTTPX
  5. 1 class UDP
  6. 1 include Loggable
  7. 1 def initialize(uri, _, _)
  8. 16 ip = IPAddr.new(uri.host)
  9. 16 @host = ip.to_s
  10. 16 @port = uri.port
  11. 16 @io = UDPSocket.new(ip.family)
  12. end
  13. 1 def to_io
  14. 164 @io.to_io
  15. end
  16. 1 def connect; end
  17. 1 def connected?
  18. 16 true
  19. end
  20. 1 if RUBY_VERSION < "2.2"
  21. skipped # :nocov:
  22. skipped def close
  23. skipped @io.close
  24. skipped rescue StandardError
  25. skipped nil
  26. skipped end
  27. skipped # :nocov:
  28. else
  29. 1 def close
  30. 16 @io.close
  31. end
  32. end
  33. 1 def write(buffer)
  34. 18 siz = @io.send(buffer, 0, @host, @port)
  35. 18 buffer.shift!(siz)
  36. 18 siz
  37. end
  38. skipped # :nocov:
  39. skipped if RUBY_VERSION < "2.3"
  40. skipped def read(size, buffer)
  41. skipped data, _ = @io.recvfrom_nonblock(size)
  42. skipped buffer.replace(data)
  43. skipped buffer.bytesize
  44. skipped rescue ::IO::WaitReadable
  45. skipped 0
  46. skipped rescue IOError
  47. skipped end
  48. skipped else
  49. skipped def read(size, buffer)
  50. skipped ret = @io.recvfrom_nonblock(size, 0, buffer, exception: false)
  51. skipped return 0 if ret == :wait_readable
  52. skipped return if ret.nil?
  53. skipped
  54. skipped buffer.bytesize
  55. skipped rescue IOError
  56. skipped end
  57. skipped end
  58. skipped # :nocov:
  59. end
  60. end

lib/httpx/io/unix.rb

71.88% lines covered

32 relevant lines. 23 lines covered and 9 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require "forwardable"
  3. 1 module HTTPX
  4. 1 class UNIX < TCP
  5. 1 extend Forwardable
  6. 1 def_delegator :@uri, :port, :scheme
  7. 1 def initialize(uri, addresses, options)
  8. 1 @uri = uri
  9. 1 @addresses = addresses
  10. 1 @state = :idle
  11. 1 @options = Options.new(options)
  12. 1 @path = @options.transport_options[:path]
  13. 1 @fallback_protocol = @options.fallback_protocol
  14. 1 if @options.io
  15. @io = case @options.io
  16. when Hash
  17. @options.io[@path]
  18. else
  19. @options.io
  20. end
  21. unless @io.nil?
  22. @keep_open = true
  23. @state = :connected
  24. end
  25. end
  26. 1 @io ||= build_socket
  27. end
  28. 1 def hostname
  29. @uri.host
  30. end
  31. 1 def connect
  32. 1 return unless closed?
  33. begin
  34. 1 if @io.closed?
  35. transition(:idle)
  36. @io = build_socket
  37. end
  38. 1 @io.connect_nonblock(Socket.sockaddr_un(@path))
  39. rescue Errno::EISCONN
  40. end
  41. 1 transition(:connected)
  42. rescue Errno::EINPROGRESS,
  43. Errno::EALREADY,
  44. ::IO::WaitReadable
  45. end
  46. 1 private
  47. 1 def build_socket
  48. 1 Socket.new(Socket::PF_UNIX, :STREAM, 0)
  49. end
  50. end
  51. end

lib/httpx/options.rb

100.0% lines covered

96 relevant lines. 96 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module HTTPX
  3. 1 class Options
  4. 1 WINDOW_SIZE = 1 << 14 # 16K
  5. 1 MAX_BODY_THRESHOLD_SIZE = (1 << 10) * 112 # 112K
  6. 1 class << self
  7. 1 def inherited(klass)
  8. 60 super
  9. 60 klass.instance_variable_set(:@defined_options, @defined_options.dup)
  10. end
  11. 1 def new(options = {})
  12. # let enhanced options go through
  13. 3205 return options if self == Options && options.class > self
  14. 3205 return options if options.is_a?(self)
  15. 2022 super
  16. end
  17. 1 def defined_options
  18. 2498 @defined_options ||= []
  19. end
  20. 1 def def_option(name, &interpreter)
  21. 131 defined_options << name.to_sym
  22. 28663 interpreter ||= ->(v) { v }
  23. 131 attr_reader name
  24. 131 define_method(:"#{name}=") do |value|
  25. 57910 return if value.nil?
  26. 37581 instance_variable_set(:"@#{name}", instance_exec(value, &interpreter))
  27. end
  28. 131 protected :"#{name}="
  29. 131 define_method(:"with_#{name}") do |value|
  30. 32 other = dup
  31. 32 other.send(:"#{name}=", other.instance_exec(value, &interpreter))
  32. 32 other
  33. end
  34. end
  35. end
  36. 1 def initialize(options = {})
  37. defaults = {
  38. 4044 :debug => ENV.key?("HTTPX_DEBUG") ? $stderr : nil,
  39. 2022 :debug_level => (ENV["HTTPX_DEBUG"] || 1).to_i,
  40. :ssl => {},
  41. :http2_settings => { settings_enable_push: 0 },
  42. :fallback_protocol => "http/1.1",
  43. :timeout => Timeout.new,
  44. :headers => {},
  45. :window_size => WINDOW_SIZE,
  46. :body_threshold_size => MAX_BODY_THRESHOLD_SIZE,
  47. :request_class => Class.new(Request),
  48. :response_class => Class.new(Response),
  49. :headers_class => Class.new(Headers),
  50. :request_body_class => Class.new(Request::Body),
  51. :response_body_class => Class.new(Response::Body),
  52. :connection_class => Class.new(Connection),
  53. :transport => nil,
  54. :transport_options => nil,
  55. :persistent => false,
  56. 2022 :resolver_class => (ENV["HTTPX_RESOLVER"] || :native).to_sym,
  57. :resolver_options => { cache: true },
  58. }
  59. 2022 defaults.merge!(options)
  60. 2022 defaults[:headers] = Headers.new(defaults[:headers])
  61. 2022 defaults.each do |(k, v)|
  62. 56022 __send__(:"#{k}=", v)
  63. end
  64. end
  65. 1 def_option(:headers) do |headers|
  66. 2256 if self.headers
  67. 234 self.headers.merge(headers)
  68. else
  69. 2022 headers
  70. end
  71. end
  72. 1 def_option(:timeout) do |opts|
  73. 2022 Timeout.new(opts)
  74. end
  75. 1 def_option(:max_concurrent_requests) do |num|
  76. 34 raise Error, ":max_concurrent_requests must be positive" unless num.positive?
  77. 34 num
  78. end
  79. 1 def_option(:max_requests) do |num|
  80. 16 raise Error, ":max_requests must be positive" unless num.positive?
  81. 16 num
  82. end
  83. 1 def_option(:window_size) do |num|
  84. 2022 Integer(num)
  85. end
  86. 1 def_option(:body_threshold_size) do |num|
  87. 2022 Integer(num)
  88. end
  89. 1 def_option(:transport) do |tr|
  90. 6 transport = tr.to_s
  91. 6 raise Error, "#{transport} is an unsupported transport type" unless IO.registry.key?(transport)
  92. 6 transport
  93. end
  94. 1 %w[
  95. params form json body
  96. follow ssl http2_settings
  97. request_class response_class headers_class request_body_class response_body_class connection_class
  98. io fallback_protocol debug debug_level transport_options resolver_class resolver_options
  99. persistent
  100. ].each do |method_name|
  101. 21 def_option(method_name)
  102. end
  103. 1 REQUEST_IVARS = %i[@params @form @json @body].freeze
  104. 1 def ==(other)
  105. 79 ivars = instance_variables | other.instance_variables
  106. 79 ivars.all? do |ivar|
  107. 971 case ivar
  108. when :@headers
  109. 71 headers = instance_variable_get(ivar)
  110. 71 headers.same_headers?(other.instance_variable_get(ivar))
  111. when *REQUEST_IVARS
  112. 6 true
  113. else
  114. 894 instance_variable_get(ivar) == other.instance_variable_get(ivar)
  115. end
  116. end
  117. end
  118. 1 def merge(other)
  119. 1697 h1 = to_hash
  120. 1697 h2 = other.to_hash
  121. 1697 merged = h1.merge(h2) do |k, v1, v2|
  122. 18059 case k
  123. when :headers, :ssl, :http2_settings, :timeout
  124. 2615 v1.merge(v2)
  125. else
  126. 15444 v2
  127. end
  128. end
  129. 1697 self.class.new(merged)
  130. end
  131. 1 def to_hash
  132. 2367 hash_pairs = self.class
  133. .defined_options
  134. 68246 .flat_map { |opt_name| [opt_name, send(opt_name)] }
  135. 2367 Hash[*hash_pairs]
  136. end
  137. 1 def initialize_dup(other)
  138. 232 self.headers = other.headers.dup
  139. 232 self.ssl = other.ssl.dup
  140. 232 self.request_class = other.request_class.dup
  141. 232 self.response_class = other.response_class.dup
  142. 232 self.headers_class = other.headers_class.dup
  143. 232 self.request_body_class = other.request_body_class.dup
  144. 232 self.response_body_class = other.response_body_class.dup
  145. 232 self.connection_class = other.connection_class.dup
  146. end
  147. 1 def freeze
  148. 201 super
  149. 201 headers.freeze
  150. 201 ssl.freeze
  151. 201 request_class.freeze
  152. 201 response_class.freeze
  153. 201 headers_class.freeze
  154. 201 request_body_class.freeze
  155. 201 response_body_class.freeze
  156. 201 connection_class.freeze
  157. end
  158. end
  159. end

lib/httpx/parser/http1.rb

94.44% lines covered

108 relevant lines. 102 lines covered and 6 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module HTTPX
  3. 1 module Parser
  4. 1 Error = Class.new(Error)
  5. 1 class HTTP1
  6. 1 VERSIONS = %w[1.0 1.1].freeze
  7. 1 attr_reader :status_code, :http_version, :headers
  8. 1 def initialize(observer, header_separator: ":")
  9. 159 @observer = observer
  10. 159 @state = :idle
  11. 159 @header_separator = header_separator
  12. 159 @buffer = "".b
  13. 159 @headers = {}
  14. end
  15. 1 def <<(chunk)
  16. 461 @buffer << chunk
  17. 461 parse
  18. end
  19. 1 def reset!
  20. 425 @state = :idle
  21. 425 @headers.clear
  22. 425 @content_length = nil
  23. 425 @_has_trailers = nil
  24. end
  25. 1 def upgrade?
  26. 125 @upgrade
  27. end
  28. 1 def upgrade_data
  29. 1 @buffer
  30. end
  31. 1 private
  32. 1 def parse
  33. 716 state = @state
  34. 716 case @state
  35. when :idle
  36. 151 parse_headline
  37. when :headers
  38. 155 parse_headers
  39. when :trailers
  40. parse_headers
  41. when :data
  42. 410 parse_data
  43. end
  44. 600 parse if !@buffer.empty? && state != @state
  45. end
  46. 1 def parse_headline
  47. 151 idx = @buffer.index("\n")
  48. 151 return unless idx
  49. 151 (m = %r{\AHTTP(?:\/(\d+\.\d+))?\s+(\d\d\d)(?:\s+(.*))?}in.match(@buffer)) ||
  50. raise(Error, "wrong head line format")
  51. 151 version, code, _ = m.captures
  52. 151 raise(Error, "unsupported HTTP version (HTTP/#{version})") unless VERSIONS.include?(version)
  53. 151 @http_version = version.split(".").map(&:to_i)
  54. 151 @status_code = code.to_i
  55. 151 raise(Error, "wrong status code (#{@status_code})") unless (100..599).cover?(@status_code)
  56. # @buffer.slice!(0, idx + 1)
  57. 151 @buffer = @buffer.byteslice((idx + 1)..-1)
  58. 151 nextstate(:headers)
  59. end
  60. 1 def parse_headers
  61. 155 headers = @headers
  62. 1463 while (idx = @buffer.index("\n"))
  63. 1304 line = @buffer.slice!(0, idx + 1).sub(/\s+\z/, "")
  64. 1304 if line.empty?
  65. 151 case @state
  66. when :headers
  67. 151 prepare_data(headers)
  68. 151 @observer.on_headers(headers)
  69. 128 return unless @state == :headers
  70. # state might have been reset
  71. # in the :headers callback
  72. 122 nextstate(:data)
  73. 122 headers.clear
  74. when :trailers
  75. @observer.on_trailers(headers)
  76. headers.clear
  77. nextstate(:complete)
  78. else
  79. raise Error, "wrong header format"
  80. end
  81. 122 return
  82. end
  83. 1153 separator_index = line.index(@header_separator)
  84. 1153 raise Error, "wrong header format" unless separator_index
  85. 1153 key = line[0..separator_index - 1]
  86. 1153 raise Error, "wrong header format" if key.start_with?("\s", "\t")
  87. 1153 key.strip!
  88. 1153 value = line[separator_index + 1..-1]
  89. 1153 value.strip!
  90. 1153 raise Error, "wrong header format" if value.nil?
  91. 1153 (headers[key.downcase] ||= []) << value
  92. end
  93. end
  94. 1 def parse_data
  95. 410 if @buffer.respond_to?(:each)
  96. 291 @buffer.each do |chunk|
  97. 88 @observer.on_data(chunk)
  98. end
  99. 119 elsif @content_length
  100. 116 data = @buffer.byteslice(0, @content_length)
  101. 116 @buffer = @buffer.byteslice(@content_length..-1) || "".b
  102. 116 @content_length -= data.bytesize
  103. 116 @observer.on_data(data)
  104. 116 data.clear
  105. else
  106. 3 @observer.on_data(@buffer)
  107. 3 @buffer.clear
  108. end
  109. 410 return unless no_more_data?
  110. 109 @buffer = @buffer.to_s
  111. 109 if @_has_trailers
  112. nextstate(:trailers)
  113. else
  114. 109 nextstate(:complete)
  115. end
  116. end
  117. 1 def prepare_data(headers)
  118. 151 @upgrade = headers.key?("upgrade")
  119. 151 @_has_trailers = headers.key?("trailer")
  120. 151 if (tr_encodings = headers["transfer-encoding"])
  121. 9 tr_encodings.reverse_each do |tr_encoding|
  122. 9 tr_encoding.split(/ *, */).each do |encoding|
  123. 9 case encoding
  124. when "chunked"
  125. 9 @buffer = Transcoder::Chunker::Decoder.new(@buffer, @_has_trailers)
  126. end
  127. end
  128. end
  129. else
  130. 142 @content_length = headers["content-length"][0].to_i if headers.key?("content-length")
  131. end
  132. end
  133. 1 def no_more_data?
  134. 410 if @content_length
  135. 116 @content_length <= 0
  136. 294 elsif @buffer.respond_to?(:finished?)
  137. 291 @buffer.finished?
  138. else
  139. 3 false
  140. end
  141. end
  142. 1 def nextstate(state)
  143. 382 @state = state
  144. 382 case state
  145. when :headers
  146. 151 @observer.on_start
  147. when :complete
  148. 109 @observer.on_complete
  149. 16 reset!
  150. 16 nextstate(:idle) unless @buffer.empty?
  151. end
  152. end
  153. end
  154. end
  155. end

lib/httpx/plugins/authentication.rb

100.0% lines covered

7 relevant lines. 7 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module HTTPX
  3. 1 module Plugins
  4. #
  5. # This plugin adds a shim +authentication+ method to the session, which will fill
  6. # the HTTP Authorization header.
  7. #
  8. # https://gitlab.com/honeyryderchuck/httpx/wikis/Authentication#authentication
  9. #
  10. 1 module Authentication
  11. 1 module InstanceMethods
  12. 1 def authentication(token)
  13. 4 with(headers: { "authorization" => token })
  14. end
  15. end
  16. end
  17. 1 register_plugin :authentication, Authentication
  18. end
  19. end

lib/httpx/plugins/basic_authentication.rb

100.0% lines covered

11 relevant lines. 11 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module HTTPX
  3. 1 module Plugins
  4. #
  5. # This plugin adds helper methods to implement HTTP Basic Auth (https://tools.ietf.org/html/rfc7617)
  6. #
  7. # https://gitlab.com/honeyryderchuck/httpx/wikis/Authentication#basic-authentication
  8. #
  9. 1 module BasicAuthentication
  10. 1 def self.load_dependencies(klass)
  11. 2 require "base64"
  12. 2 klass.plugin(:authentication)
  13. end
  14. 1 module InstanceMethods
  15. 1 def basic_authentication(user, password)
  16. 4 authentication("Basic #{Base64.strict_encode64("#{user}:#{password}")}")
  17. end
  18. 1 alias_method :basic_auth, :basic_authentication
  19. end
  20. end
  21. 1 register_plugin :basic_authentication, BasicAuthentication
  22. end
  23. end

lib/httpx/plugins/compression.rb

93.1% lines covered

87 relevant lines. 81 lines covered and 6 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module HTTPX
  3. 1 module Plugins
  4. #
  5. # This plugin adds compression support. Namely it:
  6. #
  7. # * Compresses the request body when passed a supported "Content-Encoding" mime-type;
  8. # * Decompresses the response body from a supported "Content-Encoding" mime-type;
  9. #
  10. # It supports both *gzip* and *deflate*.
  11. #
  12. # https://gitlab.com/honeyryderchuck/httpx/wikis/Compression
  13. #
  14. 1 module Compression
  15. 1 extend Registry
  16. 1 def self.load_dependencies(klass)
  17. 17 klass.plugin(:"compression/gzip")
  18. 17 klass.plugin(:"compression/deflate")
  19. end
  20. 1 def self.extra_options(options)
  21. 17 options.merge(headers: { "accept-encoding" => Compression.registry.keys })
  22. end
  23. 1 module RequestMethods
  24. 1 def initialize(*)
  25. 36 super
  26. # forego compression in the Range cases
  27. 36 @headers.delete("accept-encoding") if @headers.key?("range")
  28. end
  29. end
  30. 1 module RequestBodyMethods
  31. 1 def initialize(*)
  32. 36 super
  33. 36 return if @body.nil?
  34. 16 @headers.get("content-encoding").each do |encoding|
  35. 8 next if encoding == "identity"
  36. 6 @body = Encoder.new(@body, Compression.registry(encoding).encoder)
  37. end
  38. 16 @headers["content-length"] = @body.bytesize unless chunked?
  39. end
  40. end
  41. 1 module ResponseBodyMethods
  42. 1 attr_reader :encodings
  43. 1 def initialize(*, **)
  44. 34 @encodings = []
  45. 34 super
  46. 34 return unless @headers.key?("content-encoding")
  47. 9 @_decoders = @headers.get("content-encoding").map do |encoding|
  48. 9 next if encoding == "identity"
  49. 9 decoder = Compression.registry(encoding).decoder
  50. # do not uncompress if there is no decoder available. In fact, we can't reliably
  51. # continue decompressing beyond that, so ignore.
  52. 9 break unless decoder
  53. 9 @encodings << encoding
  54. 9 decoder
  55. end.compact
  56. # remove encodings that we are able to decode
  57. 9 @headers["content-encoding"] = @headers.get("content-encoding") - @encodings
  58. 9 @_compressed_length = if @headers.key?("content-length")
  59. 7 @headers["content-length"].to_i
  60. else
  61. 2 Float::INFINITY
  62. end
  63. end
  64. 1 def write(chunk)
  65. 117 return super unless defined?(@_compressed_length)
  66. 75 @_compressed_length -= chunk.bytesize
  67. 75 chunk = decompress(chunk)
  68. 75 super(chunk)
  69. end
  70. 1 def close
  71. 32 super
  72. 32 return unless defined?(@_decoders)
  73. 7 @_decoders.each(&:close)
  74. end
  75. 1 private
  76. 1 def decompress(buffer)
  77. 75 @_decoders.reverse_each do |decoder|
  78. 75 buffer = decoder.decode(buffer)
  79. 75 buffer << decoder.finish if @_compressed_length <= 0
  80. end
  81. 75 buffer
  82. end
  83. end
  84. 1 class Encoder
  85. 1 def initialize(body, deflater)
  86. 6 @body = body.respond_to?(:read) ? body : StringIO.new(body.to_s)
  87. 6 @buffer = StringIO.new("".b, File::RDWR)
  88. 6 @deflater = deflater
  89. end
  90. 1 def each(&blk)
  91. 6 return enum_for(__method__) unless block_given?
  92. 6 unless @buffer.size.zero?
  93. 6 @buffer.rewind
  94. 6 return @buffer.each(&blk)
  95. end
  96. deflate(&blk)
  97. end
  98. 1 def bytesize
  99. 15 deflate
  100. 15 @buffer.size
  101. end
  102. 1 def to_s
  103. deflate
  104. @buffer.rewind
  105. @buffer.read
  106. end
  107. 1 def close
  108. @buffer.close
  109. @body.close
  110. end
  111. 1 private
  112. 1 def deflate(&blk)
  113. 15 return unless @buffer.size.zero?
  114. 6 @body.rewind
  115. 6 @deflater.deflate(@body, @buffer, chunk_size: 16_384, &blk)
  116. end
  117. end
  118. 1 class Decoder
  119. 1 extend Forwardable
  120. 1 def_delegator :@inflater, :finish
  121. 1 def_delegator :@inflater, :close
  122. 1 def initialize(inflater)
  123. 9 @inflater = inflater
  124. end
  125. 1 def decode(chunk)
  126. 75 @inflater.inflate(chunk)
  127. end
  128. end
  129. end
  130. 1 register_plugin :compression, Compression
  131. end
  132. end

lib/httpx/plugins/compression/brotli.rb

100.0% lines covered

29 relevant lines. 29 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module HTTPX
  3. 1 module Plugins
  4. 1 module Compression
  5. 1 module Brotli
  6. 1 def self.load_dependencies(klass)
  7. 4 klass.plugin(:compression)
  8. 4 require "brotli"
  9. end
  10. 1 def self.configure(*)
  11. 4 Compression.register "br", self
  12. end
  13. 1 module Encoder
  14. 1 module_function
  15. 1 def deflate(raw, buffer, chunk_size:)
  16. 6 while (chunk = raw.read(chunk_size))
  17. 2 compressed = ::Brotli.deflate(chunk)
  18. 2 buffer << compressed
  19. 2 yield compressed if block_given?
  20. end
  21. end
  22. end
  23. 1 module BrotliWrapper
  24. 1 module_function
  25. 1 def inflate(text)
  26. 2 ::Brotli.inflate(text)
  27. end
  28. 1 def close; end
  29. 1 def finish
  30. 2 ""
  31. end
  32. end
  33. 1 module_function
  34. 1 def encoder
  35. 2 Encoder
  36. end
  37. 1 def decoder
  38. 2 Decoder.new(BrotliWrapper)
  39. end
  40. end
  41. end
  42. 1 register_plugin :"compression/brotli", Compression::Brotli
  43. end
  44. end

lib/httpx/plugins/compression/deflate.rb

100.0% lines covered

27 relevant lines. 27 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module HTTPX
  3. 1 module Plugins
  4. 1 module Compression
  5. 1 module Deflate
  6. 1 def self.load_dependencies(*)
  7. 17 require "stringio"
  8. 17 require "zlib"
  9. end
  10. 1 def self.configure(*)
  11. 17 Compression.register "deflate", self
  12. end
  13. 1 module Encoder
  14. 1 module_function
  15. 1 def deflate(raw, buffer, chunk_size:)
  16. 2 deflater = Zlib::Deflate.new(Zlib::BEST_COMPRESSION,
  17. Zlib::MAX_WBITS,
  18. Zlib::MAX_MEM_LEVEL,
  19. Zlib::HUFFMAN_ONLY)
  20. 6 while (chunk = raw.read(chunk_size))
  21. 2 compressed = deflater.deflate(chunk)
  22. 2 buffer << compressed
  23. 2 yield compressed if block_given?
  24. end
  25. 2 last = deflater.finish
  26. 2 buffer << last
  27. 2 yield last if block_given?
  28. ensure
  29. 2 deflater.close
  30. end
  31. end
  32. 1 module_function
  33. 1 def encoder
  34. 2 Encoder
  35. end
  36. 1 def decoder
  37. 2 Decoder.new(Zlib::Inflate.new(32 + Zlib::MAX_WBITS))
  38. end
  39. end
  40. end
  41. 1 register_plugin :"compression/deflate", Compression::Deflate
  42. end
  43. end

lib/httpx/plugins/compression/gzip.rb

100.0% lines covered

36 relevant lines. 36 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require "forwardable"
  3. 1 module HTTPX
  4. 1 module Plugins
  5. 1 module Compression
  6. 1 module GZIP
  7. 1 def self.load_dependencies(*)
  8. 17 require "zlib"
  9. end
  10. 1 def self.configure(*)
  11. 17 Compression.register "gzip", self
  12. end
  13. 1 class Encoder
  14. 1 def initialize
  15. 2 @compressed_chunk = "".b
  16. end
  17. 1 def deflate(raw, buffer, chunk_size:)
  18. 2 gzip = Zlib::GzipWriter.new(self)
  19. begin
  20. 6 while (chunk = raw.read(chunk_size))
  21. 2 gzip.write(chunk)
  22. 2 gzip.flush
  23. 2 compressed = compressed_chunk
  24. 2 buffer << compressed
  25. 2 yield compressed if block_given?
  26. end
  27. ensure
  28. 2 gzip.close
  29. end
  30. 2 return unless (compressed = compressed_chunk)
  31. 2 buffer << compressed
  32. 2 yield compressed if block_given?
  33. end
  34. 1 private
  35. 1 def write(chunk)
  36. 6 @compressed_chunk << chunk
  37. end
  38. 1 def compressed_chunk
  39. 4 @compressed_chunk.dup
  40. ensure
  41. 4 @compressed_chunk.clear
  42. end
  43. end
  44. 1 module_function
  45. 1 def encoder
  46. 2 Encoder.new
  47. end
  48. 1 def decoder
  49. 5 Decoder.new(Zlib::Inflate.new(32 + Zlib::MAX_WBITS))
  50. end
  51. end
  52. end
  53. 1 register_plugin :"compression/gzip", Compression::GZIP
  54. end
  55. end

lib/httpx/plugins/cookies.rb

90.63% lines covered

64 relevant lines. 58 lines covered and 6 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require "forwardable"
  3. 1 module HTTPX
  4. 1 module Plugins
  5. #
  6. # This plugin implements a persistent cookie jar for the duration of a session.
  7. #
  8. # It also adds a *#cookies* helper, so that you can pre-fill the cookies of a session.
  9. #
  10. # https://gitlab.com/honeyryderchuck/httpx/wikis/Cookies
  11. #
  12. 1 module Cookies
  13. 1 using URIExtensions
  14. 1 def self.extra_options(options)
  15. 6 Class.new(options.class) do
  16. 6 def_option(:cookies) do |cookies|
  17. 78 if cookies.is_a?(Store)
  18. 72 cookies
  19. else
  20. 6 Store.new(cookies)
  21. end
  22. end
  23. end.new(options)
  24. end
  25. 1 class Store
  26. 1 def self.new(cookies = nil)
  27. 18 return cookies if cookies.is_a?(self)
  28. 18 super
  29. end
  30. 1 def initialize(cookies = nil)
  31. 30 @store = Hash.new { |hash, origin| hash[origin] = HTTP::CookieJar.new }
  32. 18 return unless cookies
  33. 6 cookies = cookies.split(/ *; */) if cookies.is_a?(String)
  34. 6 @default_cookies = cookies.map do |cookie, v|
  35. 6 if cookie.is_a?(HTTP::Cookie)
  36. cookie
  37. else
  38. 6 HTTP::Cookie.new(cookie.to_s, v.to_s)
  39. end
  40. end
  41. end
  42. 1 def set(origin, cookies)
  43. 16 return unless cookies
  44. 4 @store[origin].parse(cookies, origin)
  45. end
  46. 1 def [](uri)
  47. 18 store = @store[uri.origin]
  48. @default_cookies.each do |cookie|
  49. 6 c = cookie.dup
  50. 6 c.domain ||= uri.authority
  51. 6 c.path ||= uri.path
  52. 6 store.add(c)
  53. 18 end if @default_cookies
  54. 18 store
  55. end
  56. 1 def ==(other)
  57. 1 @store == other.instance_variable_get(:@store)
  58. end
  59. end
  60. 1 def self.load_dependencies(*)
  61. 6 require "http/cookie"
  62. end
  63. 1 module InstanceMethods
  64. 1 extend Forwardable
  65. 1 def_delegator :@options, :cookies
  66. 1 def initialize(options = {}, &blk)
  67. 12 super({ cookies: Store.new }.merge(options), &blk)
  68. end
  69. 1 def wrap
  70. return super unless block_given?
  71. super do |session|
  72. old_cookies_store = @options.cookies.dup
  73. begin
  74. yield session
  75. ensure
  76. @options = @options.with(cookies: old_cookies_store)
  77. end
  78. end
  79. end
  80. 1 private
  81. 1 def on_response(request, response)
  82. 16 @options.cookies.set(request.origin, response.headers["set-cookie"]) if response.respond_to?(:headers)
  83. 16 super
  84. end
  85. 1 def build_request(*, _)
  86. 16 request = super
  87. 16 request.headers.set_cookie(@options.cookies[request.uri])
  88. 16 request
  89. end
  90. end
  91. 1 module HeadersMethods
  92. 1 def set_cookie(jar)
  93. 16 return unless jar
  94. 16 cookie_value = HTTP::Cookie.cookie_value(jar.cookies)
  95. 16 return if cookie_value.empty?
  96. 10 add("cookie", cookie_value)
  97. end
  98. end
  99. end
  100. 1 register_plugin :cookies, Cookies
  101. end
  102. end

lib/httpx/plugins/digest_authentication.rb

96.43% lines covered

84 relevant lines. 81 lines covered and 3 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require "digest"
  3. 1 module HTTPX
  4. 1 module Plugins
  5. #
  6. # This plugin adds helper methods to implement HTTP Digest Auth (https://tools.ietf.org/html/rfc7616)
  7. #
  8. # https://gitlab.com/honeyryderchuck/httpx/wikis/Authentication#authentication
  9. #
  10. 1 module DigestAuthentication
  11. 1 DigestError = Class.new(Error)
  12. 1 def self.extra_options(options)
  13. 14 Class.new(options.class) do
  14. 14 def_option(:digest) do |digest|
  15. 112 raise Error, ":digest must be a Digest" unless digest.is_a?(Digest)
  16. 112 digest
  17. end
  18. end.new(options)
  19. end
  20. 1 def self.load_dependencies(*)
  21. 14 require "securerandom"
  22. 14 require "digest"
  23. end
  24. 1 module InstanceMethods
  25. 1 def digest_authentication(user, password)
  26. 14 branch(default_options.with_digest(Digest.new(user, password)))
  27. end
  28. 1 alias_method :digest_auth, :digest_authentication
  29. 1 def request(*args, **options)
  30. 14 requests = build_requests(*args, options)
  31. 14 probe_request = requests.first
  32. 14 digest = probe_request.options.digest
  33. 14 return super unless digest
  34. 28 prev_response = wrap { send_requests(*probe_request, options).first }
  35. 14 raise Error, "request doesn't require authentication (status: #{prev_response.status})" unless prev_response.status == 401
  36. 14 probe_request.transition(:idle)
  37. 14 responses = []
  38. 42 while (request = requests.shift)
  39. 14 token = digest.generate_header(request, prev_response)
  40. 14 request.headers["authorization"] = "Digest #{token}"
  41. 14 response = if requests.empty?
  42. 14 send_requests(*request, options).first
  43. else
  44. wrap { send_requests(*request, options).first }
  45. end
  46. 14 responses << response
  47. 14 prev_response = response
  48. end
  49. 14 responses.size == 1 ? responses.first : responses
  50. end
  51. end
  52. 1 class Digest
  53. 1 def initialize(user, password)
  54. 14 @user = user
  55. 14 @password = password
  56. 14 @nonce = 0
  57. end
  58. 1 def generate_header(request, response, _iis = false)
  59. 14 meth = request.verb.to_s.upcase
  60. 14 www = response.headers["www-authenticate"]
  61. # discard first token, it's Digest
  62. 14 auth_info = www[/^(\w+) (.*)/, 2]
  63. 14 uri = request.path
  64. 14 params = Hash[auth_info.split(/ *, */)
  65. 70 .map { |val| val.split("=") }
  66. 70 .map { |k, v| [k, v.delete("\"")] }]
  67. 14 nonce = params["nonce"]
  68. 14 nc = next_nonce
  69. # verify qop
  70. 14 qop = params["qop"]
  71. 14 if params["algorithm"] =~ /(.*?)(-sess)?$/
  72. 14 alg = Regexp.last_match(1)
  73. 14 algorithm = ::Digest.const_get(alg)
  74. 14 raise DigestError, "unknown algorithm \"#{alg}\"" unless algorithm
  75. 14 sess = Regexp.last_match(2)
  76. 14 params.delete("algorithm")
  77. else
  78. algorithm = ::Digest::MD5
  79. end
  80. 14 if qop || sess
  81. 14 cnonce = make_cnonce
  82. 14 nc = format("%<nonce>08x", nonce: nc)
  83. end
  84. 14 a1 = if sess
  85. [algorithm.hexdigest("#{@user}:#{params["realm"]}:#{@password}"),
  86. nonce,
  87. cnonce].join ":"
  88. else
  89. 14 "#{@user}:#{params["realm"]}:#{@password}"
  90. end
  91. 14 ha1 = algorithm.hexdigest(a1)
  92. 14 ha2 = algorithm.hexdigest("#{meth}:#{uri}")
  93. 14 request_digest = [ha1, nonce]
  94. 14 request_digest.push(nc, cnonce, qop) if qop
  95. 14 request_digest << ha2
  96. 14 request_digest = request_digest.join(":")
  97. header = [
  98. 14 %(username="#{@user}"),
  99. %(nonce="#{nonce}"),
  100. %(uri="#{uri}"),
  101. %(response="#{algorithm.hexdigest(request_digest)}"),
  102. ]
  103. 14 header << %(realm="#{params["realm"]}") if params.key?("realm")
  104. 14 header << %(algorithm=#{params["algorithm"]}") if params.key?("algorithm")
  105. 14 header << %(opaque="#{params["opaque"]}") if params.key?("opaque")
  106. 14 header << %(cnonce="#{cnonce}") if cnonce
  107. 14 header << %(nc=#{nc})
  108. 14 header << %(qop=#{qop}) if qop
  109. 14 header.join ", "
  110. end
  111. 1 private
  112. 1 def make_cnonce
  113. 14 ::Digest::MD5.hexdigest [
  114. Time.now.to_i,
  115. Process.pid,
  116. SecureRandom.random_number(2**32),
  117. ].join ":"
  118. end
  119. 1 def next_nonce
  120. 14 @nonce += 1
  121. end
  122. end
  123. end
  124. 1 register_plugin :digest_authentication, DigestAuthentication
  125. end
  126. end

lib/httpx/plugins/expect.rb

88.57% lines covered

35 relevant lines. 31 lines covered and 4 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module HTTPX
  3. 1 module Plugins
  4. #
  5. # This plugin makes all HTTP/1.1 requests with a body send the "Expect: 100-continue".
  6. #
  7. # https://gitlab.com/honeyryderchuck/httpx/wikis/Expect#expect
  8. #
  9. 1 module Expect
  10. 1 EXPECT_TIMEOUT = 2
  11. 1 def self.extra_options(options)
  12. 4 Class.new(options.class) do
  13. 4 def_option(:expect_timeout) do |seconds|
  14. 24 seconds = Integer(seconds)
  15. 24 raise Error, ":expect_timeout must be positive" unless seconds.positive?
  16. 24 seconds
  17. end
  18. end.new(options).merge(expect_timeout: EXPECT_TIMEOUT)
  19. end
  20. 1 module RequestBodyMethods
  21. 1 def initialize(*)
  22. 4 super
  23. 4 return if @body.nil?
  24. 4 @headers["expect"] = "100-continue"
  25. end
  26. end
  27. 1 module ConnectionMethods
  28. 1 def send(request)
  29. 6 request.once(:expects) do
  30. @timers.after(@options.expect_timeout) do
  31. if request.state == :expects && !request.expects?
  32. request.headers.delete("expect")
  33. handle(request)
  34. end
  35. end
  36. end
  37. 6 super
  38. end
  39. end
  40. 1 module InstanceMethods
  41. 1 def fetch_response(request, connections, options)
  42. 12328 response = @responses.delete(request)
  43. 12328 return unless response
  44. 6 if response.status == 417 && request.headers.key?("expect")
  45. 2 request.headers.delete("expect")
  46. 2 request.transition(:idle)
  47. 2 connection = find_connection(request, connections, options)
  48. 2 connection.send(request)
  49. 2 return
  50. end
  51. 4 response
  52. end
  53. end
  54. end
  55. 1 register_plugin :expect, Expect
  56. end
  57. end

lib/httpx/plugins/follow_redirects.rb

100.0% lines covered

49 relevant lines. 49 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module HTTPX
  3. 1 InsecureRedirectError = Class.new(Error)
  4. 1 module Plugins
  5. #
  6. # This plugin adds support for following redirect (status 30X) responses.
  7. #
  8. # It has an upper bound of followed redirects (see *MAX_REDIRECTS*), after which it
  9. # will return the last redirect response. It will **not** raise an exception.
  10. #
  11. # It also doesn't follow insecure redirects (https -> http) by default (see *follow_insecure_redirects*).
  12. #
  13. # https://gitlab.com/honeyryderchuck/httpx/wikis/Follow-Redirects
  14. #
  15. 1 module FollowRedirects
  16. 1 MAX_REDIRECTS = 3
  17. 1 REDIRECT_STATUS = (300..399).freeze
  18. 1 def self.extra_options(options)
  19. 10 Class.new(options.class) do
  20. 10 def_option(:max_redirects) do |num|
  21. 88 num = Integer(num)
  22. 88 raise Error, ":max_redirects must be positive" if num.negative?
  23. 88 num
  24. end
  25. 10 def_option(:follow_insecure_redirects)
  26. end.new(options)
  27. end
  28. 1 module InstanceMethods
  29. 1 def max_redirects(n)
  30. 6 branch(default_options.with_max_redirects(n.to_i))
  31. end
  32. 1 private
  33. 1 def fetch_response(request, connections, options)
  34. 72389 redirect_request = request.redirect_request
  35. 72389 response = super(redirect_request, connections, options)
  36. 72389 return unless response
  37. 35 max_redirects = redirect_request.max_redirects
  38. 35 return response unless REDIRECT_STATUS.include?(response.status)
  39. 26 return response unless max_redirects.positive?
  40. 22 retry_request = build_redirect_request(redirect_request, response, options)
  41. 22 request.redirect_request = retry_request
  42. 22 if !options.follow_insecure_redirects &&
  43. response.uri.scheme == "https" &&
  44. retry_request.uri.scheme == "http"
  45. 1 error = InsecureRedirectError.new(retry_request.uri.to_s)
  46. 1 error.set_backtrace(caller)
  47. 1 return ErrorResponse.new(request, error, options)
  48. end
  49. 21 connection = find_connection(retry_request, connections, options)
  50. 21 connection.send(retry_request)
  51. nil
  52. end
  53. 1 def build_redirect_request(request, response, options)
  54. 22 redirect_uri = __get_location_from_response(response)
  55. 22 max_redirects = request.max_redirects
  56. # redirects are **ALWAYS** GET
  57. 22 retry_options = options.merge(headers: request.headers,
  58. body: request.body,
  59. max_redirects: max_redirects - 1)
  60. 22 build_request(:get, redirect_uri, retry_options)
  61. end
  62. 1 def __get_location_from_response(response)
  63. 22 location_uri = URI(response.headers["location"])
  64. 22 location_uri = response.uri.merge(location_uri) if location_uri.relative?
  65. 22 location_uri
  66. end
  67. end
  68. 1 module RequestMethods
  69. 1 def self.included(klass)
  70. 10 klass.__send__(:attr_writer, :redirect_request)
  71. end
  72. 1 def redirect_request
  73. 72389 @redirect_request || self
  74. end
  75. 1 def max_redirects
  76. 57 @options.max_redirects || MAX_REDIRECTS
  77. end
  78. end
  79. end
  80. 1 register_plugin :follow_redirects, FollowRedirects
  81. end
  82. end

lib/httpx/plugins/h2c.rb

96.55% lines covered

58 relevant lines. 56 lines covered and 2 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module HTTPX
  3. 1 module Plugins
  4. #
  5. # This plugin adds support for upgrading a plaintext HTTP/1.1 connection to HTTP/2
  6. # (https://tools.ietf.org/html/rfc7540#section-3.2)
  7. #
  8. # https://gitlab.com/honeyryderchuck/httpx/wikis/Follow-Redirects
  9. #
  10. 1 module H2C
  11. 1 def self.load_dependencies(*)
  12. 1 require "base64"
  13. end
  14. 1 module InstanceMethods
  15. 1 def request(*args, **options)
  16. 1 h2c_options = options.merge(fallback_protocol: "h2c")
  17. 1 requests = build_requests(*args, h2c_options)
  18. 1 upgrade_request = requests.first
  19. 1 return super unless valid_h2c_upgrade_request?(upgrade_request)
  20. 1 upgrade_request.headers.add("connection", "upgrade")
  21. 1 upgrade_request.headers.add("connection", "http2-settings")
  22. 1 upgrade_request.headers["upgrade"] = "h2c"
  23. 1 upgrade_request.headers["http2-settings"] = HTTP2Next::Client.settings_header(upgrade_request.options.http2_settings)
  24. 2 wrap { send_requests(*upgrade_request, h2c_options).first }
  25. 1 responses = send_requests(*requests, h2c_options)
  26. 1 responses.size == 1 ? responses.first : responses
  27. end
  28. 1 private
  29. 1 def fetch_response(request, connections, options)
  30. 5 response = super
  31. 5 if response && valid_h2c_upgrade?(request, response, options)
  32. 1 log { "upgrading to h2c..." }
  33. 1 connection = find_connection(request, connections, options)
  34. 1 connections << connection unless connections.include?(connection)
  35. 1 connection.upgrade(request, response)
  36. end
  37. 5 response
  38. end
  39. 1 VALID_H2C_METHODS = %i[get options head].freeze
  40. 1 private_constant :VALID_H2C_METHODS
  41. 1 def valid_h2c_upgrade_request?(request)
  42. 1 VALID_H2C_METHODS.include?(request.verb) &&
  43. request.scheme == "http"
  44. end
  45. 1 def valid_h2c_upgrade?(request, response, options)
  46. 2 options.fallback_protocol == "h2c" &&
  47. request.headers.get("connection").include?("upgrade") &&
  48. request.headers.get("upgrade").include?("h2c") &&
  49. response.status == 101
  50. end
  51. end
  52. 1 class H2CParser < Connection::HTTP2
  53. 1 def upgrade(request, response)
  54. 1 @connection.send_connection_preface
  55. # skip checks, it is assumed that this is the first
  56. # request in the connection
  57. 1 stream = @connection.upgrade
  58. 1 handle_stream(stream, request)
  59. 1 @streams[request] = stream
  60. # clean up data left behind in the buffer, if the server started
  61. # sending frames
  62. 1 data = response.to_s
  63. 1 @connection << data
  64. end
  65. end
  66. 1 module ConnectionMethods
  67. 1 using URIExtensions
  68. 1 def match?(uri, options)
  69. 2 return super unless uri.scheme == "http" && @options.fallback_protocol == "h2c"
  70. 2 super && options.fallback_protocol == "h2c"
  71. end
  72. 1 def coalescable?(connection)
  73. return super unless @options.fallback_protocol == "h2c" && @origin.scheme == "http"
  74. @origin == connection.origin && connection.options.fallback_protocol == "h2c"
  75. end
  76. 1 def upgrade(request, response)
  77. 1 @parser.reset if @parser
  78. 1 @parser = H2CParser.new(@write_buffer, @options)
  79. 1 set_parser_callbacks(@parser)
  80. 1 @parser.upgrade(request, response)
  81. end
  82. 1 def build_parser(*)
  83. 2 return super unless @origin.scheme == "http"
  84. 2 super("http/1.1")
  85. end
  86. end
  87. end
  88. 1 register_plugin(:h2c, H2C)
  89. end
  90. end

lib/httpx/plugins/multipart.rb

92.0% lines covered

25 relevant lines. 23 lines covered and 2 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module HTTPX
  3. 1 module Plugins
  4. #
  5. # This plugin adds support for passing `http-form_data` objects (like file objects) as "multipart/form-data";
  6. #
  7. # HTTPX.post(URL, form: form: { image: HTTP::FormData::File.new("path/to/file")})
  8. #
  9. # https://gitlab.com/honeyryderchuck/httpx/wikis/Multipart-Uploads
  10. #
  11. 1 module Multipart
  12. 1 module FormTranscoder
  13. 1 module_function
  14. 1 class Encoder
  15. 1 extend Forwardable
  16. 1 def_delegator :@raw, :content_type
  17. 1 def_delegator :@raw, :to_s
  18. 1 def_delegator :@raw, :read
  19. 1 def initialize(form)
  20. 36 @raw = HTTP::FormData.create(form)
  21. end
  22. 1 def bytesize
  23. 144 @raw.content_length
  24. end
  25. 1 def force_encoding(*args)
  26. @raw.to_s.force_encoding(*args)
  27. end
  28. 1 def to_str
  29. @raw.to_s
  30. end
  31. end
  32. 1 def encode(form)
  33. 36 Encoder.new(form)
  34. end
  35. end
  36. 1 def self.load_dependencies(*)
  37. 16 require "http/form_data"
  38. end
  39. 1 def self.configure(*)
  40. 16 Transcoder.register("form", FormTranscoder)
  41. end
  42. end
  43. 1 register_plugin :multipart, Multipart
  44. end
  45. end

lib/httpx/plugins/persistent.rb

100.0% lines covered

8 relevant lines. 8 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module HTTPX
  3. 1 module Plugins
  4. # This plugin implements a session that persists connections over the duration of the process.
  5. #
  6. # This will improve connection reuse in a long-running process.
  7. #
  8. # One important caveat to note is, although this session might not close connections,
  9. # other sessions from the same process that don't have this plugin turned on might.
  10. #
  11. # This session will still be able to work with it, as if, when expecting a connection
  12. # terminated by a different session, it will just retry on a new one and keep it open.
  13. #
  14. # This plugin is also not recommendable when connecting to >9000 (like, a lot) different origins.
  15. # So when you use this, make sure that you don't fall into this trap.
  16. #
  17. # https://gitlab.com/honeyryderchuck/httpx/wikis/Persistent
  18. #
  19. 1 module Persistent
  20. 1 def self.load_dependencies(klass)
  21. 1 klass.plugin(:retries, max_retries: 1, retry_change_requests: true)
  22. end
  23. 1 def self.extra_options(options)
  24. 1 options.merge(persistent: true)
  25. end
  26. end
  27. 1 register_plugin :persistent, Persistent
  28. end
  29. end

lib/httpx/plugins/proxy.rb

85.38% lines covered

130 relevant lines. 111 lines covered and 19 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require "resolv"
  3. 1 require "ipaddr"
  4. 1 require "forwardable"
  5. 1 module HTTPX
  6. 1 HTTPProxyError = Class.new(Error)
  7. 1 module Plugins
  8. #
  9. # This plugin adds support for proxies. It ships with support for:
  10. #
  11. # * HTTP proxies
  12. # * HTTPS proxies
  13. # * Socks4/4a proxies
  14. # * Socks5 proxies
  15. #
  16. # https://gitlab.com/honeyryderchuck/httpx/wikis/Proxy
  17. #
  18. 1 module Proxy
  19. 1 Error = HTTPProxyError
  20. 1 PROXY_ERRORS = [TimeoutError, IOError, SystemCallError, Error].freeze
  21. 1 class Parameters
  22. 1 attr_reader :uri, :username, :password
  23. 1 def initialize(uri:, username: nil, password: nil)
  24. 17 @uri = uri.is_a?(URI::Generic) ? uri : URI(uri)
  25. 17 @username = username || @uri.user
  26. 17 @password = password || @uri.password
  27. end
  28. 1 def authenticated?
  29. 17 @username && @password
  30. end
  31. 1 def token_authentication
  32. 5 return unless authenticated?
  33. 5 Base64.strict_encode64("#{@username}:#{@password}")
  34. end
  35. 1 def ==(other)
  36. 7 case other
  37. when Parameters
  38. 3 @uri == other.uri &&
  39. @username == other.username &&
  40. @password == other.password
  41. when URI::Generic, String
  42. 3 proxy_uri = @uri.dup
  43. 3 proxy_uri.user = @username
  44. 3 proxy_uri.password = @password
  45. 3 other_uri = other.is_a?(URI::Generic) ? other : URI.parse(other)
  46. 3 proxy_uri == other_uri
  47. else
  48. 1 super
  49. end
  50. end
  51. end
  52. 1 class << self
  53. 1 def configure(klass)
  54. 12 klass.plugin(:"proxy/http")
  55. 12 klass.plugin(:"proxy/socks4")
  56. 12 klass.plugin(:"proxy/socks5")
  57. end
  58. 1 def extra_options(options)
  59. 12 Class.new(options.class) do
  60. 12 def_option(:proxy) do |pr|
  61. 86 if pr.is_a?(Parameters)
  62. 14 pr
  63. else
  64. 72 Hash[pr]
  65. end
  66. end
  67. end.new(options)
  68. end
  69. end
  70. 1 module InstanceMethods
  71. 1 private
  72. 1 def proxy_uris(uri, options)
  73. 12 @_proxy_uris ||= begin
  74. 12 uris = options.proxy ? Array(options.proxy[:uri]) : []
  75. 12 if uris.empty?
  76. 2 uri = URI(uri).find_proxy
  77. 2 uris << uri if uri
  78. end
  79. 12 uris
  80. end
  81. 12 options.proxy.merge(uri: @_proxy_uris.first) unless @_proxy_uris.empty?
  82. end
  83. 1 def find_connection(request, connections, options)
  84. 12 return super unless options.respond_to?(:proxy)
  85. 12 uri = URI(request.uri)
  86. 12 next_proxy = proxy_uris(uri, options)
  87. 12 raise Error, "Failed to connect to proxy" unless next_proxy
  88. 10 proxy_options = options.merge(proxy: Parameters.new(**next_proxy))
  89. 10 connection = pool.find_connection(uri, proxy_options) || build_connection(uri, proxy_options)
  90. 10 unless connections.nil? || connections.include?(connection)
  91. 10 connections << connection
  92. 10 set_connection_callbacks(connection, connections, options)
  93. end
  94. 10 connection
  95. end
  96. 1 def build_connection(uri, options)
  97. 10 proxy = options.proxy
  98. 10 return super unless proxy
  99. 10 connection = options.connection_class.new("tcp", uri, options)
  100. 10 pool.init_connection(connection, options)
  101. 10 connection
  102. end
  103. 1 def fetch_response(request, connections, options)
  104. 5863 response = super
  105. 5863 if response.is_a?(ErrorResponse) &&
  106. # either it was a timeout error connecting, or it was a proxy error
  107. PROXY_ERRORS.any? { |ex| response.error.is_a?(ex) } && !@_proxy_uris.empty?
  108. @_proxy_uris.shift
  109. log { "failed connecting to proxy, trying next..." }
  110. request.transition(:idle)
  111. connection = find_connection(request, connections, options)
  112. connections << connection unless connections.include?(connection)
  113. connection.send(request)
  114. return
  115. end
  116. 5863 response
  117. end
  118. 1 def build_altsvc_connection(_, _, _, _, _, options)
  119. return if options.proxy
  120. super
  121. end
  122. end
  123. 1 module ConnectionMethods
  124. 1 using URIExtensions
  125. 1 def initialize(*)
  126. 10 super
  127. 10 return unless @options.proxy
  128. # redefining the connection origin as the proxy's URI,
  129. # as this will be used as the tcp peer ip.
  130. 10 @origin = URI(@options.proxy.uri.origin)
  131. end
  132. 1 def match?(uri, options)
  133. return super unless @options.proxy
  134. super && @options.proxy == options.proxy
  135. end
  136. # should not coalesce connections here, as the IP is the IP of the proxy
  137. 1 def coalescable?(*)
  138. return super unless @options.proxy
  139. false
  140. end
  141. 1 def send(request)
  142. 10 return super unless @options.proxy
  143. 10 return super unless connecting?
  144. 10 @pending << request
  145. end
  146. 1 def connecting?
  147. 10 return super unless @options.proxy
  148. 10 super || @state == :connecting || @state == :connected
  149. end
  150. 1 def to_io
  151. 23416 return super unless @options.proxy
  152. 23416 case @state
  153. when :idle
  154. 20 transition(:connecting)
  155. when :connected
  156. 9 transition(:open)
  157. end
  158. 23416 @io.to_io
  159. end
  160. 1 def call
  161. 5859 super
  162. 5859 return unless @options.proxy
  163. 5859 case @state
  164. when :connecting
  165. 16 consume
  166. end
  167. end
  168. 1 def reset
  169. return super unless @options.proxy
  170. @state = :open
  171. transition(:closing)
  172. transition(:closed)
  173. emit(:close)
  174. end
  175. 1 def transition(nextstate)
  176. 72 return super unless @options.proxy
  177. 72 case nextstate
  178. when :closing
  179. 22 @state = :open if @state == :connecting
  180. end
  181. 72 super
  182. end
  183. end
  184. end
  185. 1 register_plugin :proxy, Proxy
  186. end
  187. 1 class ProxySSL < SSL
  188. 1 def initialize(tcp, request_uri, options)
  189. 4 @io = tcp.to_io
  190. 4 super(request_uri, tcp.addresses, options)
  191. 4 @hostname = request_uri.host
  192. 4 @state = :connected
  193. end
  194. end
  195. end

lib/httpx/plugins/proxy/http.rb

100.0% lines covered

67 relevant lines. 67 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require "base64"
  3. 1 module HTTPX
  4. 1 module Plugins
  5. 1 module Proxy
  6. 1 module HTTP
  7. 1 module ConnectionMethods
  8. 1 private
  9. 1 def transition(nextstate)
  10. 78 return super unless @options.proxy && @options.proxy.uri.scheme == "http"
  11. 32 case nextstate
  12. when :connecting
  13. 8 return unless @state == :idle
  14. 8 @io.connect
  15. 8 return unless @io.connected?
  16. 4 @parser = ConnectProxyParser.new(@write_buffer, @options.merge(max_concurrent_requests: 1))
  17. 4 @parser.once(:response, &method(:__http_on_connect))
  18. 6 @parser.on(:close) { transition(:closing) }
  19. 4 __http_proxy_connect
  20. 4 return if @state == :connected
  21. when :connected
  22. 3 return unless @state == :idle || @state == :connecting
  23. 3 case @state
  24. when :connecting
  25. 1 @parser.close
  26. 1 @parser = nil
  27. when :idle
  28. 2 @parser = ProxyParser.new(@write_buffer, @options)
  29. 2 set_parser_callbacks(@parser)
  30. 4 @parser.on(:close) { transition(:closing) }
  31. end
  32. end
  33. 26 super
  34. end
  35. 1 def __http_proxy_connect
  36. 4 req = @pending.first
  37. # if the first request after CONNECT is to an https address, it is assumed that
  38. # all requests in the queue are not only ALL HTTPS, but they also share the certificate,
  39. # and therefore, will share the connection.
  40. #
  41. 4 if req.uri.scheme == "https"
  42. 2 connect_request = ConnectRequest.new(req.uri, @options)
  43. 2 parser.send(connect_request)
  44. else
  45. 2 transition(:connected)
  46. end
  47. end
  48. 1 def __http_on_connect(_, response)
  49. 2 if response.status == 200
  50. 1 req = @pending.first
  51. 1 request_uri = req.uri
  52. 1 @io = ProxySSL.new(@io, request_uri, @options)
  53. 1 transition(:connected)
  54. 1 throw(:called)
  55. else
  56. 1 pending = @pending + @parser.pending
  57. 3 while (req = pending.shift)
  58. 1 req.emit(:response, response)
  59. end
  60. end
  61. end
  62. end
  63. 1 class ProxyParser < Connection::HTTP1
  64. 1 def headline_uri(request)
  65. 2 request.uri.to_s
  66. end
  67. 1 def set_request_headers(request)
  68. 8 super
  69. 8 proxy_params = @options.proxy
  70. 8 request.headers["proxy-authorization"] = "Basic #{proxy_params.token_authentication}" if proxy_params.authenticated?
  71. 8 request.headers["proxy-connection"] = request.headers["connection"]
  72. 8 request.headers.delete("connection")
  73. end
  74. end
  75. 1 class ConnectProxyParser < ProxyParser
  76. 1 attr_reader :pending
  77. 1 def headline_uri(request)
  78. 2 return super unless request.verb == :connect
  79. 2 tunnel = request.path
  80. 2 log { "establishing HTTP proxy tunnel to #{tunnel}" }
  81. 2 tunnel
  82. end
  83. 1 def empty?
  84. 1 @requests.reject { |r| r.verb == :connect }.empty? || @requests.all? { |request| !request.response.nil? }
  85. end
  86. end
  87. 1 class ConnectRequest < Request
  88. 1 def initialize(uri, _options)
  89. 2 super(:connect, uri, {})
  90. 2 @headers.delete("accept")
  91. end
  92. 1 def path
  93. 2 "#{@uri.hostname}:#{@uri.port}"
  94. end
  95. end
  96. end
  97. end
  98. 1 register_plugin :"proxy/http", Proxy::HTTP
  99. end
  100. end

lib/httpx/plugins/proxy/socks4.rb

87.32% lines covered

71 relevant lines. 62 lines covered and 9 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require "resolv"
  3. 1 require "ipaddr"
  4. 1 module HTTPX
  5. 1 Socks4Error = Class.new(Error)
  6. 1 module Plugins
  7. 1 module Proxy
  8. 1 module Socks4
  9. 1 VERSION = 4
  10. 1 CONNECT = 1
  11. 1 GRANTED = 90
  12. 1 PROTOCOLS = %w[socks4 socks4a].freeze
  13. 1 Error = Socks4Error
  14. 1 module ConnectionMethods
  15. 1 private
  16. 1 def transition(nextstate)
  17. 82 return super unless @options.proxy && PROTOCOLS.include?(@options.proxy.uri.scheme)
  18. 32 case nextstate
  19. when :connecting
  20. 8 return unless @state == :idle
  21. 8 @io.connect
  22. 8 return unless @io.connected?
  23. 4 req = @pending.first
  24. 4 return unless req
  25. 4 request_uri = req.uri
  26. 4 @write_buffer << Packet.connect(@options.proxy, request_uri)
  27. 4 __socks4_proxy_connect
  28. when :connected
  29. 4 return unless @state == :connecting
  30. 4 @parser = nil
  31. end
  32. 28 log(level: 1, label: "SOCKS4: ") { "#{nextstate}: #{@write_buffer.to_s.inspect}" } unless nextstate == :open
  33. 28 super
  34. end
  35. 1 def __socks4_proxy_connect
  36. 4 @parser = SocksParser.new(@write_buffer, @options)
  37. 4 @parser.once(:packet, &method(:__socks4_on_packet))
  38. end
  39. 1 def __socks4_on_packet(packet)
  40. 4 _version, status, _port, _ip = packet.unpack("CCnN")
  41. 4 if status == GRANTED
  42. 4 req = @pending.first
  43. 4 request_uri = req.uri
  44. 4 @io = ProxySSL.new(@io, request_uri, @options) if request_uri.scheme == "https"
  45. 4 transition(:connected)
  46. 4 throw(:called)
  47. else
  48. on_socks4_error("socks error: #{status}")
  49. end
  50. end
  51. 1 def on_socks4_error(message)
  52. ex = Error.new(message)
  53. ex.set_backtrace(caller)
  54. on_error(ex)
  55. throw(:called)
  56. end
  57. end
  58. 1 class SocksParser
  59. 1 include Callbacks
  60. 1 def initialize(buffer, options)
  61. 4 @buffer = buffer
  62. 4 @options = Options.new(options)
  63. end
  64. 1 def close; end
  65. 1 def consume(*); end
  66. 1 def empty?
  67. true
  68. end
  69. 1 def <<(packet)
  70. 4 emit(:packet, packet)
  71. end
  72. end
  73. 1 module Packet
  74. 1 module_function
  75. 1 def connect(parameters, uri)
  76. 4 packet = [VERSION, CONNECT, uri.port].pack("CCn")
  77. begin
  78. 4 ip = IPAddr.new(uri.host)
  79. raise Error, "Socks4 connection to #{ip} not supported" unless ip.ipv4?
  80. packet << [ip.to_i].pack("N")
  81. rescue IPAddr::InvalidAddressError
  82. 4 if parameters.uri.scheme =~ /^socks4a?$/
  83. # resolv defaults to IPv4, and socks4 doesn't support IPv6 otherwise
  84. 4 ip = IPAddr.new(Resolv.getaddress(uri.host))
  85. 4 packet << [ip.to_i].pack("N")
  86. else
  87. packet << "\x0\x0\x0\x1" << "\x7\x0" << uri.host
  88. end
  89. end
  90. 4 packet << [parameters.username].pack("Z*")
  91. 4 packet
  92. end
  93. end
  94. end
  95. end
  96. 1 register_plugin :"proxy/socks4", Proxy::Socks4
  97. end
  98. end

lib/httpx/plugins/proxy/socks5.rb

90.2% lines covered

102 relevant lines. 92 lines covered and 10 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module HTTPX
  3. 1 Socks5Error = Class.new(Error)
  4. 1 module Plugins
  5. 1 module Proxy
  6. 1 module Socks5
  7. 1 VERSION = 5
  8. 1 NOAUTH = 0
  9. 1 PASSWD = 2
  10. 1 NONE = 0xff
  11. 1 CONNECT = 1
  12. 1 IPV4 = 1
  13. 1 DOMAIN = 3
  14. 1 IPV6 = 4
  15. 1 SUCCESS = 0
  16. 1 Error = Socks5Error
  17. 1 module ConnectionMethods
  18. 1 def call
  19. 5859 super
  20. 5859 return unless @options.proxy && @options.proxy.uri.scheme == "socks5"
  21. 200 case @state
  22. when :connecting,
  23. :negotiating,
  24. :authenticating
  25. 6 consume
  26. end
  27. end
  28. 1 private
  29. 1 def transition(nextstate)
  30. 86 return super unless @options.proxy && @options.proxy.uri.scheme == "socks5"
  31. 22 case nextstate
  32. when :connecting
  33. 4 return unless @state == :idle
  34. 4 @io.connect
  35. 4 return unless @io.connected?
  36. 2 @write_buffer << Packet.negotiate(@options.proxy)
  37. 2 __socks5_proxy_connect
  38. when :authenticating
  39. 2 return unless @state == :connecting
  40. 2 @write_buffer << Packet.authenticate(@options.proxy)
  41. when :negotiating
  42. 4 return unless @state == :connecting || @state == :authenticating
  43. 2 req = @pending.first
  44. 2 request_uri = req.uri
  45. 2 @write_buffer << Packet.connect(request_uri)
  46. when :connected
  47. 2 return unless @state == :negotiating
  48. 2 @parser = nil
  49. end
  50. 18 log(level: 1, label: "SOCKS5: ") { "#{nextstate}: #{@write_buffer.to_s.inspect}" } unless nextstate == :open
  51. 18 super
  52. end
  53. 1 def __socks5_proxy_connect
  54. 2 @parser = SocksParser.new(@write_buffer, @options)
  55. 2 @parser.on(:packet, &method(:__socks5_on_packet))
  56. 2 transition(:negotiating)
  57. end
  58. 1 def __socks5_on_packet(packet)
  59. 6 case @state
  60. when :connecting
  61. 2 version, method = packet.unpack("CC")
  62. 2 __socks5_check_version(version)
  63. 2 case method
  64. when PASSWD
  65. 2 transition(:authenticating)
  66. nil
  67. when NONE
  68. __on_socks5_error("no supported authorization methods")
  69. else
  70. transition(:negotiating)
  71. end
  72. when :authenticating
  73. 2 _, status = packet.unpack("CC")
  74. 2 return transition(:negotiating) if status == SUCCESS
  75. __on_socks5_error("socks authentication error: #{status}")
  76. when :negotiating
  77. 2 version, reply, = packet.unpack("CC")
  78. 2 __socks5_check_version(version)
  79. 2 __on_socks5_error("socks5 negotiation error: #{reply}") unless reply == SUCCESS
  80. 2 req = @pending.first
  81. 2 request_uri = req.uri
  82. 2 @io = ProxySSL.new(@io, request_uri, @options) if request_uri.scheme == "https"
  83. 2 transition(:connected)
  84. 2 throw(:called)
  85. end
  86. end
  87. 1 def __socks5_check_version(version)
  88. 4 __on_socks5_error("invalid SOCKS version (#{version})") if version != 5
  89. end
  90. 1 def __on_socks5_error(message)
  91. ex = Error.new(message)
  92. ex.set_backtrace(caller)
  93. on_error(ex)
  94. throw(:called)
  95. end
  96. end
  97. 1 class SocksParser
  98. 1 include Callbacks
  99. 1 def initialize(buffer, options)
  100. 2 @buffer = buffer
  101. 2 @options = Options.new(options)
  102. end
  103. 1