loading
Generated 2024-12-18T13:37:52+00:00

All Files ( 96.27% covered at 24808.45 hits/line )

102 files in total.
7161 relevant lines, 6894 lines covered and 267 lines missed. ( 96.27% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line
lib/httpx.rb 100.00 % 66 39 39 0 566.82
lib/httpx/adapters/datadog.rb 87.12 % 352 163 142 21 43.17
lib/httpx/adapters/faraday.rb 99.37 % 298 158 157 1 79.15
lib/httpx/adapters/sentry.rb 100.00 % 121 62 62 0 49.55
lib/httpx/adapters/webmock.rb 100.00 % 164 86 86 0 77.36
lib/httpx/altsvc.rb 96.39 % 163 83 80 3 180.35
lib/httpx/buffer.rb 100.00 % 50 21 21 0 164801.62
lib/httpx/callbacks.rb 100.00 % 35 19 19 0 109873.37
lib/httpx/chainable.rb 95.35 % 106 43 41 2 828.81
lib/httpx/connection.rb 94.64 % 876 448 424 24 65329.05
lib/httpx/connection/http1.rb 89.19 % 401 222 198 24 2967.83
lib/httpx/connection/http2.rb 95.18 % 413 249 237 12 96156.85
lib/httpx/domain_name.rb 95.56 % 145 45 43 2 186.69
lib/httpx/errors.rb 97.83 % 119 46 45 1 62.48
lib/httpx/extensions.rb 67.86 % 59 28 19 9 389.54
lib/httpx/headers.rb 100.00 % 175 71 71 0 14309.79
lib/httpx/io.rb 100.00 % 11 5 5 0 23.00
lib/httpx/io/ssl.rb 96.20 % 162 79 76 3 1719.08
lib/httpx/io/tcp.rb 90.35 % 206 114 103 11 5513.25
lib/httpx/io/udp.rb 100.00 % 62 35 35 0 258.80
lib/httpx/io/unix.rb 94.29 % 70 35 33 2 15.80
lib/httpx/loggable.rb 100.00 % 34 13 13 0 41861.69
lib/httpx/options.rb 98.71 % 360 155 153 2 13712.29
lib/httpx/parser/http1.rb 100.00 % 186 109 109 0 5860.23
lib/httpx/plugins/auth.rb 100.00 % 25 9 9 0 18.00
lib/httpx/plugins/auth/basic.rb 100.00 % 20 10 10 0 65.60
lib/httpx/plugins/auth/digest.rb 100.00 % 102 57 57 0 101.88
lib/httpx/plugins/auth/ntlm.rb 100.00 % 35 19 19 0 4.00
lib/httpx/plugins/auth/socks5.rb 100.00 % 22 11 11 0 18.18
lib/httpx/plugins/aws_sdk_authentication.rb 100.00 % 109 43 43 0 9.91
lib/httpx/plugins/aws_sigv4.rb 100.00 % 236 105 105 0 86.08
lib/httpx/plugins/basic_auth.rb 100.00 % 29 12 12 0 26.50
lib/httpx/plugins/brotli.rb 100.00 % 50 25 25 0 9.00
lib/httpx/plugins/callbacks.rb 100.00 % 101 46 46 0 109.70
lib/httpx/plugins/circuit_breaker.rb 100.00 % 143 63 63 0 55.97
lib/httpx/plugins/circuit_breaker/circuit.rb 100.00 % 100 47 47 0 44.51
lib/httpx/plugins/circuit_breaker/circuit_store.rb 100.00 % 53 23 23 0 74.09
lib/httpx/plugins/content_digest.rb 100.00 % 202 100 100 0 60.24
lib/httpx/plugins/cookies.rb 100.00 % 107 51 51 0 89.08
lib/httpx/plugins/cookies/cookie.rb 100.00 % 174 76 76 0 242.33
lib/httpx/plugins/cookies/jar.rb 100.00 % 97 47 47 0 199.21
lib/httpx/plugins/cookies/set_cookie_parser.rb 100.00 % 140 70 70 0 120.09
lib/httpx/plugins/digest_auth.rb 100.00 % 65 29 29 0 74.90
lib/httpx/plugins/expect.rb 100.00 % 118 56 56 0 65.96
lib/httpx/plugins/follow_redirects.rb 100.00 % 229 106 106 0 50387.93
lib/httpx/plugins/grpc.rb 100.00 % 280 133 133 0 98.16
lib/httpx/plugins/grpc/call.rb 90.91 % 63 33 30 3 33.48
lib/httpx/plugins/grpc/grpc_encoding.rb 97.83 % 88 46 45 1 61.85
lib/httpx/plugins/grpc/message.rb 95.83 % 55 24 23 1 32.92
lib/httpx/plugins/h2c.rb 94.64 % 111 56 53 3 11.04
lib/httpx/plugins/ntlm_auth.rb 100.00 % 60 30 30 0 4.83
lib/httpx/plugins/oauth.rb 100.00 % 175 87 87 0 53.33
lib/httpx/plugins/persistent.rb 100.00 % 52 19 19 0 147.05
lib/httpx/plugins/proxy.rb 98.00 % 308 150 147 3 249.47
lib/httpx/plugins/proxy/http.rb 100.00 % 182 102 102 0 145.67
lib/httpx/plugins/proxy/socks4.rb 97.47 % 135 79 77 2 135.37
lib/httpx/plugins/proxy/socks5.rb 99.12 % 194 113 112 1 208.13
lib/httpx/plugins/proxy/ssh.rb 92.31 % 92 52 48 4 6.83
lib/httpx/plugins/push_promise.rb 100.00 % 81 41 41 0 7.90
lib/httpx/plugins/rate_limiter.rb 100.00 % 55 18 18 0 31.39
lib/httpx/plugins/response_cache.rb 100.00 % 181 80 80 0 58.01
lib/httpx/plugins/response_cache/store.rb 100.00 % 93 47 47 0 86.94
lib/httpx/plugins/retries.rb 95.74 % 216 94 90 4 63867.47
lib/httpx/plugins/ssrf_filter.rb 100.00 % 145 61 61 0 99.72
lib/httpx/plugins/stream.rb 100.00 % 157 74 74 0 92.28
lib/httpx/plugins/upgrade.rb 100.00 % 78 34 34 0 34.50
lib/httpx/plugins/upgrade/h2.rb 91.67 % 54 24 22 2 6.25
lib/httpx/plugins/webdav.rb 100.00 % 86 39 39 0 17.41
lib/httpx/plugins/xml.rb 100.00 % 76 34 34 0 63.71
lib/httpx/pmatch_extensions.rb 100.00 % 33 17 17 0 22.71
lib/httpx/pool.rb 100.00 % 132 68 68 0 3720.69
lib/httpx/punycode.rb 100.00 % 22 9 9 0 15.67
lib/httpx/request.rb 100.00 % 279 114 114 0 4046.63
lib/httpx/request/body.rb 100.00 % 158 69 69 0 2203.06
lib/httpx/resolver.rb 100.00 % 161 82 82 0 1250.43
lib/httpx/resolver/https.rb 86.71 % 246 143 124 19 28.71
lib/httpx/resolver/multi.rb 88.24 % 93 51 45 6 2572.59
lib/httpx/resolver/native.rb 93.33 % 482 285 266 19 666.26
lib/httpx/resolver/resolver.rb 91.25 % 158 80 73 7 1188.78
lib/httpx/resolver/system.rb 82.09 % 232 134 110 24 15.32
lib/httpx/response.rb 100.00 % 289 107 107 0 1370.87
lib/httpx/response/body.rb 100.00 % 253 114 114 0 2030.98
lib/httpx/response/buffer.rb 96.00 % 96 50 48 2 1435.64
lib/httpx/selector.rb 91.92 % 207 99 91 8 453948.70
lib/httpx/session.rb 87.79 % 522 262 230 32 27984.51
lib/httpx/session_extensions.rb 100.00 % 29 14 14 0 5.79
lib/httpx/timers.rb 100.00 % 110 57 57 0 709653.30
lib/httpx/transcoder.rb 100.00 % 91 52 52 0 213.00
lib/httpx/transcoder/body.rb 100.00 % 59 33 33 0 578.85
lib/httpx/transcoder/chunker.rb 100.00 % 115 66 66 0 168.80
lib/httpx/transcoder/deflate.rb 100.00 % 37 20 20 0 24.40
lib/httpx/transcoder/form.rb 100.00 % 80 42 42 0 332.55
lib/httpx/transcoder/gzip.rb 100.00 % 71 40 40 0 83.70
lib/httpx/transcoder/json.rb 100.00 % 71 33 33 0 34.52
lib/httpx/transcoder/multipart.rb 100.00 % 17 10 10 0 695.00
lib/httpx/transcoder/multipart/decoder.rb 93.83 % 139 81 76 5 23.00
lib/httpx/transcoder/multipart/encoder.rb 100.00 % 118 69 69 0 1540.49
lib/httpx/transcoder/multipart/mime_type_detector.rb 92.11 % 78 38 35 3 135.00
lib/httpx/transcoder/multipart/part.rb 100.00 % 35 18 18 0 382.28
lib/httpx/transcoder/utils/body_reader.rb 96.00 % 46 25 24 1 79.92
lib/httpx/transcoder/utils/deflater.rb 100.00 % 75 37 37 0 81.19
lib/httpx/utils.rb 100.00 % 75 39 39 0 171527.62

lib/httpx.rb

100.0% lines covered

39 relevant lines. 39 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 23 require "httpx/version"
  3. # Top-Level Namespace
  4. #
  5. 23 module HTTPX
  6. 23 EMPTY = [].freeze
  7. 23 EMPTY_HASH = {}.freeze
  8. # All plugins should be stored under this module/namespace. Can register and load
  9. # plugins.
  10. #
  11. 23 module Plugins
  12. 23 @plugins = {}
  13. 23 @plugins_mutex = Thread::Mutex.new
  14. # Loads a plugin based on a name. If the plugin hasn't been loaded, tries to load
  15. # it from the load path under "httpx/plugins/" directory.
  16. #
  17. 23 def self.load_plugin(name)
  18. 4042 h = @plugins
  19. 4042 m = @plugins_mutex
  20. 8084 unless (plugin = m.synchronize { h[name] })
  21. 86 require "httpx/plugins/#{name}"
  22. 172 raise "Plugin #{name} hasn't been registered" unless (plugin = m.synchronize { h[name] })
  23. end
  24. 4042 plugin
  25. end
  26. # Registers a plugin (+mod+) in the central store indexed by +name+.
  27. #
  28. 23 def self.register_plugin(name, mod)
  29. 237 h = @plugins
  30. 237 m = @plugins_mutex
  31. 474 m.synchronize { h[name] = mod }
  32. end
  33. end
  34. end
  35. 23 require "httpx/extensions"
  36. 23 require "httpx/errors"
  37. 23 require "httpx/utils"
  38. 23 require "httpx/punycode"
  39. 23 require "httpx/domain_name"
  40. 23 require "httpx/altsvc"
  41. 23 require "httpx/callbacks"
  42. 23 require "httpx/loggable"
  43. 23 require "httpx/transcoder"
  44. 23 require "httpx/timers"
  45. 23 require "httpx/pool"
  46. 23 require "httpx/headers"
  47. 23 require "httpx/request"
  48. 23 require "httpx/response"
  49. 23 require "httpx/options"
  50. 23 require "httpx/chainable"
  51. 23 require "httpx/session"
  52. 23 require "httpx/session_extensions"
  53. # load integrations when possible
  54. 23 require "httpx/adapters/datadog" if defined?(DDTrace) || defined?(Datadog)
  55. 23 require "httpx/adapters/sentry" if defined?(Sentry)
  56. 23 require "httpx/adapters/webmock" if defined?(WebMock)

lib/httpx/adapters/datadog.rb

87.12% lines covered

163 relevant lines. 142 lines covered and 21 lines missed.
    
  1. # frozen_string_literal: true
  2. 5 require "datadog/tracing/contrib/integration"
  3. 5 require "datadog/tracing/contrib/configuration/settings"
  4. 5 require "datadog/tracing/contrib/patcher"
  5. 5 module Datadog::Tracing
  6. 5 module Contrib
  7. 5 module HTTPX
  8. 5 DATADOG_VERSION = defined?(::DDTrace) ? ::DDTrace::VERSION : ::Datadog::VERSION
  9. 5 METADATA_MODULE = Datadog::Tracing::Metadata
  10. 5 TYPE_OUTBOUND = Datadog::Tracing::Metadata::Ext::HTTP::TYPE_OUTBOUND
  11. 5 TAG_PEER_SERVICE = Datadog::Tracing::Metadata::Ext::TAG_PEER_SERVICE
  12. 5 TAG_URL = Datadog::Tracing::Metadata::Ext::HTTP::TAG_URL
  13. 5 TAG_METHOD = Datadog::Tracing::Metadata::Ext::HTTP::TAG_METHOD
  14. 5 TAG_TARGET_HOST = Datadog::Tracing::Metadata::Ext::NET::TAG_TARGET_HOST
  15. 5 TAG_TARGET_PORT = Datadog::Tracing::Metadata::Ext::NET::TAG_TARGET_PORT
  16. 5 TAG_STATUS_CODE = Datadog::Tracing::Metadata::Ext::HTTP::TAG_STATUS_CODE
  17. # HTTPX Datadog Plugin
  18. #
  19. # Enables tracing for httpx requests.
  20. #
  21. # A span will be created for each request transaction; the span is created lazily only when
  22. # receiving a response, and it is fed the start time stored inside the tracer object.
  23. #
  24. 5 module Plugin
  25. 5 class RequestTracer
  26. 5 include Contrib::HttpAnnotationHelper
  27. 5 SPAN_REQUEST = "httpx.request"
  28. # initializes the tracer object on the +request+.
  29. 5 def initialize(request)
  30. 223 @request = request
  31. 223 @start_time = nil
  32. # request objects are reused, when already buffered requests get rerouted to a different
  33. # connection due to connection issues, or when they already got a response, but need to
  34. # be retried. In such situations, the original span needs to be extended for the former,
  35. # while a new is required for the latter.
  36. 246 request.on(:idle) { reset }
  37. # the span is initialized when the request is buffered in the parser, which is the closest
  38. # one gets to actually sending the request.
  39. 343 request.on(:headers) { call }
  40. end
  41. # sets up the span start time, while preparing the on response callback.
  42. 5 def call(*args)
  43. 129 return if @start_time
  44. 124 start(*args)
  45. 124 @request.once(:response, &method(:finish))
  46. end
  47. 5 private
  48. # just sets the span init time. It can be passed a +start_time+ in cases where
  49. # this is collected outside the request transaction.
  50. 5 def start(start_time = now)
  51. 129 @start_time = start_time
  52. end
  53. # resets the start time for already finished request transactions.
  54. 5 def reset
  55. 23 return unless @start_time
  56. 5 start
  57. end
  58. # creates the span from the collected +@start_time+ to what the +response+ state
  59. # contains. It also resets internal state to allow this object to be reused.
  60. 5 def finish(response)
  61. 124 return unless @start_time
  62. 124 span = initialize_span
  63. 124 return unless span
  64. 124 if response.is_a?(::HTTPX::ErrorResponse)
  65. 9 span.set_error(response.error)
  66. else
  67. 115 span.set_tag(TAG_STATUS_CODE, response.status.to_s)
  68. 115 span.set_error(::HTTPX::HTTPError.new(response)) if response.status >= 400 && response.status <= 599
  69. end
  70. 124 span.finish
  71. ensure
  72. 124 @start_time = nil
  73. end
  74. # return a span initialized with the +@request+ state.
  75. 5 def initialize_span
  76. 124 verb = @request.verb
  77. 124 uri = @request.uri
  78. 124 span = create_span(@request)
  79. 124 span.resource = verb
  80. # Add additional request specific tags to the span.
  81. 124 span.set_tag(TAG_URL, @request.path)
  82. 124 span.set_tag(TAG_METHOD, verb)
  83. 124 span.set_tag(TAG_TARGET_HOST, uri.host)
  84. 124 span.set_tag(TAG_TARGET_PORT, uri.port.to_s)
  85. # Tag as an external peer service
  86. 124 span.set_tag(TAG_PEER_SERVICE, span.service)
  87. 124 if configuration[:distributed_tracing]
  88. 119 propagate_trace_http(
  89. Datadog::Tracing.active_trace.to_digest,
  90. @request.headers
  91. )
  92. end
  93. # Set analytics sample rate
  94. 124 if Contrib::Analytics.enabled?(configuration[:analytics_enabled])
  95. 10 Contrib::Analytics.set_sample_rate(span, configuration[:analytics_sample_rate])
  96. end
  97. 124 span
  98. rescue StandardError => e
  99. Datadog.logger.error("error preparing span for http request: #{e}")
  100. Datadog.logger.error(e.backtrace)
  101. end
  102. 5 def now
  103. 120 ::Datadog::Core::Utils::Time.now.utc
  104. end
  105. 5 def configuration
  106. 382 @configuration ||= Datadog.configuration.tracing[:httpx, @request.uri.host]
  107. end
  108. 5 if Gem::Version.new(DATADOG_VERSION::STRING) >= Gem::Version.new("2.0.0")
  109. 2 def propagate_trace_http(digest, headers)
  110. 52 Datadog::Tracing::Contrib::HTTP.inject(digest, headers)
  111. end
  112. 2 def create_span(request)
  113. 54 Datadog::Tracing.trace(
  114. SPAN_REQUEST,
  115. service: service_name(request.uri.host, configuration, Datadog.configuration_for(self)),
  116. type: TYPE_OUTBOUND,
  117. start_time: @start_time
  118. )
  119. end
  120. else
  121. 3 def propagate_trace_http(digest, headers)
  122. 67 Datadog::Tracing::Propagation::HTTP.inject!(digest, headers)
  123. end
  124. 3 def create_span(request)
  125. 70 Datadog::Tracing.trace(
  126. SPAN_REQUEST,
  127. service: service_name(request.uri.host, configuration, Datadog.configuration_for(self)),
  128. span_type: TYPE_OUTBOUND,
  129. start_time: @start_time
  130. )
  131. end
  132. end
  133. end
  134. 5 module RequestMethods
  135. # intercepts request initialization to inject the tracing logic.
  136. 5 def initialize(*)
  137. 214 super
  138. 214 return unless Datadog::Tracing.enabled?
  139. 214 RequestTracer.new(self)
  140. end
  141. end
  142. 5 module ConnectionMethods
  143. 5 attr_reader :init_time
  144. 5 def initialize(*)
  145. 201 super
  146. 201 @init_time = ::Datadog::Core::Utils::Time.now.utc
  147. end
  148. # handles the case when the +error+ happened during name resolution, which meanns
  149. # that the tracing logic hasn't been injected yet; in such cases, the approximate
  150. # initial resolving time is collected from the connection, and used as span start time,
  151. # and the tracing object in inserted before the on response callback is called.
  152. 5 def handle_error(error, request = nil)
  153. 9 return super unless Datadog::Tracing.enabled?
  154. 9 return super unless error.respond_to?(:connection)
  155. 9 @pending.each do |req|
  156. 9 next if request and request == req
  157. 9 RequestTracer.new(req).call(error.connection.init_time)
  158. end
  159. 9 RequestTracer.new(request).call(error.connection.init_time) if request
  160. 9 super
  161. end
  162. end
  163. end
  164. 5 module Configuration
  165. # Default settings for httpx
  166. #
  167. 5 class Settings < Datadog::Tracing::Contrib::Configuration::Settings
  168. 5 DEFAULT_ERROR_HANDLER = lambda do |response|
  169. Datadog::Ext::HTTP::ERROR_RANGE.cover?(response.status)
  170. end
  171. 5 option :service_name, default: "httpx"
  172. 5 option :distributed_tracing, default: true
  173. 5 option :split_by_domain, default: false
  174. 5 if Gem::Version.new(DATADOG_VERSION::STRING) >= Gem::Version.new("1.13.0")
  175. 5 option :enabled do |o|
  176. 5 o.type :bool
  177. 5 o.env "DD_TRACE_HTTPX_ENABLED"
  178. 5 o.default true
  179. end
  180. 5 option :analytics_enabled do |o|
  181. 5 o.type :bool
  182. 5 o.env "DD_TRACE_HTTPX_ANALYTICS_ENABLED"
  183. 5 o.default false
  184. end
  185. 5 option :analytics_sample_rate do |o|
  186. 5 o.type :float
  187. 5 o.env "DD_TRACE_HTTPX_ANALYTICS_SAMPLE_RATE"
  188. 5 o.default 1.0
  189. end
  190. else
  191. option :enabled do |o|
  192. o.default { env_to_bool("DD_TRACE_HTTPX_ENABLED", true) }
  193. o.lazy
  194. end
  195. option :analytics_enabled do |o|
  196. o.default { env_to_bool(%w[DD_TRACE_HTTPX_ANALYTICS_ENABLED DD_HTTPX_ANALYTICS_ENABLED], false) }
  197. o.lazy
  198. end
  199. option :analytics_sample_rate do |o|
  200. o.default { env_to_float(%w[DD_TRACE_HTTPX_ANALYTICS_SAMPLE_RATE DD_HTTPX_ANALYTICS_SAMPLE_RATE], 1.0) }
  201. o.lazy
  202. end
  203. end
  204. 5 if defined?(Datadog::Tracing::Contrib::SpanAttributeSchema)
  205. 5 option :service_name do |o|
  206. 5 o.default do
  207. 60 Datadog::Tracing::Contrib::SpanAttributeSchema.fetch_service_name(
  208. "DD_TRACE_HTTPX_SERVICE_NAME",
  209. "httpx"
  210. )
  211. end
  212. 5 o.lazy unless Gem::Version.new(DATADOG_VERSION::STRING) >= Gem::Version.new("1.13.0")
  213. end
  214. else
  215. option :service_name do |o|
  216. o.default do
  217. ENV.fetch("DD_TRACE_HTTPX_SERVICE_NAME", "httpx")
  218. end
  219. o.lazy unless Gem::Version.new(DATADOG_VERSION::STRING) >= Gem::Version.new("1.13.0")
  220. end
  221. end
  222. 5 option :distributed_tracing, default: true
  223. 5 if Gem::Version.new(DATADOG_VERSION::STRING) >= Gem::Version.new("1.15.0")
  224. 5 option :error_handler do |o|
  225. 5 o.type :proc
  226. 5 o.default_proc(&DEFAULT_ERROR_HANDLER)
  227. end
  228. elsif Gem::Version.new(DATADOG_VERSION::STRING) >= Gem::Version.new("1.13.0")
  229. option :error_handler do |o|
  230. o.type :proc
  231. o.experimental_default_proc(&DEFAULT_ERROR_HANDLER)
  232. end
  233. else
  234. option :error_handler, default: DEFAULT_ERROR_HANDLER
  235. end
  236. end
  237. end
  238. # Patcher enables patching of 'httpx' with datadog components.
  239. #
  240. 5 module Patcher
  241. 5 include Datadog::Tracing::Contrib::Patcher
  242. 5 module_function
  243. 5 def target_version
  244. 10 Integration.version
  245. end
  246. # loads a session instannce with the datadog plugin, and replaces the
  247. # base HTTPX::Session with the patched session class.
  248. 5 def patch
  249. 5 datadog_session = ::HTTPX.plugin(Plugin)
  250. 5 ::HTTPX.send(:remove_const, :Session)
  251. 5 ::HTTPX.send(:const_set, :Session, datadog_session.class)
  252. end
  253. end
  254. # Datadog Integration for HTTPX.
  255. #
  256. 5 class Integration
  257. 5 include Contrib::Integration
  258. 5 MINIMUM_VERSION = Gem::Version.new("0.10.2")
  259. 5 register_as :httpx
  260. 5 def self.version
  261. 205 Gem.loaded_specs["httpx"] && Gem.loaded_specs["httpx"].version
  262. end
  263. 5 def self.loaded?
  264. 65 defined?(::HTTPX::Request)
  265. end
  266. 5 def self.compatible?
  267. 65 super && version >= MINIMUM_VERSION
  268. end
  269. 5 def new_configuration
  270. 70 Configuration::Settings.new
  271. end
  272. 5 def patcher
  273. 130 Patcher
  274. end
  275. end
  276. end
  277. end
  278. end

lib/httpx/adapters/faraday.rb

99.37% lines covered

158 relevant lines. 157 lines covered and 1 lines missed.
    
  1. # frozen_string_literal: true
  2. 7 require "delegate"
  3. 7 require "httpx"
  4. 7 require "faraday"
  5. 7 module Faraday
  6. 7 class Adapter
  7. 7 class HTTPX < Faraday::Adapter
  8. 7 module RequestMixin
  9. 7 def build_connection(env)
  10. 163 return @connection if defined?(@connection)
  11. 163 @connection = ::HTTPX.plugin(:persistent).plugin(ReasonPlugin)
  12. 163 @connection = @connection.with(@connection_options) unless @connection_options.empty?
  13. 163 connection_opts = options_from_env(env)
  14. 163 if (bind = env.request.bind)
  15. 6 @bind = TCPSocket.new(bind[:host], bind[:port])
  16. 6 connection_opts[:io] = @bind
  17. end
  18. 163 @connection = @connection.with(connection_opts)
  19. 163 if (proxy = env.request.proxy)
  20. 6 proxy_options = { uri: proxy.uri }
  21. 6 proxy_options[:username] = proxy.user if proxy.user
  22. 6 proxy_options[:password] = proxy.password if proxy.password
  23. 6 @connection = @connection.plugin(:proxy).with(proxy: proxy_options)
  24. end
  25. 163 @connection = @connection.plugin(OnDataPlugin) if env.request.stream_response?
  26. 163 @connection = @config_block.call(@connection) || @connection if @config_block
  27. 163 @connection
  28. end
  29. 7 def close
  30. 168 @connection.close if @connection
  31. 168 @bind.close if @bind
  32. end
  33. 7 private
  34. 7 def connect(env, &blk)
  35. 163 connection(env, &blk)
  36. rescue ::HTTPX::TLSError => e
  37. 6 raise Faraday::SSLError, e
  38. rescue Errno::ECONNABORTED,
  39. Errno::ECONNREFUSED,
  40. Errno::ECONNRESET,
  41. Errno::EHOSTUNREACH,
  42. Errno::EINVAL,
  43. Errno::ENETUNREACH,
  44. Errno::EPIPE,
  45. ::HTTPX::ConnectionError => e
  46. 6 raise Faraday::ConnectionFailed, e
  47. end
  48. 7 def build_request(env)
  49. 169 meth = env[:method]
  50. request_options = {
  51. 168 headers: env.request_headers,
  52. body: env.body,
  53. **options_from_env(env),
  54. }
  55. 169 [meth.to_s.upcase, env.url, request_options]
  56. end
  57. 7 def options_from_env(env)
  58. 332 timeout_options = {}
  59. 332 req_opts = env.request
  60. 332 if (sec = request_timeout(:read, req_opts))
  61. 12 timeout_options[:read_timeout] = sec
  62. end
  63. 332 if (sec = request_timeout(:write, req_opts))
  64. 12 timeout_options[:write_timeout] = sec
  65. end
  66. 332 if (sec = request_timeout(:open, req_opts))
  67. 12 timeout_options[:connect_timeout] = sec
  68. end
  69. {
  70. 331 ssl: ssl_options_from_env(env),
  71. timeout: timeout_options,
  72. }
  73. end
  74. 7 if defined?(::OpenSSL)
  75. 7 def ssl_options_from_env(env)
  76. 332 ssl_options = {}
  77. 332 unless env.ssl.verify.nil?
  78. 24 ssl_options[:verify_mode] = env.ssl.verify ? OpenSSL::SSL::VERIFY_PEER : OpenSSL::SSL::VERIFY_NONE
  79. end
  80. 332 ssl_options[:ca_file] = env.ssl.ca_file if env.ssl.ca_file
  81. 332 ssl_options[:ca_path] = env.ssl.ca_path if env.ssl.ca_path
  82. 332 ssl_options[:cert_store] = env.ssl.cert_store if env.ssl.cert_store
  83. 332 ssl_options[:cert] = env.ssl.client_cert if env.ssl.client_cert
  84. 332 ssl_options[:key] = env.ssl.client_key if env.ssl.client_key
  85. 332 ssl_options[:ssl_version] = env.ssl.version if env.ssl.version
  86. 332 ssl_options[:verify_depth] = env.ssl.verify_depth if env.ssl.verify_depth
  87. 332 ssl_options[:min_version] = env.ssl.min_version if env.ssl.min_version
  88. 332 ssl_options[:max_version] = env.ssl.max_version if env.ssl.max_version
  89. 332 ssl_options
  90. end
  91. else
  92. skipped # :nocov:
  93. skipped def ssl_options_from_env(*)
  94. skipped {}
  95. skipped end
  96. skipped # :nocov:
  97. end
  98. end
  99. 7 include RequestMixin
  100. 7 module OnDataPlugin
  101. 7 module RequestMethods
  102. 7 attr_writer :response_on_data
  103. 7 def response=(response)
  104. 12 super
  105. 12 return if response.is_a?(::HTTPX::ErrorResponse)
  106. 12 response.body.on_data = @response_on_data
  107. end
  108. end
  109. 7 module ResponseBodyMethods
  110. 7 attr_writer :on_data
  111. 7 def write(chunk)
  112. 35 return super unless @on_data
  113. 35 @on_data.call(chunk, chunk.bytesize)
  114. end
  115. end
  116. end
  117. 7 module ReasonPlugin
  118. 7 def self.load_dependencies(*)
  119. 163 require "net/http/status"
  120. end
  121. 7 module ResponseMethods
  122. 7 def reason
  123. 133 Net::HTTP::STATUS_CODES.fetch(@status)
  124. end
  125. end
  126. end
  127. 7 class ParallelManager
  128. 7 class ResponseHandler < SimpleDelegator
  129. 7 attr_reader :env
  130. 7 def initialize(env)
  131. 24 @env = env
  132. 24 super
  133. end
  134. 7 def on_response(&blk)
  135. 48 if blk
  136. 24 @on_response = ->(response) do
  137. 24 blk.call(response)
  138. end
  139. 24 self
  140. else
  141. 24 @on_response
  142. end
  143. end
  144. 7 def on_complete(&blk)
  145. 72 if blk
  146. 24 @on_complete = blk
  147. 24 self
  148. else
  149. 48 @on_complete
  150. end
  151. end
  152. end
  153. 7 include RequestMixin
  154. 7 def initialize(options)
  155. 24 @handlers = []
  156. 24 @connection_options = options
  157. end
  158. 7 def enqueue(request)
  159. 24 handler = ResponseHandler.new(request)
  160. 24 @handlers << handler
  161. 24 handler
  162. end
  163. 7 def run
  164. 24 return unless @handlers.last
  165. 18 env = @handlers.last.env
  166. 18 connect(env) do |session|
  167. 42 requests = @handlers.map { |handler| session.build_request(*build_request(handler.env)) }
  168. 18 if env.request.stream_response?
  169. 6 requests.each do |request|
  170. 6 request.response_on_data = env.request.on_data
  171. end
  172. end
  173. 18 responses = session.request(*requests)
  174. 18 Array(responses).each_with_index do |response, index|
  175. 24 handler = @handlers[index]
  176. 24 handler.on_response.call(response)
  177. 24 handler.on_complete.call(handler.env) if handler.on_complete
  178. end
  179. end
  180. rescue ::HTTPX::TimeoutError => e
  181. raise Faraday::TimeoutError, e
  182. end
  183. # from Faraday::Adapter#connection
  184. 7 def connection(env)
  185. 18 conn = build_connection(env)
  186. 18 return conn unless block_given?
  187. 18 yield conn
  188. end
  189. 7 private
  190. # from Faraday::Adapter#request_timeout
  191. 7 def request_timeout(type, options)
  192. 126 key = Faraday::Adapter::TIMEOUT_KEYS[type]
  193. 126 options[key] || options[:timeout]
  194. end
  195. end
  196. 7 self.supports_parallel = true
  197. 7 class << self
  198. 7 def setup_parallel_manager(options = {})
  199. 24 ParallelManager.new(options)
  200. end
  201. end
  202. 7 def call(env)
  203. 169 super
  204. 169 if parallel?(env)
  205. 24 handler = env[:parallel_manager].enqueue(env)
  206. 24 handler.on_response do |response|
  207. 24 if response.is_a?(::HTTPX::Response)
  208. 18 save_response(env, response.status, response.body.to_s, response.headers, response.reason) do |response_headers|
  209. 18 response_headers.merge!(response.headers)
  210. end
  211. else
  212. 6 env[:error] = response.error
  213. 6 save_response(env, 0, "", {}, nil)
  214. end
  215. end
  216. 24 return handler
  217. end
  218. 145 response = connect_and_request(env)
  219. 115 save_response(env, response.status, response.body.to_s, response.headers, response.reason) do |response_headers|
  220. 115 response_headers.merge!(response.headers)
  221. end
  222. 115 @app.call(env)
  223. end
  224. 7 private
  225. 7 def connect_and_request(env)
  226. 145 connect(env) do |session|
  227. 145 request = session.build_request(*build_request(env))
  228. 145 request.response_on_data = env.request.on_data if env.request.stream_response?
  229. 145 response = session.request(request)
  230. # do not call #raise_for_status for HTTP 4xx or 5xx, as faraday has a middleware for that.
  231. 145 response.raise_for_status unless response.is_a?(::HTTPX::Response)
  232. 115 response
  233. end
  234. rescue ::HTTPX::TimeoutError => e
  235. 18 raise Faraday::TimeoutError, e
  236. end
  237. 7 def parallel?(env)
  238. 169 env[:parallel_manager]
  239. end
  240. end
  241. 7 register_middleware httpx: HTTPX
  242. end
  243. end

lib/httpx/adapters/sentry.rb

100.0% lines covered

62 relevant lines. 62 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 5 require "sentry-ruby"
  3. 5 module HTTPX::Plugins
  4. 5 module Sentry
  5. 5 module Tracer
  6. 5 module_function
  7. 5 def call(request)
  8. 64 sentry_span = start_sentry_span
  9. 64 return unless sentry_span
  10. 64 set_sentry_trace_header(request, sentry_span)
  11. 64 request.on(:response, &method(:finish_sentry_span).curry(3)[sentry_span, request])
  12. end
  13. 5 def start_sentry_span
  14. 64 return unless ::Sentry.initialized? && (span = ::Sentry.get_current_scope.get_span)
  15. 64 return if span.sampled == false
  16. 64 span.start_child(op: "httpx.client", start_timestamp: ::Sentry.utc_now.to_f)
  17. end
  18. 5 def set_sentry_trace_header(request, sentry_span)
  19. 64 return unless sentry_span
  20. 64 config = ::Sentry.configuration
  21. 64 url = request.uri.to_s
  22. 128 return unless config.propagate_traces && config.trace_propagation_targets.any? { |target| url.match?(target) }
  23. 64 trace = ::Sentry.get_current_client.generate_sentry_trace(sentry_span)
  24. 64 request.headers[::Sentry::SENTRY_TRACE_HEADER_NAME] = trace if trace
  25. end
  26. 5 def finish_sentry_span(span, request, response)
  27. 66 return unless ::Sentry.initialized?
  28. 66 record_sentry_breadcrumb(request, response)
  29. 66 record_sentry_span(request, response, span)
  30. end
  31. 5 def record_sentry_breadcrumb(req, res)
  32. 66 return unless ::Sentry.configuration.breadcrumbs_logger.include?(:http_logger)
  33. 66 request_info = extract_request_info(req)
  34. 66 data = if res.is_a?(HTTPX::ErrorResponse)
  35. 6 { error: res.error.message, **request_info }
  36. else
  37. 60 { status: res.status, **request_info }
  38. end
  39. 66 crumb = ::Sentry::Breadcrumb.new(
  40. level: :info,
  41. category: "httpx",
  42. type: :info,
  43. data: data
  44. )
  45. 66 ::Sentry.add_breadcrumb(crumb)
  46. end
  47. 5 def record_sentry_span(req, res, sentry_span)
  48. 66 return unless sentry_span
  49. 66 request_info = extract_request_info(req)
  50. 66 sentry_span.set_description("#{request_info[:method]} #{request_info[:url]}")
  51. 66 if res.is_a?(HTTPX::ErrorResponse)
  52. 6 sentry_span.set_data(:error, res.error.message)
  53. else
  54. 60 sentry_span.set_data(:status, res.status)
  55. end
  56. 66 sentry_span.set_timestamp(::Sentry.utc_now.to_f)
  57. end
  58. 5 def extract_request_info(req)
  59. 132 uri = req.uri
  60. result = {
  61. 132 method: req.verb,
  62. }
  63. 132 if ::Sentry.configuration.send_default_pii
  64. 20 uri += "?#{req.query}" unless req.query.empty?
  65. 20 result[:body] = req.body.to_s unless req.body.empty? || req.body.unbounded_body?
  66. end
  67. 132 result[:url] = uri.to_s
  68. 132 result
  69. end
  70. end
  71. 5 module RequestMethods
  72. 5 def __sentry_enable_trace!
  73. 66 return if @__sentry_enable_trace
  74. 64 Tracer.call(self)
  75. 64 @__sentry_enable_trace = true
  76. end
  77. end
  78. 5 module ConnectionMethods
  79. 5 def send(request)
  80. 66 request.__sentry_enable_trace!
  81. 66 super
  82. end
  83. end
  84. end
  85. end
  86. 5 Sentry.register_patch(:httpx) do
  87. 25 sentry_session = HTTPX.plugin(HTTPX::Plugins::Sentry)
  88. 25 HTTPX.send(:remove_const, :Session)
  89. 25 HTTPX.send(:const_set, :Session, sentry_session.class)
  90. end

lib/httpx/adapters/webmock.rb

100.0% lines covered

86 relevant lines. 86 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 7 module WebMock
  3. 7 module HttpLibAdapters
  4. 7 require "net/http/status"
  5. 7 HTTP_REASONS = Net::HTTP::STATUS_CODES
  6. #
  7. # HTTPX plugin for webmock.
  8. #
  9. # Requests are "hijacked" at the session, before they're distributed to a connection.
  10. #
  11. 7 module Plugin
  12. 7 class << self
  13. 7 def build_webmock_request_signature(request)
  14. 157 uri = WebMock::Util::URI.heuristic_parse(request.uri)
  15. 157 uri.query = request.query
  16. 157 uri.path = uri.normalized_path.gsub("[^:]//", "/")
  17. 157 WebMock::RequestSignature.new(
  18. request.verb.downcase.to_sym,
  19. uri.to_s,
  20. body: request.body.to_s,
  21. headers: request.headers.to_h
  22. )
  23. end
  24. 7 def build_webmock_response(_request, response)
  25. 5 webmock_response = WebMock::Response.new
  26. 5 webmock_response.status = [response.status, HTTP_REASONS[response.status]]
  27. 5 webmock_response.body = response.body.to_s
  28. 5 webmock_response.headers = response.headers.to_h
  29. 5 webmock_response
  30. end
  31. 7 def build_from_webmock_response(request, webmock_response)
  32. 132 return build_error_response(request, HTTPX::TimeoutError.new(1, "Timed out")) if webmock_response.should_timeout
  33. 117 return build_error_response(request, webmock_response.exception) if webmock_response.exception
  34. 111 request.options.response_class.new(request,
  35. webmock_response.status[0],
  36. "2.0",
  37. webmock_response.headers).tap do |res|
  38. 111 res.mocked = true
  39. end
  40. end
  41. 7 def build_error_response(request, exception)
  42. 21 HTTPX::ErrorResponse.new(request, exception)
  43. end
  44. end
  45. 7 module InstanceMethods
  46. 7 private
  47. 7 def do_init_connection(connection, selector)
  48. 142 super
  49. 142 connection.once(:unmock_connection) do
  50. 20 unless connection.addresses
  51. 20 connection.__send__(:callbacks)[:connect_error].clear
  52. 20 deselect_connection(connection, selector)
  53. end
  54. 20 resolve_connection(connection, selector)
  55. end
  56. end
  57. end
  58. 7 module ResponseMethods
  59. 7 attr_accessor :mocked
  60. 7 def initialize(*)
  61. 131 super
  62. 131 @mocked = false
  63. end
  64. end
  65. 7 module ResponseBodyMethods
  66. 7 def decode_chunk(chunk)
  67. 80 return chunk if @response.mocked
  68. 35 super
  69. end
  70. end
  71. 7 module ConnectionMethods
  72. 7 def initialize(*)
  73. 142 super
  74. 142 @mocked = true
  75. end
  76. 7 def open?
  77. 162 return true if @mocked
  78. 20 super
  79. end
  80. 7 def interests
  81. 230 return if @mocked
  82. 205 super
  83. end
  84. 7 def terminate
  85. 121 force_reset
  86. end
  87. 7 def send(request)
  88. 157 request_signature = Plugin.build_webmock_request_signature(request)
  89. 157 WebMock::RequestRegistry.instance.requested_signatures.put(request_signature)
  90. 157 if (mock_response = WebMock::StubRegistry.instance.response_for_request(request_signature))
  91. 132 response = Plugin.build_from_webmock_response(request, mock_response)
  92. 132 WebMock::CallbackRegistry.invoke_callbacks({ lib: :httpx }, request_signature, mock_response)
  93. 132 log { "mocking #{request.uri} with #{mock_response.inspect}" }
  94. 132 request.response = response
  95. 132 request.emit(:response, response)
  96. 132 response << mock_response.body.dup unless response.is_a?(HTTPX::ErrorResponse)
  97. 25 elsif WebMock.net_connect_allowed?(request_signature.uri)
  98. 20 if WebMock::CallbackRegistry.any_callbacks?
  99. 5 request.on(:response) do |resp|
  100. 5 unless resp.is_a?(HTTPX::ErrorResponse)
  101. 5 webmock_response = Plugin.build_webmock_response(request, resp)
  102. 5 WebMock::CallbackRegistry.invoke_callbacks(
  103. { lib: :httpx, real_request: true }, request_signature,
  104. webmock_response
  105. )
  106. end
  107. end
  108. end
  109. 20 @mocked = false
  110. 20 emit(:unmock_connection, self)
  111. 20 super
  112. else
  113. 5 raise WebMock::NetConnectNotAllowedError, request_signature
  114. end
  115. end
  116. end
  117. end
  118. 7 class HttpxAdapter < HttpLibAdapter
  119. 7 adapter_for :httpx
  120. 7 class << self
  121. 7 def enable!
  122. 309 @original_session ||= HTTPX::Session
  123. 309 webmock_session = HTTPX.plugin(Plugin)
  124. 309 HTTPX.send(:remove_const, :Session)
  125. 309 HTTPX.send(:const_set, :Session, webmock_session.class)
  126. end
  127. 7 def disable!
  128. 309 return unless @original_session
  129. 302 HTTPX.send(:remove_const, :Session)
  130. 302 HTTPX.send(:const_set, :Session, @original_session)
  131. end
  132. end
  133. end
  134. end
  135. end

lib/httpx/altsvc.rb

96.39% lines covered

83 relevant lines. 80 lines covered and 3 lines missed.
    
  1. # frozen_string_literal: true
  2. 23 require "strscan"
  3. 23 module HTTPX
  4. 23 module AltSvc
  5. # makes connections able to accept requests destined to primary service.
  6. 23 module ConnectionMixin
  7. 23 using URIExtensions
  8. 23 def send(request)
  9. 6 request.headers["alt-used"] = @origin.authority if @parser && !@write_buffer.full? && match_altsvcs?(request.uri)
  10. 6 super
  11. end
  12. 23 def match?(uri, options)
  13. 6 return false if !used? && (@state == :closing || @state == :closed)
  14. 6 match_altsvcs?(uri) && match_altsvc_options?(uri, options)
  15. end
  16. 23 private
  17. # checks if this is connection is an alternative service of
  18. # +uri+
  19. 23 def match_altsvcs?(uri)
  20. 18 @origins.any? { |origin| altsvc_match?(uri, origin) } ||
  21. AltSvc.cached_altsvc(@origin).any? do |altsvc|
  22. origin = altsvc["origin"]
  23. altsvc_match?(origin, uri.origin)
  24. end
  25. end
  26. 23 def match_altsvc_options?(uri, options)
  27. 6 return @options == options unless @options.ssl.all? do |k, v|
  28. 6 v == (k == :hostname ? uri.host : options.ssl[k])
  29. end
  30. 6 @options.options_equals?(options, Options::REQUEST_BODY_IVARS + %i[@ssl])
  31. end
  32. 23 def altsvc_match?(uri, other_uri)
  33. 12 other_uri = URI(other_uri)
  34. 12 uri.origin == other_uri.origin || begin
  35. 6 case uri.scheme
  36. when "h2"
  37. (other_uri.scheme == "https" || other_uri.scheme == "h2") &&
  38. uri.host == other_uri.host &&
  39. uri.port == other_uri.port
  40. else
  41. 6 false
  42. end
  43. end
  44. end
  45. end
  46. 23 @altsvc_mutex = Thread::Mutex.new
  47. 41 @altsvcs = Hash.new { |h, k| h[k] = [] }
  48. 23 module_function
  49. 23 def cached_altsvc(origin)
  50. 30 now = Utils.now
  51. 30 @altsvc_mutex.synchronize do
  52. 30 lookup(origin, now)
  53. end
  54. end
  55. 23 def cached_altsvc_set(origin, entry)
  56. 18 now = Utils.now
  57. 18 @altsvc_mutex.synchronize do
  58. 18 return if @altsvcs[origin].any? { |altsvc| altsvc["origin"] == entry["origin"] }
  59. 18 entry["TTL"] = Integer(entry["ma"]) + now if entry.key?("ma")
  60. 18 @altsvcs[origin] << entry
  61. 18 entry
  62. end
  63. end
  64. 23 def lookup(origin, ttl)
  65. 30 return [] unless @altsvcs.key?(origin)
  66. 24 @altsvcs[origin] = @altsvcs[origin].select do |entry|
  67. 18 !entry.key?("TTL") || entry["TTL"] > ttl
  68. end
  69. 36 @altsvcs[origin].reject { |entry| entry["noop"] }
  70. end
  71. 23 def emit(request, response)
  72. 5839 return unless response.respond_to?(:headers)
  73. # Alt-Svc
  74. 5818 return unless response.headers.key?("alt-svc")
  75. 63 origin = request.origin
  76. 63 host = request.uri.host
  77. 63 altsvc = response.headers["alt-svc"]
  78. # https://datatracker.ietf.org/doc/html/rfc7838#section-3
  79. # A field value containing the special value "clear" indicates that the
  80. # origin requests all alternatives for that origin to be invalidated
  81. # (including those specified in the same response, in case of an
  82. # invalid reply containing both "clear" and alternative services).
  83. 63 if altsvc == "clear"
  84. 6 @altsvc_mutex.synchronize do
  85. 6 @altsvcs[origin].clear
  86. end
  87. 6 return
  88. end
  89. 57 parse(altsvc) do |alt_origin, alt_params|
  90. 6 alt_origin.host ||= host
  91. 6 yield(alt_origin, origin, alt_params)
  92. end
  93. end
  94. 23 def parse(altsvc)
  95. 141 return enum_for(__method__, altsvc) unless block_given?
  96. 99 scanner = StringScanner.new(altsvc)
  97. 99 until scanner.eos?
  98. 99 alt_service = scanner.scan(/[^=]+=("[^"]+"|[^;,]+)/)
  99. 99 alt_params = []
  100. 99 loop do
  101. 117 alt_param = scanner.scan(/[^=]+=("[^"]+"|[^;,]+)/)
  102. 117 alt_params << alt_param.strip if alt_param
  103. 117 scanner.skip(/;/)
  104. 117 break if scanner.eos? || scanner.scan(/ *, */)
  105. end
  106. 198 alt_params = Hash[alt_params.map { |field| field.split("=", 2) }]
  107. 99 alt_proto, alt_authority = alt_service.split("=", 2)
  108. 99 alt_origin = parse_altsvc_origin(alt_proto, alt_authority)
  109. 99 return unless alt_origin
  110. 36 yield(alt_origin, alt_params.merge("proto" => alt_proto))
  111. end
  112. end
  113. 23 def parse_altsvc_scheme(alt_proto)
  114. 117 case alt_proto
  115. when "h2c"
  116. 6 "http"
  117. when "h2"
  118. 42 "https"
  119. end
  120. end
  121. 23 def parse_altsvc_origin(alt_proto, alt_origin)
  122. 99 alt_scheme = parse_altsvc_scheme(alt_proto)
  123. 99 return unless alt_scheme
  124. 36 alt_origin = alt_origin[1..-2] if alt_origin.start_with?("\"") && alt_origin.end_with?("\"")
  125. 36 URI.parse("#{alt_scheme}://#{alt_origin}")
  126. end
  127. end
  128. end

lib/httpx/buffer.rb

100.0% lines covered

21 relevant lines. 21 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 23 require "forwardable"
  3. 23 module HTTPX
  4. # Internal class to abstract a string buffer, by wrapping a string and providing the
  5. # minimum possible API and functionality required.
  6. #
  7. # buffer = Buffer.new(640)
  8. # buffer.full? #=> false
  9. # buffer << "aa"
  10. # buffer.capacity #=> 638
  11. #
  12. 23 class Buffer
  13. 23 extend Forwardable
  14. 23 def_delegator :@buffer, :<<
  15. 23 def_delegator :@buffer, :to_s
  16. 23 def_delegator :@buffer, :to_str
  17. 23 def_delegator :@buffer, :empty?
  18. 23 def_delegator :@buffer, :bytesize
  19. 23 def_delegator :@buffer, :clear
  20. 23 def_delegator :@buffer, :replace
  21. 23 attr_reader :limit
  22. 23 def initialize(limit)
  23. 15532 @buffer = "".b
  24. 15532 @limit = limit
  25. end
  26. 23 def full?
  27. 3413790 @buffer.bytesize >= @limit
  28. end
  29. 23 def capacity
  30. 10 @limit - @buffer.bytesize
  31. end
  32. 23 def shift!(fin)
  33. 15602 @buffer = @buffer.byteslice(fin..-1) || "".b
  34. end
  35. end
  36. end

lib/httpx/callbacks.rb

100.0% lines covered

19 relevant lines. 19 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 23 module HTTPX
  3. 23 module Callbacks
  4. 23 def on(type, &action)
  5. 210662 callbacks(type) << action
  6. 210662 self
  7. end
  8. 23 def once(type, &block)
  9. 84090 on(type) do |*args, &callback|
  10. 83153 block.call(*args, &callback)
  11. 83105 :delete
  12. end
  13. 84090 self
  14. end
  15. 23 def emit(type, *args)
  16. 219953 callbacks(type).delete_if { |pr| :delete == pr.call(*args) } # rubocop:disable Style/YodaCondition
  17. end
  18. 23 def callbacks_for?(type)
  19. 2322 @callbacks.key?(type) && @callbacks[type].any?
  20. end
  21. 23 protected
  22. 23 def callbacks(type = nil)
  23. 310578 return @callbacks unless type
  24. 488303 @callbacks ||= Hash.new { |h, k| h[k] = [] }
  25. 310492 @callbacks[type]
  26. end
  27. end
  28. end

lib/httpx/chainable.rb

95.35% lines covered

43 relevant lines. 41 lines covered and 2 lines missed.
    
  1. # frozen_string_literal: true
  2. 23 module HTTPX
  3. # Session mixin, implements most of the APIs that the users call.
  4. # delegates to a default session when extended.
  5. 23 module Chainable
  6. 23 %w[head get post put delete trace options connect patch].each do |meth|
  7. 198 class_eval(<<-MOD, __FILE__, __LINE__ + 1)
  8. def #{meth}(*uri, **options) # def get(*uri, **options)
  9. 9 request("#{meth.upcase}", uri, **options) # request("GET", uri, **options)
  10. end # end
  11. MOD
  12. end
  13. # delegates to the default session (see HTTPX::Session#request).
  14. 23 def request(*args, **options)
  15. 1876 branch(default_options).request(*args, **options)
  16. end
  17. 23 def accept(type)
  18. 12 with(headers: { "accept" => String(type) })
  19. end
  20. # delegates to the default session (see HTTPX::Session#wrap).
  21. 23 def wrap(&blk)
  22. 67 branch(default_options).wrap(&blk)
  23. end
  24. # returns a new instance loaded with the +pl+ plugin and +options+.
  25. 23 def plugin(pl, options = nil, &blk)
  26. 3755 klass = is_a?(S) ? self.class : Session
  27. 3755 klass = Class.new(klass)
  28. 3755 klass.instance_variable_set(:@default_options, klass.default_options.merge(default_options))
  29. 3755 klass.plugin(pl, options, &blk).new
  30. end
  31. # returns a new instance loaded with +options+.
  32. 23 def with(options, &blk)
  33. 1952 branch(default_options.merge(options), &blk)
  34. end
  35. 23 private
  36. # returns default instance of HTTPX::Options.
  37. 23 def default_options
  38. 7692 @options || Session.default_options
  39. end
  40. # returns a default instance of HTTPX::Session.
  41. 23 def branch(options, &blk)
  42. 3883 return self.class.new(options, &blk) if is_a?(S)
  43. 2245 Session.new(options, &blk)
  44. end
  45. 23 def method_missing(meth, *args, **options, &blk)
  46. 558 case meth
  47. when /\Awith_(.+)/
  48. 551 option = Regexp.last_match(1)
  49. 551 return super unless option
  50. 551 with(option.to_sym => args.first || options)
  51. when /\Aon_(.+)/
  52. 7 callback = Regexp.last_match(1)
  53. 6 return super unless %w[
  54. connection_opened connection_closed
  55. request_error
  56. request_started request_body_chunk request_completed
  57. response_started response_body_chunk response_completed
  58. ].include?(callback)
  59. 7 warn "DEPRECATION WARNING: calling `.#{meth}` on plain HTTPX sessions is deprecated. " \
  60. "Use `HTTPX.plugin(:callbacks).#{meth}` instead."
  61. 7 plugin(:callbacks).__send__(meth, *args, **options, &blk)
  62. else
  63. super
  64. end
  65. end
  66. 23 def respond_to_missing?(meth, *)
  67. 42 case meth
  68. when /\Awith_(.+)/
  69. 30 option = Regexp.last_match(1)
  70. 30 default_options.respond_to?(option) || super
  71. when /\Aon_(.+)/
  72. 12 callback = Regexp.last_match(1)
  73. 10 %w[
  74. connection_opened connection_closed
  75. request_error
  76. request_started request_body_chunk request_completed
  77. response_started response_body_chunk response_completed
  78. 1 ].include?(callback) || super
  79. else
  80. super
  81. end
  82. end
  83. end
  84. 23 extend Chainable
  85. end

lib/httpx/connection.rb

94.64% lines covered

448 relevant lines. 424 lines covered and 24 lines missed.
    
  1. # frozen_string_literal: true
  2. 23 require "resolv"
  3. 23 require "forwardable"
  4. 23 require "httpx/io"
  5. 23 require "httpx/buffer"
  6. 23 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. 23 class Connection
  29. 23 extend Forwardable
  30. 23 include Loggable
  31. 23 include Callbacks
  32. 23 using URIExtensions
  33. 23 require "httpx/connection/http2"
  34. 23 require "httpx/connection/http1"
  35. 23 def_delegator :@io, :closed?
  36. 23 def_delegator :@write_buffer, :empty?
  37. 23 attr_reader :type, :io, :origin, :origins, :state, :pending, :options, :ssl_session
  38. 23 attr_writer :current_selector, :coalesced_connection
  39. 23 attr_accessor :current_session, :family
  40. 23 def initialize(uri, options)
  41. 5281 @current_session = @current_selector = @coalesced_connection = nil
  42. 5281 @exhausted = @cloned = false
  43. 5281 @options = Options.new(options)
  44. 5281 @type = initialize_type(uri, @options)
  45. 5281 @origins = [uri.origin]
  46. 5281 @origin = Utils.to_uri(uri.origin)
  47. 5281 @window_size = @options.window_size
  48. 5281 @read_buffer = Buffer.new(@options.buffer_size)
  49. 5281 @write_buffer = Buffer.new(@options.buffer_size)
  50. 5281 @pending = []
  51. 5281 on(:error, &method(:on_error))
  52. 5281 if @options.io
  53. # if there's an already open IO, get its
  54. # peer address, and force-initiate the parser
  55. 50 transition(:already_open)
  56. 50 @io = build_socket
  57. 50 parser
  58. else
  59. 5231 transition(:idle)
  60. end
  61. 5281 on(:activate) do
  62. 119 @current_session.select_connection(self, @current_selector)
  63. end
  64. 5281 on(:close) do
  65. 5540 next if @exhausted # it'll reset
  66. # may be called after ":close" above, so after the connection has been checked back in.
  67. # next unless @current_session
  68. 5534 next unless @current_session
  69. 5534 @current_session.deselect_connection(self, @current_selector, @cloned)
  70. end
  71. 5281 on(:terminate) do
  72. 1939 next if @exhausted # it'll reset
  73. # may be called after ":close" above, so after the connection has been checked back in.
  74. 1933 next unless @current_session
  75. 12 @current_session.deselect_connection(self, @current_selector)
  76. end
  77. 5281 on(:altsvc) do |alt_origin, origin, alt_params|
  78. 6 build_altsvc_connection(alt_origin, origin, alt_params)
  79. end
  80. 5281 @inflight = 0
  81. 5281 @keep_alive_timeout = @options.timeout[:keep_alive_timeout]
  82. 5281 @intervals = []
  83. 5281 self.addresses = @options.addresses if @options.addresses
  84. end
  85. 23 def peer
  86. 11287 @origin
  87. end
  88. # this is a semi-private method, to be used by the resolver
  89. # to initiate the io object.
  90. 23 def addresses=(addrs)
  91. 5084 if @io
  92. 166 @io.add_addresses(addrs)
  93. else
  94. 4918 @io = build_socket(addrs)
  95. end
  96. end
  97. 23 def addresses
  98. 10545 @io && @io.addresses
  99. end
  100. 23 def match?(uri, options)
  101. 1675 return false if !used? && (@state == :closing || @state == :closed)
  102. 146 (
  103. 1427 @origins.include?(uri.origin) &&
  104. # if there is more than one origin to match, it means that this connection
  105. # was the result of coalescing. To prevent blind trust in the case where the
  106. # origin came from an ORIGIN frame, we're going to verify the hostname with the
  107. # SSL certificate
  108. 1506 (@origins.size == 1 || @origin == uri.origin || (@io.is_a?(SSL) && @io.verify_hostname(uri.host)))
  109. ) && @options == options
  110. end
  111. 23 def expired?
  112. return false unless @io
  113. @io.expired?
  114. end
  115. 23 def mergeable?(connection)
  116. 243 return false if @state == :closing || @state == :closed || !@io
  117. 53 return false unless connection.addresses
  118. (
  119. 53 (open? && @origin == connection.origin) ||
  120. 53 !(@io.addresses & (connection.addresses || [])).empty?
  121. ) && @options == connection.options
  122. end
  123. # coalescable connections need to be mergeable!
  124. # but internally, #mergeable? is called before #coalescable?
  125. 23 def coalescable?(connection)
  126. 23 if @io.protocol == "h2" &&
  127. @origin.scheme == "https" &&
  128. connection.origin.scheme == "https" &&
  129. @io.can_verify_peer?
  130. 11 @io.verify_hostname(connection.origin.host)
  131. else
  132. 12 @origin == connection.origin
  133. end
  134. end
  135. 23 def create_idle(options = {})
  136. self.class.new(@origin, @options.merge(options))
  137. end
  138. 23 def merge(connection)
  139. 23 @origins |= connection.instance_variable_get(:@origins)
  140. 23 if connection.ssl_session
  141. 5 @ssl_session = connection.ssl_session
  142. @io.session_new_cb do |sess|
  143. 10 @ssl_session = sess
  144. 5 end if @io
  145. end
  146. 23 connection.purge_pending do |req|
  147. 6 send(req)
  148. end
  149. end
  150. 23 def purge_pending(&block)
  151. 23 pendings = []
  152. 23 if @parser
  153. 12 @inflight -= @parser.pending.size
  154. 12 pendings << @parser.pending
  155. end
  156. 23 pendings << @pending
  157. 23 pendings.each do |pending|
  158. 35 pending.reject!(&block)
  159. end
  160. end
  161. 23 def connecting?
  162. 3437236 @state == :idle
  163. end
  164. 23 def inflight?
  165. 2051 @parser && (
  166. # parser may be dealing with other requests (possibly started from a different fiber)
  167. 1632 !@parser.empty? ||
  168. # connection may be doing connection termination handshake
  169. !@write_buffer.empty?
  170. )
  171. end
  172. 23 def interests
  173. # connecting
  174. 3428984 if connecting?
  175. 8021 connect
  176. 8021 return @io.interests if connecting?
  177. end
  178. # if the write buffer is full, we drain it
  179. 3421423 return :w unless @write_buffer.empty?
  180. 3391835 return @parser.interests if @parser
  181. 5 nil
  182. rescue StandardError => e
  183. emit(:error, e)
  184. nil
  185. end
  186. 23 def to_io
  187. 16237 @io.to_io
  188. end
  189. 23 def call
  190. 15721 case @state
  191. when :idle
  192. 7442 connect
  193. 7430 consume
  194. when :closed
  195. return
  196. when :closing
  197. consume
  198. transition(:closed)
  199. when :open
  200. 8121 consume
  201. end
  202. 1995 nil
  203. rescue StandardError => e
  204. 17 emit(:error, e)
  205. 17 raise e
  206. end
  207. 23 def close
  208. 1917 transition(:active) if @state == :inactive
  209. 1917 @parser.close if @parser
  210. end
  211. 23 def terminate
  212. 1917 @connected_at = nil if @state == :closed
  213. 1917 close
  214. end
  215. # bypasses the state machine to force closing of connections still connecting.
  216. # **only** used for Happy Eyeballs v2.
  217. 23 def force_reset(cloned = false)
  218. 232 @state = :closing
  219. 232 @cloned = cloned
  220. 232 transition(:closed)
  221. end
  222. 23 def reset
  223. 5459 return if @state == :closing || @state == :closed
  224. 5422 transition(:closing)
  225. 5422 transition(:closed)
  226. end
  227. 23 def send(request)
  228. 6565 return @coalesced_connection.send(request) if @coalesced_connection
  229. 6560 if @parser && !@write_buffer.full?
  230. 311 if @response_received_at && @keep_alive_timeout &&
  231. Utils.elapsed_time(@response_received_at) > @keep_alive_timeout
  232. # when pushing a request into an existing connection, we have to check whether there
  233. # is the possibility that the connection might have extended the keep alive timeout.
  234. # for such cases, we want to ping for availability before deciding to shovel requests.
  235. 6 log(level: 3) { "keep alive timeout expired, pinging connection..." }
  236. 6 @pending << request
  237. 6 transition(:active) if @state == :inactive
  238. 6 parser.ping
  239. 6 return
  240. end
  241. 305 send_request_to_parser(request)
  242. else
  243. 6249 @pending << request
  244. end
  245. end
  246. 23 def timeout
  247. 3339581 return if @state == :closed || @state == :inactive
  248. 3339581 return @timeout if @timeout
  249. 3330689 return @options.timeout[:connect_timeout] if @state == :idle
  250. 3330689 @options.timeout[:operation_timeout]
  251. end
  252. 23 def idling
  253. 614 purge_after_closed
  254. 614 @write_buffer.clear
  255. 614 transition(:idle)
  256. 614 @parser = nil if @parser
  257. end
  258. 23 def used?
  259. 1821 @connected_at
  260. end
  261. 23 def deactivate
  262. 255 transition(:inactive)
  263. end
  264. 23 def open?
  265. 5158 @state == :open || @state == :inactive
  266. end
  267. 23 def handle_socket_timeout(interval)
  268. 334 @intervals.delete_if(&:elapsed?)
  269. 334 unless @intervals.empty?
  270. # remove the intervals which will elapse
  271. 310 return
  272. end
  273. 24 error = HTTPX::TimeoutError.new(interval, "timed out while waiting on select")
  274. 24 error.set_backtrace(caller)
  275. 24 on_error(error)
  276. end
  277. 23 private
  278. 23 def connect
  279. 14696 transition(:open)
  280. end
  281. 23 def disconnect
  282. 5540 emit(:close)
  283. 5528 @current_session = nil
  284. 5528 @current_selector = nil
  285. end
  286. 23 def consume
  287. 17746 return unless @io
  288. 17746 catch(:called) do
  289. 17746 epiped = false
  290. 17746 loop do
  291. # connection may have
  292. 32205 return if @state == :idle
  293. 29971 parser.consume
  294. # we exit if there's no more requests to process
  295. #
  296. # this condition takes into account:
  297. #
  298. # * the number of inflight requests
  299. # * the number of pending requests
  300. # * whether the write buffer has bytes (i.e. for close handshake)
  301. 29959 if @pending.empty? && @inflight.zero? && @write_buffer.empty?
  302. 1992 log(level: 3) { "NO MORE REQUESTS..." }
  303. 1980 return
  304. end
  305. 27979 @timeout = @current_timeout
  306. 27979 read_drained = false
  307. 27979 write_drained = nil
  308. #
  309. # tight read loop.
  310. #
  311. # read as much of the socket as possible.
  312. #
  313. # this tight loop reads all the data it can from the socket and pipes it to
  314. # its parser.
  315. #
  316. loop do
  317. 36184 siz = @io.read(@window_size, @read_buffer)
  318. 36272 log(level: 3, color: :cyan) { "IO READ: #{siz} bytes... (wsize: #{@window_size}, rbuffer: #{@read_buffer.bytesize})" }
  319. 36184 unless siz
  320. 14 ex = EOFError.new("descriptor closed")
  321. 14 ex.set_backtrace(caller)
  322. 14 on_error(ex)
  323. 14 return
  324. end
  325. # socket has been drained. mark and exit the read loop.
  326. 36170 if siz.zero?
  327. 7490 read_drained = @read_buffer.empty?
  328. 7490 epiped = false
  329. 7490 break
  330. end
  331. 28680 parser << @read_buffer.to_s
  332. # continue reading if possible.
  333. 25482 break if interests == :w && !epiped
  334. # exit the read loop if connection is preparing to be closed
  335. 20133 break if @state == :closing || @state == :closed
  336. # exit #consume altogether if all outstanding requests have been dealt with
  337. 20128 return if @pending.empty? && @inflight.zero?
  338. 27979 end unless ((ints = interests).nil? || ints == :w || @state == :closing) && !epiped
  339. #
  340. # tight write loop.
  341. #
  342. # flush as many bytes as the sockets allow.
  343. #
  344. loop do
  345. # buffer has been drainned, mark and exit the write loop.
  346. 17257 if @write_buffer.empty?
  347. # we only mark as drained on the first loop
  348. 2167 write_drained = write_drained.nil? && @inflight.positive?
  349. 2167 break
  350. end
  351. 1936 begin
  352. 15090 siz = @io.write(@write_buffer)
  353. rescue Errno::EPIPE
  354. # this can happen if we still have bytes in the buffer to send to the server, but
  355. # the server wants to respond immediately with some message, or an error. An example is
  356. # when one's uploading a big file to an unintended endpoint, and the server stops the
  357. # consumption, and responds immediately with an authorization of even method not allowed error.
  358. # at this point, we have to let the connection switch to read-mode.
  359. 21 log(level: 2) { "pipe broken, could not flush buffer..." }
  360. 21 epiped = true
  361. 21 read_drained = false
  362. 21 break
  363. end
  364. 15127 log(level: 3, color: :cyan) { "IO WRITE: #{siz} bytes..." }
  365. 15062 unless siz
  366. ex = EOFError.new("descriptor closed")
  367. ex.set_backtrace(caller)
  368. on_error(ex)
  369. return
  370. end
  371. # socket closed for writing. mark and exit the write loop.
  372. 15062 if siz.zero?
  373. 18 write_drained = !@write_buffer.empty?
  374. 18 break
  375. end
  376. # exit write loop if marked to consume from peer, or is closing.
  377. 15044 break if interests == :r || @state == :closing || @state == :closed
  378. 2136 write_drained = false
  379. 22604 end unless (ints = interests) == :r
  380. 22597 send_pending if @state == :open
  381. # return if socket is drained
  382. 22597 next unless (ints != :r || read_drained) && (ints != :w || write_drained)
  383. # gotta go back to the event loop. It happens when:
  384. #
  385. # * the socket is drained of bytes or it's not the interest of the conn to read;
  386. # * theres nothing more to write, or it's not in the interest of the conn to write;
  387. 8166 log(level: 3) { "(#{ints}): WAITING FOR EVENTS..." }
  388. 8138 return
  389. end
  390. end
  391. end
  392. 23 def send_pending
  393. 63304 while !@write_buffer.full? && (request = @pending.shift)
  394. 16481 send_request_to_parser(request)
  395. end
  396. end
  397. 23 def parser
  398. 81635 @parser ||= build_parser
  399. end
  400. 23 def send_request_to_parser(request)
  401. 16786 @inflight += 1
  402. 16786 request.peer_address = @io.ip
  403. 16786 parser.send(request)
  404. 16786 set_request_timeouts(request)
  405. 16786 return unless @state == :inactive
  406. 5 transition(:active)
  407. end
  408. 23 def build_parser(protocol = @io.protocol)
  409. 5325 parser = self.class.parser_type(protocol).new(@write_buffer, @options)
  410. 5325 set_parser_callbacks(parser)
  411. 5325 parser
  412. end
  413. 23 def set_parser_callbacks(parser)
  414. 5409 parser.on(:response) do |request, response|
  415. 5833 AltSvc.emit(request, response) do |alt_origin, origin, alt_params|
  416. 6 emit(:altsvc, alt_origin, origin, alt_params)
  417. end
  418. 5833 @response_received_at = Utils.now
  419. 5833 @inflight -= 1
  420. 5833 request.emit(:response, response)
  421. end
  422. 5409 parser.on(:altsvc) do |alt_origin, origin, alt_params|
  423. emit(:altsvc, alt_origin, origin, alt_params)
  424. end
  425. 5409 parser.on(:pong, &method(:send_pending))
  426. 5409 parser.on(:promise) do |request, stream|
  427. 18 request.emit(:promise, parser, stream)
  428. end
  429. 5409 parser.on(:exhausted) do
  430. 6 @exhausted = true
  431. 6 current_session = @current_session
  432. 6 current_selector = @current_selector
  433. 6 parser.close
  434. 6 @pending.concat(parser.pending)
  435. 6 case @state
  436. when :closed
  437. 6 idling
  438. 6 @exhausted = false
  439. 6 @current_session = current_session
  440. 6 @current_selector = current_selector
  441. when :closing
  442. once(:close) do
  443. idling
  444. @exhausted = false
  445. @current_session = current_session
  446. @current_selector = current_selector
  447. end
  448. end
  449. end
  450. 5409 parser.on(:origin) do |origin|
  451. @origins |= [origin]
  452. end
  453. 5409 parser.on(:close) do |force|
  454. 1939 if force
  455. 1939 reset
  456. 1933 emit(:terminate)
  457. end
  458. end
  459. 5409 parser.on(:close_handshake) do
  460. 6 consume
  461. end
  462. 5409 parser.on(:reset) do
  463. 2915 @pending.concat(parser.pending) unless parser.empty?
  464. 2915 current_session = @current_session
  465. 2915 current_selector = @current_selector
  466. 2915 reset
  467. 2909 unless @pending.empty?
  468. 154 idling
  469. 154 @current_session = current_session
  470. 154 @current_selector = current_selector
  471. end
  472. end
  473. 5409 parser.on(:current_timeout) do
  474. 2241 @current_timeout = @timeout = parser.timeout
  475. end
  476. 5409 parser.on(:timeout) do |tout|
  477. 1904 @timeout = tout
  478. end
  479. 5409 parser.on(:error) do |request, ex|
  480. 42 case ex
  481. when MisdirectedRequestError
  482. 6 current_session = @current_session
  483. 6 current_selector = @current_selector
  484. 6 parser.close
  485. 6 other_connection = current_session.find_connection(@origin, current_selector,
  486. @options.merge(ssl: { alpn_protocols: %w[http/1.1] }))
  487. 6 other_connection.merge(self)
  488. 6 request.transition(:idle)
  489. 6 other_connection.send(request)
  490. else
  491. 36 response = ErrorResponse.new(request, ex)
  492. 36 request.response = response
  493. 36 request.emit(:response, response)
  494. end
  495. end
  496. end
  497. 23 def transition(nextstate)
  498. 33116 handle_transition(nextstate)
  499. rescue Errno::ECONNABORTED,
  500. Errno::ECONNREFUSED,
  501. Errno::ECONNRESET,
  502. Errno::EADDRNOTAVAIL,
  503. Errno::EHOSTUNREACH,
  504. Errno::EINVAL,
  505. Errno::ENETUNREACH,
  506. Errno::EPIPE,
  507. Errno::ENOENT,
  508. SocketError,
  509. IOError => e
  510. # connect errors, exit gracefully
  511. 59 error = ConnectionError.new(e.message)
  512. 59 error.set_backtrace(e.backtrace)
  513. 59 connecting? && callbacks_for?(:connect_error) ? emit(:connect_error, error) : handle_error(error)
  514. 59 @state = :closed
  515. 59 disconnect
  516. rescue TLSError, ::HTTP2::Error::ProtocolError, ::HTTP2::Error::HandshakeError => e
  517. # connect errors, exit gracefully
  518. 19 handle_error(e)
  519. 19 connecting? && callbacks_for?(:connect_error) ? emit(:connect_error, e) : handle_error(e)
  520. 19 @state = :closed
  521. 19 disconnect
  522. end
  523. 23 def handle_transition(nextstate)
  524. 32736 case nextstate
  525. when :idle
  526. 5857 @timeout = @current_timeout = @options.timeout[:connect_timeout]
  527. 5857 @connected_at = nil
  528. when :open
  529. 14925 return if @state == :closed
  530. 14925 @io.connect
  531. 14848 emit(:tcp_open, self) if @io.state == :connected
  532. 14848 return unless @io.connected?
  533. 5330 @connected_at = Utils.now
  534. 5330 send_pending
  535. 5330 @timeout = @current_timeout = parser.timeout
  536. 5330 emit(:open)
  537. when :inactive
  538. 255 return unless @state == :open
  539. # do not deactivate connection in use
  540. 254 return if @inflight.positive?
  541. when :closing
  542. 5428 return unless @state == :idle || @state == :open
  543. 5428 unless @write_buffer.empty?
  544. # preset state before handshake, as error callbacks
  545. # may take it back here.
  546. 1929 @state = nextstate
  547. # handshakes, try sending
  548. 1929 consume
  549. 1928 @write_buffer.clear
  550. 1928 return
  551. end
  552. when :closed
  553. 5660 return unless @state == :closing
  554. 5659 return unless @write_buffer.empty?
  555. 5640 purge_after_closed
  556. 5640 disconnect if @pending.empty?
  557. when :already_open
  558. 50 nextstate = :open
  559. # the first check for given io readiness must still use a timeout.
  560. # connect is the reasonable choice in such a case.
  561. 50 @timeout = @options.timeout[:connect_timeout]
  562. 50 send_pending
  563. when :active
  564. 119 return unless @state == :inactive
  565. 119 nextstate = :open
  566. 119 emit(:activate)
  567. end
  568. 21162 @state = nextstate
  569. end
  570. 23 def purge_after_closed
  571. 6260 @io.close if @io
  572. 6260 @read_buffer.clear
  573. 6260 @timeout = nil
  574. end
  575. 23 def initialize_type(uri, options)
  576. 5004 options.transport || begin
  577. 4984 case uri.scheme
  578. when "http"
  579. 2871 "tcp"
  580. when "https"
  581. 2113 "ssl"
  582. else
  583. raise UnsupportedSchemeError, "#{uri}: #{uri.scheme}: unsupported URI scheme"
  584. end
  585. end
  586. end
  587. # returns an HTTPX::Connection for the negotiated Alternative Service (or none).
  588. 23 def build_altsvc_connection(alt_origin, origin, alt_params)
  589. # do not allow security downgrades on altsvc negotiation
  590. 6 return if @origin.scheme == "https" && alt_origin.scheme != "https"
  591. 6 altsvc = AltSvc.cached_altsvc_set(origin, alt_params.merge("origin" => alt_origin))
  592. # altsvc already exists, somehow it wasn't advertised, probably noop
  593. 6 return unless altsvc
  594. 6 alt_options = @options.merge(ssl: @options.ssl.merge(hostname: URI(origin).host))
  595. 6 connection = @current_session.find_connection(alt_origin, @current_selector, alt_options)
  596. # advertised altsvc is the same origin being used, ignore
  597. 6 return if connection == self
  598. 6 connection.extend(AltSvc::ConnectionMixin) unless connection.is_a?(AltSvc::ConnectionMixin)
  599. 6 log(level: 1) { "#{origin} alt-svc: #{alt_origin}" }
  600. 6 connection.merge(self)
  601. 6 terminate
  602. rescue UnsupportedSchemeError
  603. altsvc["noop"] = true
  604. nil
  605. end
  606. 23 def build_socket(addrs = nil)
  607. 4968 case @type
  608. when "tcp"
  609. 2916 TCP.new(peer, addrs, @options)
  610. when "ssl"
  611. 2032 SSL.new(peer, addrs, @options) do |sock|
  612. 2015 sock.ssl_session = @ssl_session
  613. 2015 sock.session_new_cb do |sess|
  614. 3382 @ssl_session = sess
  615. 3382 sock.ssl_session = sess
  616. end
  617. end
  618. when "unix"
  619. 20 path = Array(addrs).first
  620. 20 path = String(path) if path
  621. 20 UNIX.new(peer, path, @options)
  622. else
  623. raise Error, "unsupported transport (#{@type})"
  624. end
  625. end
  626. 23 def on_error(error, request = nil)
  627. 587 if error.instance_of?(TimeoutError)
  628. # inactive connections do not contribute to the select loop, therefore
  629. # they should not fail due to such errors.
  630. 24 return if @state == :inactive
  631. 24 if @timeout
  632. 24 @timeout -= error.timeout
  633. 24 return unless @timeout <= 0
  634. end
  635. 24 error = error.to_connection_error if connecting?
  636. end
  637. 587 handle_error(error, request)
  638. 587 reset
  639. end
  640. 23 def handle_error(error, request = nil)
  641. 684 parser.handle_error(error, request) if @parser && parser.respond_to?(:handle_error)
  642. 1559 while (req = @pending.shift)
  643. 320 next if request && req == request
  644. 320 response = ErrorResponse.new(req, error)
  645. 320 req.response = response
  646. 320 req.emit(:response, response)
  647. end
  648. 684 return unless request
  649. 307 response = ErrorResponse.new(request, error)
  650. 307 request.response = response
  651. 307 request.emit(:response, response)
  652. end
  653. 23 def set_request_timeouts(request)
  654. 16786 set_request_write_timeout(request)
  655. 16786 set_request_read_timeout(request)
  656. 16786 set_request_request_timeout(request)
  657. end
  658. 23 def set_request_read_timeout(request)
  659. 16786 read_timeout = request.read_timeout
  660. 16786 return if read_timeout.nil? || read_timeout.infinite?
  661. 16561 set_request_timeout(request, read_timeout, :done, :response) do
  662. 18 read_timeout_callback(request, read_timeout)
  663. end
  664. end
  665. 23 def set_request_write_timeout(request)
  666. 16786 write_timeout = request.write_timeout
  667. 16786 return if write_timeout.nil? || write_timeout.infinite?
  668. 16786 set_request_timeout(request, write_timeout, :headers, %i[done response]) do
  669. 18 write_timeout_callback(request, write_timeout)
  670. end
  671. end
  672. 23 def set_request_request_timeout(request)
  673. 16572 request_timeout = request.request_timeout
  674. 16572 return if request_timeout.nil? || request_timeout.infinite?
  675. 384 set_request_timeout(request, request_timeout, :headers, :complete) do
  676. 271 read_timeout_callback(request, request_timeout, RequestTimeoutError)
  677. end
  678. end
  679. 23 def write_timeout_callback(request, write_timeout)
  680. 18 return if request.state == :done
  681. 18 @write_buffer.clear
  682. 18 error = WriteTimeoutError.new(request, nil, write_timeout)
  683. 18 on_error(error, request)
  684. end
  685. 23 def read_timeout_callback(request, read_timeout, error_type = ReadTimeoutError)
  686. 289 response = request.response
  687. 289 return if response && response.finished?
  688. 289 @write_buffer.clear
  689. 289 error = error_type.new(request, request.response, read_timeout)
  690. 289 on_error(error, request)
  691. end
  692. 23 def set_request_timeout(request, timeout, start_event, finish_events, &callback)
  693. 33791 request.once(start_event) do
  694. 33237 interval = @current_selector.after(timeout, callback)
  695. 33237 Array(finish_events).each do |event|
  696. # clean up request timeouts if the connection errors out
  697. 49821 request.once(event) do
  698. 49707 if @intervals.include?(interval)
  699. 49288 interval.delete(callback)
  700. 49288 @intervals.delete(interval) if interval.no_callbacks?
  701. end
  702. end
  703. end
  704. 33237 @intervals << interval
  705. end
  706. end
  707. 23 class << self
  708. 23 def parser_type(protocol)
  709. 5435 case protocol
  710. 2246 when "h2" then HTTP2
  711. 3189 when "http/1.1" then HTTP1
  712. else
  713. raise Error, "unsupported protocol (##{protocol})"
  714. end
  715. end
  716. end
  717. end
  718. end

lib/httpx/connection/http1.rb

89.19% lines covered

222 relevant lines. 198 lines covered and 24 lines missed.
    
  1. # frozen_string_literal: true
  2. 23 require "httpx/parser/http1"
  3. 23 module HTTPX
  4. 23 class Connection::HTTP1
  5. 23 include Callbacks
  6. 23 include Loggable
  7. 23 MAX_REQUESTS = 200
  8. 23 CRLF = "\r\n"
  9. 23 attr_reader :pending, :requests
  10. 23 attr_accessor :max_concurrent_requests
  11. 23 def initialize(buffer, options)
  12. 3189 @options = options
  13. 3189 @max_concurrent_requests = @options.max_concurrent_requests || MAX_REQUESTS
  14. 3189 @max_requests = @options.max_requests
  15. 3189 @parser = Parser::HTTP1.new(self)
  16. 3189 @buffer = buffer
  17. 3189 @version = [1, 1]
  18. 3189 @pending = []
  19. 3189 @requests = []
  20. 3189 @handshake_completed = false
  21. end
  22. 23 def timeout
  23. 3100 @options.timeout[:operation_timeout]
  24. end
  25. 23 def interests
  26. # this means we're processing incoming response already
  27. 23823 return :r if @request
  28. 19741 return if @requests.empty?
  29. 19723 request = @requests.first
  30. 19723 return unless request
  31. 19723 return :w if request.interests == :w || !@buffer.empty?
  32. 17173 :r
  33. end
  34. 23 def reset
  35. 3014 @max_requests = @options.max_requests || MAX_REQUESTS
  36. 3014 @parser.reset!
  37. 3014 @handshake_completed = false
  38. 3014 @pending.concat(@requests) unless @requests.empty?
  39. end
  40. 23 def close
  41. 63 reset
  42. 63 emit(:close, true)
  43. end
  44. 23 def exhausted?
  45. 515 !@max_requests.positive?
  46. end
  47. 23 def empty?
  48. # this means that for every request there's an available
  49. # partial response, so there are no in-flight requests waiting.
  50. 2952 @requests.empty? || (
  51. # checking all responses can be time-consuming. Alas, as in HTTP/1, responses
  52. # do not come out of order, we can get away with checking first and last.
  53. 157 !@requests.first.response.nil? &&
  54. (@requests.size == 1 || !@requests.last.response.nil?)
  55. )
  56. end
  57. 23 def <<(data)
  58. 5380 @parser << data
  59. end
  60. 23 def send(request)
  61. 14249 unless @max_requests.positive?
  62. @pending << request
  63. return
  64. end
  65. 14249 return if @requests.include?(request)
  66. 14249 @requests << request
  67. 14249 @pipelining = true if @requests.size > 1
  68. end
  69. 23 def consume
  70. 12262 requests_limit = [@max_requests, @requests.size].min
  71. 12262 concurrent_requests_limit = [@max_concurrent_requests, requests_limit].min
  72. 12262 @requests.each_with_index do |request, idx|
  73. 14740 break if idx >= concurrent_requests_limit
  74. 12212 next if request.state == :done
  75. 4577 handle(request)
  76. end
  77. end
  78. # HTTP Parser callbacks
  79. #
  80. # must be public methods, or else they won't be reachable
  81. 23 def on_start
  82. 3594 log(level: 2) { "parsing begins" }
  83. end
  84. 23 def on_headers(h)
  85. 3576 @request = @requests.first
  86. 3576 return if @request.response
  87. 3594 log(level: 2) { "headers received" }
  88. 3576 headers = @request.options.headers_class.new(h)
  89. 3576 response = @request.options.response_class.new(@request,
  90. @parser.status_code,
  91. @parser.http_version.join("."),
  92. headers)
  93. 3594 log(color: :yellow) { "-> HEADLINE: #{response.status} HTTP/#{@parser.http_version.join(".")}" }
  94. 3738 log(color: :yellow) { response.headers.each.map { |f, v| "-> HEADER: #{f}: #{v}" }.join("\n") }
  95. 3576 @request.response = response
  96. 3570 on_complete if response.finished?
  97. end
  98. 23 def on_trailers(h)
  99. 6 return unless @request
  100. 6 response = @request.response
  101. 6 log(level: 2) { "trailer headers received" }
  102. 6 log(color: :yellow) { h.each.map { |f, v| "-> HEADER: #{f}: #{v.join(", ")}" }.join("\n") }
  103. 6 response.merge_headers(h)
  104. end
  105. 23 def on_data(chunk)
  106. 4106 request = @request
  107. 4106 return unless request
  108. 4124 log(color: :green) { "-> DATA: #{chunk.bytesize} bytes..." }
  109. 4124 log(level: 2, color: :green) { "-> #{chunk.inspect}" }
  110. 4106 response = request.response
  111. 4106 response << chunk
  112. rescue StandardError => e
  113. 11 error_response = ErrorResponse.new(request, e)
  114. 11 request.response = error_response
  115. 11 dispatch
  116. end
  117. 23 def on_complete
  118. 3553 request = @request
  119. 3553 return unless request
  120. 3571 log(level: 2) { "parsing complete" }
  121. 3553 dispatch
  122. end
  123. 23 def dispatch
  124. 3564 request = @request
  125. 3564 if request.expects?
  126. 54 @parser.reset!
  127. 54 return handle(request)
  128. end
  129. 3510 @request = nil
  130. 3510 @requests.shift
  131. 3510 response = request.response
  132. 3510 response.finish! unless response.is_a?(ErrorResponse)
  133. 3510 emit(:response, request, response)
  134. 3466 if @parser.upgrade?
  135. 24 response << @parser.upgrade_data
  136. 24 throw(:called)
  137. end
  138. 3442 @parser.reset!
  139. 3442 @max_requests -= 1
  140. 3442 if response.is_a?(ErrorResponse)
  141. 11 disable
  142. else
  143. 3431 manage_connection(request, response)
  144. end
  145. 515 if exhausted?
  146. @pending.concat(@requests)
  147. @requests.clear
  148. emit(:exhausted)
  149. else
  150. 515 send(@pending.shift) unless @pending.empty?
  151. end
  152. end
  153. 23 def handle_error(ex, request = nil)
  154. 165 if (ex.is_a?(EOFError) || ex.is_a?(TimeoutError)) && @request && @request.response &&
  155. !@request.response.headers.key?("content-length") &&
  156. !@request.response.headers.key?("transfer-encoding")
  157. # if the response does not contain a content-length header, the server closing the
  158. # connnection is the indicator of response consumed.
  159. # https://greenbytes.de/tech/webdav/rfc2616.html#rfc.section.4.4
  160. 12 catch(:called) { on_complete }
  161. 6 return
  162. end
  163. 159 if @pipelining
  164. catch(:called) { disable }
  165. else
  166. 159 @requests.each do |req|
  167. 145 next if request && request == req
  168. emit(:error, req, ex)
  169. end
  170. 159 @pending.each do |req|
  171. next if request && request == req
  172. emit(:error, req, ex)
  173. end
  174. end
  175. end
  176. 23 def ping
  177. reset
  178. emit(:reset)
  179. emit(:exhausted)
  180. end
  181. 23 private
  182. 23 def manage_connection(request, response)
  183. 3431 connection = response.headers["connection"]
  184. 3431 case connection
  185. when /keep-alive/i
  186. 515 if @handshake_completed
  187. if @max_requests.zero?
  188. @pending.concat(@requests)
  189. @requests.clear
  190. emit(:exhausted)
  191. end
  192. return
  193. end
  194. 515 keep_alive = response.headers["keep-alive"]
  195. 515 return unless keep_alive
  196. 106 parameters = Hash[keep_alive.split(/ *, */).map do |pair|
  197. 106 pair.split(/ *= */, 2)
  198. end]
  199. 106 @max_requests = parameters["max"].to_i - 1 if parameters.key?("max")
  200. 106 if parameters.key?("timeout")
  201. keep_alive_timeout = parameters["timeout"].to_i
  202. emit(:timeout, keep_alive_timeout)
  203. end
  204. 106 @handshake_completed = true
  205. when /close/i
  206. 2916 disable
  207. when nil
  208. # In HTTP/1.1, it's keep alive by default
  209. return if response.version == "1.1" && request.headers["connection"] != "close"
  210. disable
  211. end
  212. end
  213. 23 def disable
  214. 2927 disable_pipelining
  215. 2927 reset
  216. 2927 emit(:reset)
  217. 2921 throw(:called)
  218. end
  219. 23 def disable_pipelining
  220. 2927 return if @requests.empty?
  221. # do not disable pipelining if already set to 1 request at a time
  222. 160 return if @max_concurrent_requests == 1
  223. 16 @requests.each do |r|
  224. 16 r.transition(:idle)
  225. # when we disable pipelining, we still want to try keep-alive.
  226. # only when keep-alive with one request fails, do we fallback to
  227. # connection: close.
  228. 16 r.headers["connection"] = "close" if @max_concurrent_requests == 1
  229. end
  230. # server doesn't handle pipelining, and probably
  231. # doesn't support keep-alive. Fallback to send only
  232. # 1 keep alive request.
  233. 16 @max_concurrent_requests = 1
  234. 16 @pipelining = false
  235. end
  236. 23 def set_protocol_headers(request)
  237. 3689 if !request.headers.key?("content-length") &&
  238. request.body.bytesize == Float::INFINITY
  239. 24 request.body.chunk!
  240. end
  241. 3689 extra_headers = {}
  242. 3689 unless request.headers.key?("connection")
  243. 3671 connection_value = if request.persistent?
  244. # when in a persistent connection, the request can't be at
  245. # the edge of a renegotiation
  246. 43 if @requests.index(request) + 1 < @max_requests
  247. 43 "keep-alive"
  248. else
  249. "close"
  250. end
  251. else
  252. # when it's not a persistent connection, it sets "Connection: close" always
  253. # on the last request of the possible batch (either allowed max requests,
  254. # or if smaller, the size of the batch itself)
  255. 3628 requests_limit = [@max_requests, @requests.size].min
  256. 3628 if request == @requests[requests_limit - 1]
  257. 3086 "close"
  258. else
  259. 542 "keep-alive"
  260. end
  261. end
  262. 3671 extra_headers["connection"] = connection_value
  263. end
  264. 3689 extra_headers["host"] = request.authority unless request.headers.key?("host")
  265. 3689 extra_headers
  266. end
  267. 23 def handle(request)
  268. 4631 catch(:buffer_full) do
  269. 4631 request.transition(:headers)
  270. 4625 join_headers(request) if request.state == :headers
  271. 4625 request.transition(:body)
  272. 4625 join_body(request) if request.state == :body
  273. 3823 request.transition(:trailers)
  274. # HTTP/1.1 trailers should only work for chunked encoding
  275. 3823 join_trailers(request) if request.body.chunked? && request.state == :trailers
  276. 3823 request.transition(:done)
  277. end
  278. end
  279. 23 def join_headline(request)
  280. 3629 "#{request.verb} #{request.path} HTTP/#{@version.join(".")}"
  281. end
  282. 23 def join_headers(request)
  283. 3689 headline = join_headline(request)
  284. 3689 @buffer << headline << CRLF
  285. 3707 log(color: :yellow) { "<- HEADLINE: #{headline.chomp.inspect}" }
  286. 3689 extra_headers = set_protocol_headers(request)
  287. 3689 join_headers2(request.headers.each(extra_headers))
  288. 3707 log { "<- " }
  289. 3689 @buffer << CRLF
  290. end
  291. 23 def join_body(request)
  292. 4473 return if request.body.empty?
  293. 4918 while (chunk = request.drain_body)
  294. 2589 log(color: :green) { "<- DATA: #{chunk.bytesize} bytes..." }
  295. 2589 log(level: 2, color: :green) { "<- #{chunk.inspect}" }
  296. 2589 @buffer << chunk
  297. 2589 throw(:buffer_full, request) if @buffer.full?
  298. end
  299. 1141 return unless (error = request.drain_error)
  300. raise error
  301. end
  302. 23 def join_trailers(request)
  303. 72 return unless request.trailers? && request.callbacks_for?(:trailers)
  304. 24 join_headers2(request.trailers)
  305. 24 log { "<- " }
  306. 24 @buffer << CRLF
  307. end
  308. 23 def join_headers2(headers)
  309. 3713 headers.each do |field, value|
  310. 22116 buffer = "#{capitalized(field)}: #{value}#{CRLF}"
  311. 22206 log(color: :yellow) { "<- HEADER: #{buffer.chomp}" }
  312. 22116 @buffer << buffer
  313. end
  314. end
  315. 23 UPCASED = {
  316. "www-authenticate" => "WWW-Authenticate",
  317. "http2-settings" => "HTTP2-Settings",
  318. "content-md5" => "Content-MD5",
  319. }.freeze
  320. 23 def capitalized(field)
  321. 22116 UPCASED[field] || field.split("-").map(&:capitalize).join("-")
  322. end
  323. end
  324. end

lib/httpx/connection/http2.rb

95.18% lines covered

249 relevant lines. 237 lines covered and 12 lines missed.
    
  1. # frozen_string_literal: true
  2. 23 require "securerandom"
  3. 23 require "http/2"
  4. 23 module HTTPX
  5. 23 class Connection::HTTP2
  6. 23 include Callbacks
  7. 23 include Loggable
  8. 23 MAX_CONCURRENT_REQUESTS = ::HTTP2::DEFAULT_MAX_CONCURRENT_STREAMS
  9. 23 class Error < Error
  10. 23 def initialize(id, code)
  11. 26 super("stream #{id} closed with error: #{code}")
  12. end
  13. end
  14. 23 class GoawayError < Error
  15. 23 def initialize
  16. 11 super(0, :no_error)
  17. end
  18. end
  19. 23 attr_reader :streams, :pending
  20. 23 def initialize(buffer, options)
  21. 2264 @options = options
  22. 2264 @settings = @options.http2_settings
  23. 2264 @pending = []
  24. 2264 @streams = {}
  25. 2264 @drains = {}
  26. 2264 @pings = []
  27. 2264 @buffer = buffer
  28. 2264 @handshake_completed = false
  29. 2264 @wait_for_handshake = @settings.key?(:wait_for_handshake) ? @settings.delete(:wait_for_handshake) : true
  30. 2264 @max_concurrent_requests = @options.max_concurrent_requests || MAX_CONCURRENT_REQUESTS
  31. 2264 @max_requests = @options.max_requests
  32. 2264 init_connection
  33. end
  34. 23 def timeout
  35. 4471 return @options.timeout[:operation_timeout] if @handshake_completed
  36. 2230 @options.timeout[:settings_timeout]
  37. end
  38. 23 def interests
  39. # waiting for WINDOW_UPDATE frames
  40. 3367952 return :r if @buffer.full?
  41. 3367952 if @connection.state == :closed
  42. 2047 return unless @handshake_completed
  43. 2019 return :w
  44. end
  45. 3365905 unless @connection.state == :connected && @handshake_completed
  46. 9527 return @buffer.empty? ? :r : :rw
  47. end
  48. 3356378 return :w if !@pending.empty? && can_buffer_more_requests?
  49. 3356378 return :w unless @drains.empty?
  50. 3355730 if @buffer.empty?
  51. 3355730 return if @streams.empty? && @pings.empty?
  52. 30042 return :r
  53. end
  54. :rw
  55. end
  56. 23 def close
  57. 1904 unless @connection.state == :closed
  58. 1904 @connection.goaway
  59. 1904 emit(:timeout, @options.timeout[:close_handshake_timeout])
  60. end
  61. 1904 emit(:close, true)
  62. end
  63. 23 def empty?
  64. 1905 @connection.state == :closed || @streams.empty?
  65. end
  66. 23 def exhausted?
  67. 2266 !@max_requests.positive?
  68. end
  69. 23 def <<(data)
  70. 23072 @connection << data
  71. end
  72. 23 def can_buffer_more_requests?
  73. 5306 (@handshake_completed || !@wait_for_handshake) &&
  74. @streams.size < @max_concurrent_requests &&
  75. @streams.size < @max_requests
  76. end
  77. 23 def send(request, head = false)
  78. 4966 unless can_buffer_more_requests?
  79. 2409 head ? @pending.unshift(request) : @pending << request
  80. 2409 return false
  81. end
  82. 2557 unless (stream = @streams[request])
  83. 2557 stream = @connection.new_stream
  84. 2557 handle_stream(stream, request)
  85. 2557 @streams[request] = stream
  86. 2557 @max_requests -= 1
  87. end
  88. 2557 handle(request, stream)
  89. 2545 true
  90. rescue ::HTTP2::Error::StreamLimitExceeded
  91. @pending.unshift(request)
  92. false
  93. end
  94. 23 def consume
  95. 17119 @streams.each do |request, stream|
  96. 6549 next if request.state == :done
  97. 738 handle(request, stream)
  98. end
  99. end
  100. 23 def handle_error(ex, request = nil)
  101. 197 if ex.instance_of?(TimeoutError) && !@handshake_completed && @connection.state != :closed
  102. 6 @connection.goaway(:settings_timeout, "closing due to settings timeout")
  103. 6 emit(:close_handshake)
  104. 6 settings_ex = SettingsTimeoutError.new(ex.timeout, ex.message)
  105. 6 settings_ex.set_backtrace(ex.backtrace)
  106. 6 ex = settings_ex
  107. end
  108. 197 @streams.each_key do |req|
  109. 161 next if request && request == req
  110. 11 emit(:error, req, ex)
  111. end
  112. 197 @pending.each do |req|
  113. 25 next if request && request == req
  114. 25 emit(:error, req, ex)
  115. end
  116. end
  117. 23 def ping
  118. 6 ping = SecureRandom.gen_random(8)
  119. 6 @connection.ping(ping)
  120. ensure
  121. 6 @pings << ping
  122. end
  123. 23 private
  124. 23 def send_pending
  125. 6074 while (request = @pending.shift)
  126. 2318 break unless send(request, true)
  127. end
  128. end
  129. 23 def handle(request, stream)
  130. 3343 catch(:buffer_full) do
  131. 3343 request.transition(:headers)
  132. 3337 join_headers(stream, request) if request.state == :headers
  133. 3337 request.transition(:body)
  134. 3337 join_body(stream, request) if request.state == :body
  135. 2683 request.transition(:trailers)
  136. 2683 join_trailers(stream, request) if request.state == :trailers && !request.body.empty?
  137. 2683 request.transition(:done)
  138. end
  139. end
  140. 23 def init_connection
  141. 2264 @connection = ::HTTP2::Client.new(@settings)
  142. 2264 @connection.on(:frame, &method(:on_frame))
  143. 2264 @connection.on(:frame_sent, &method(:on_frame_sent))
  144. 2264 @connection.on(:frame_received, &method(:on_frame_received))
  145. 2264 @connection.on(:origin, &method(:on_origin))
  146. 2264 @connection.on(:promise, &method(:on_promise))
  147. 2264 @connection.on(:altsvc) { |frame| on_altsvc(frame[:origin], frame) }
  148. 2264 @connection.on(:settings_ack, &method(:on_settings))
  149. 2264 @connection.on(:ack, &method(:on_pong))
  150. 2264 @connection.on(:goaway, &method(:on_close))
  151. #
  152. # Some servers initiate HTTP/2 negotiation right away, some don't.
  153. # As such, we have to check the socket buffer. If there is something
  154. # to read, the server initiated the negotiation. If not, we have to
  155. # initiate it.
  156. #
  157. 2264 @connection.send_connection_preface
  158. end
  159. 23 alias_method :reset, :init_connection
  160. 23 public :reset
  161. 23 def handle_stream(stream, request)
  162. 2569 request.on(:refuse, &method(:on_stream_refuse).curry(3)[stream, request])
  163. 2569 stream.on(:close, &method(:on_stream_close).curry(3)[stream, request])
  164. 2569 stream.on(:half_close) do
  165. 2553 log(level: 2) { "#{stream.id}: waiting for response..." }
  166. end
  167. 2569 stream.on(:altsvc, &method(:on_altsvc).curry(2)[request.origin])
  168. 2569 stream.on(:headers, &method(:on_stream_headers).curry(3)[stream, request])
  169. 2569 stream.on(:data, &method(:on_stream_data).curry(3)[stream, request])
  170. end
  171. 23 def set_protocol_headers(request)
  172. {
  173. 2550 ":scheme" => request.scheme,
  174. ":method" => request.verb,
  175. ":path" => request.path,
  176. ":authority" => request.authority,
  177. }
  178. end
  179. 23 def join_headers(stream, request)
  180. 2551 extra_headers = set_protocol_headers(request)
  181. 2551 if request.headers.key?("host")
  182. 6 log { "forbidden \"host\" header found (#{request.headers["host"]}), will use it as authority..." }
  183. 6 extra_headers[":authority"] = request.headers["host"]
  184. end
  185. 2551 log(level: 1, color: :yellow) do
  186. 108 request.headers.merge(extra_headers).each.map { |k, v| "#{stream.id}: -> HEADER: #{k}: #{v}" }.join("\n")
  187. end
  188. 2551 stream.headers(request.headers.each(extra_headers), end_stream: request.body.empty?)
  189. end
  190. 23 def join_trailers(stream, request)
  191. 1054 unless request.trailers?
  192. 1048 stream.data("", end_stream: true) if request.callbacks_for?(:trailers)
  193. 1048 return
  194. end
  195. 6 log(level: 1, color: :yellow) do
  196. 12 request.trailers.each.map { |k, v| "#{stream.id}: -> HEADER: #{k}: #{v}" }.join("\n")
  197. end
  198. 6 stream.headers(request.trailers.each, end_stream: true)
  199. end
  200. 23 def join_body(stream, request)
  201. 3205 return if request.body.empty?
  202. 1708 chunk = @drains.delete(request) || request.drain_body
  203. 1708 while chunk
  204. 2016 next_chunk = request.drain_body
  205. 2034 log(level: 1, color: :green) { "#{stream.id}: -> DATA: #{chunk.bytesize} bytes..." }
  206. 2034 log(level: 2, color: :green) { "#{stream.id}: -> #{chunk.inspect}" }
  207. 2016 stream.data(chunk, end_stream: !(next_chunk || request.trailers? || request.callbacks_for?(:trailers)))
  208. 2016 if next_chunk && (@buffer.full? || request.body.unbounded_body?)
  209. 654 @drains[request] = next_chunk
  210. 654 throw(:buffer_full)
  211. end
  212. 1362 chunk = next_chunk
  213. end
  214. 1054 return unless (error = request.drain_error)
  215. 10 on_stream_refuse(stream, request, error)
  216. end
  217. ######
  218. # HTTP/2 Callbacks
  219. ######
  220. 23 def on_stream_headers(stream, request, h)
  221. 2546 response = request.response
  222. 2546 if response.is_a?(Response) && response.version == "2.0"
  223. 95 on_stream_trailers(stream, response, h)
  224. 95 return
  225. end
  226. 2451 log(color: :yellow) do
  227. 108 h.map { |k, v| "#{stream.id}: <- HEADER: #{k}: #{v}" }.join("\n")
  228. end
  229. 2451 _, status = h.shift
  230. 2451 headers = request.options.headers_class.new(h)
  231. 2451 response = request.options.response_class.new(request, status, "2.0", headers)
  232. 2451 request.response = response
  233. 2445 @streams[request] = stream
  234. 2445 handle(request, stream) if request.expects?
  235. end
  236. 23 def on_stream_trailers(stream, response, h)
  237. 95 log(color: :yellow) do
  238. h.map { |k, v| "#{stream.id}: <- HEADER: #{k}: #{v}" }.join("\n")
  239. end
  240. 95 response.merge_headers(h)
  241. end
  242. 23 def on_stream_data(stream, request, data)
  243. 4505 log(level: 1, color: :green) { "#{stream.id}: <- DATA: #{data.bytesize} bytes..." }
  244. 4505 log(level: 2, color: :green) { "#{stream.id}: <- #{data.inspect}" }
  245. 4488 request.response << data
  246. end
  247. 23 def on_stream_refuse(stream, request, error)
  248. 10 on_stream_close(stream, request, error)
  249. 10 stream.close
  250. end
  251. 23 def on_stream_close(stream, request, error)
  252. 2389 return if error == :stream_closed && !@streams.key?(request)
  253. 2391 log(level: 2) { "#{stream.id}: closing stream" }
  254. 2379 @drains.delete(request)
  255. 2379 @streams.delete(request)
  256. 2379 if error
  257. 10 ex = Error.new(stream.id, error)
  258. 10 ex.set_backtrace(caller)
  259. 10 response = ErrorResponse.new(request, ex)
  260. 10 request.response = response
  261. 10 emit(:response, request, response)
  262. else
  263. 2369 response = request.response
  264. 2369 if response && response.is_a?(Response) && response.status == 421
  265. 6 ex = MisdirectedRequestError.new(response)
  266. 6 ex.set_backtrace(caller)
  267. 6 emit(:error, request, ex)
  268. else
  269. 2363 emit(:response, request, response)
  270. end
  271. end
  272. 2373 send(@pending.shift) unless @pending.empty?
  273. 2373 return unless @streams.empty? && exhausted?
  274. 6 if @pending.empty?
  275. close
  276. else
  277. 6 emit(:exhausted)
  278. end
  279. end
  280. 23 def on_frame(bytes)
  281. 14148 @buffer << bytes
  282. end
  283. 23 def on_settings(*)
  284. 2241 @handshake_completed = true
  285. 2241 emit(:current_timeout)
  286. 2241 @max_concurrent_requests = [@max_concurrent_requests, @connection.remote_settings[:settings_max_concurrent_streams]].min
  287. 2241 send_pending
  288. end
  289. 23 def on_close(_last_frame, error, _payload)
  290. 16 is_connection_closed = @connection.state == :closed
  291. 16 if error
  292. 16 @buffer.clear if is_connection_closed
  293. 16 if error == :no_error
  294. 11 ex = GoawayError.new
  295. 11 @pending.unshift(*@streams.keys)
  296. 11 @drains.clear
  297. 11 @streams.clear
  298. else
  299. 5 ex = Error.new(0, error)
  300. end
  301. 16 ex.set_backtrace(caller)
  302. 16 handle_error(ex)
  303. end
  304. 16 return unless is_connection_closed && @streams.empty?
  305. 16 emit(:close, is_connection_closed)
  306. end
  307. 23 def on_frame_sent(frame)
  308. 11948 log(level: 2) { "#{frame[:stream]}: frame was sent!" }
  309. 11876 log(level: 2, color: :blue) do
  310. 72 payload = frame
  311. 72 payload = payload.merge(payload: frame[:payload].bytesize) if frame[:type] == :data
  312. 72 "#{frame[:stream]}: #{payload}"
  313. end
  314. end
  315. 23 def on_frame_received(frame)
  316. 12590 log(level: 2) { "#{frame[:stream]}: frame was received!" }
  317. 12537 log(level: 2, color: :magenta) do
  318. 53 payload = frame
  319. 53 payload = payload.merge(payload: frame[:payload].bytesize) if frame[:type] == :data
  320. 53 "#{frame[:stream]}: #{payload}"
  321. end
  322. end
  323. 23 def on_altsvc(origin, frame)
  324. log(level: 2) { "#{frame[:stream]}: altsvc frame was received" }
  325. log(level: 2) { "#{frame[:stream]}: #{frame.inspect}" }
  326. alt_origin = URI.parse("#{frame[:proto]}://#{frame[:host]}:#{frame[:port]}")
  327. params = { "ma" => frame[:max_age] }
  328. emit(:altsvc, origin, alt_origin, origin, params)
  329. end
  330. 23 def on_promise(stream)
  331. 18 emit(:promise, @streams.key(stream.parent), stream)
  332. end
  333. 23 def on_origin(origin)
  334. emit(:origin, origin)
  335. end
  336. 23 def on_pong(ping)
  337. 6 if @pings.delete(ping.to_s)
  338. 6 emit(:pong)
  339. else
  340. close(:protocol_error, "ping payload did not match")
  341. end
  342. end
  343. end
  344. end

lib/httpx/domain_name.rb

95.56% lines covered

45 relevant lines. 43 lines covered and 2 lines missed.
    
  1. # frozen_string_literal: true
  2. #
  3. # domain_name.rb - Domain Name manipulation library for Ruby
  4. #
  5. # Copyright (C) 2011-2017 Akinori MUSHA, All rights reserved.
  6. #
  7. # Redistribution and use in source and binary forms, with or without
  8. # modification, are permitted provided that the following conditions
  9. # are met:
  10. # 1. Redistributions of source code must retain the above copyright
  11. # notice, this list of conditions and the following disclaimer.
  12. # 2. Redistributions in binary form must reproduce the above copyright
  13. # notice, this list of conditions and the following disclaimer in the
  14. # documentation and/or other materials provided with the distribution.
  15. #
  16. # THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS ``AS IS'' AND
  17. # ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
  18. # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
  19. # ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE
  20. # FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
  21. # DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
  22. # OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
  23. # HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
  24. # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
  25. # OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
  26. # SUCH DAMAGE.
  27. 23 require "ipaddr"
  28. 23 module HTTPX
  29. # Represents a domain name ready for extracting its registered domain
  30. # and TLD.
  31. 23 class DomainName
  32. 23 include Comparable
  33. # The full host name normalized, ASCII-ized and downcased using the
  34. # Unicode NFC rules and the Punycode algorithm. If initialized with
  35. # an IP address, the string representation of the IP address
  36. # suitable for opening a connection to.
  37. 23 attr_reader :hostname
  38. # The Unicode representation of the #hostname property.
  39. #
  40. # :attr_reader: hostname_idn
  41. # The least "universally original" domain part of this domain name.
  42. # For example, "example.co.uk" for "www.sub.example.co.uk". This
  43. # may be nil if the hostname does not have one, like when it is an
  44. # IP address, an effective TLD or higher itself, or of a
  45. # non-canonical domain.
  46. 23 attr_reader :domain
  47. 23 class << self
  48. 23 def new(domain)
  49. 642 return domain if domain.is_a?(self)
  50. 594 super(domain)
  51. end
  52. # Normalizes a _domain_ using the Punycode algorithm as necessary.
  53. # The result will be a downcased, ASCII-only string.
  54. 23 def normalize(domain)
  55. 570 unless domain.ascii_only?
  56. domain = domain.chomp(".").unicode_normalize(:nfc)
  57. domain = Punycode.encode_hostname(domain)
  58. end
  59. 570 domain.downcase
  60. end
  61. end
  62. # Parses _hostname_ into a DomainName object. An IP address is also
  63. # accepted. An IPv6 address may be enclosed in square brackets.
  64. 23 def initialize(hostname)
  65. 594 hostname = String(hostname)
  66. 594 raise ArgumentError, "domain name must not start with a dot: #{hostname}" if hostname.start_with?(".")
  67. 98 begin
  68. 594 @ipaddr = IPAddr.new(hostname)
  69. 24 @hostname = @ipaddr.to_s
  70. 24 return
  71. rescue IPAddr::Error
  72. 570 nil
  73. end
  74. 570 @hostname = DomainName.normalize(hostname)
  75. 570 tld = if (last_dot = @hostname.rindex("."))
  76. 138 @hostname[(last_dot + 1)..-1]
  77. else
  78. 432 @hostname
  79. end
  80. # unknown/local TLD
  81. 570 @domain = if last_dot
  82. # fallback - accept cookies down to second level
  83. # cf. http://www.dkim-reputation.org/regdom-libs/
  84. 138 if (penultimate_dot = @hostname.rindex(".", last_dot - 1))
  85. 36 @hostname[(penultimate_dot + 1)..-1]
  86. else
  87. 102 @hostname
  88. end
  89. else
  90. # no domain part - must be a local hostname
  91. 432 tld
  92. end
  93. end
  94. # Checks if the server represented by this domain is qualified to
  95. # send and receive cookies with a domain attribute value of
  96. # _domain_. A true value given as the second argument represents
  97. # cookies without a domain attribute value, in which case only
  98. # hostname equality is checked.
  99. 23 def cookie_domain?(domain, host_only = false)
  100. # RFC 6265 #5.3
  101. # When the user agent "receives a cookie":
  102. 24 return self == @domain if host_only
  103. 24 domain = DomainName.new(domain)
  104. # RFC 6265 #5.1.3
  105. # Do not perform subdomain matching against IP addresses.
  106. 24 @hostname == domain.hostname if @ipaddr
  107. # RFC 6265 #4.1.1
  108. # Domain-value must be a subdomain.
  109. 24 @domain && self <= domain && domain <= @domain
  110. end
  111. 23 def <=>(other)
  112. 36 other = DomainName.new(other)
  113. 36 othername = other.hostname
  114. 36 if othername == @hostname
  115. 12 0
  116. 23 elsif @hostname.end_with?(othername) && @hostname[-othername.size - 1, 1] == "."
  117. # The other is higher
  118. 12 -1
  119. else
  120. # The other is lower
  121. 12 1
  122. end
  123. end
  124. end
  125. end

lib/httpx/errors.rb

97.83% lines covered

46 relevant lines. 45 lines covered and 1 lines missed.
    
  1. # frozen_string_literal: true
  2. 23 module HTTPX
  3. # the default exception class for exceptions raised by HTTPX.
  4. 23 class Error < StandardError; end
  5. 23 class UnsupportedSchemeError < Error; end
  6. 23 class ConnectionError < Error; end
  7. # Error raised when there was a timeout. Its subclasses allow for finer-grained
  8. # control of which timeout happened.
  9. 23 class TimeoutError < Error
  10. # The timeout value which caused this error to be raised.
  11. 23 attr_reader :timeout
  12. # initializes the timeout exception with the +timeout+ causing the error, and the
  13. # error +message+ for it.
  14. 23 def initialize(timeout, message)
  15. 387 @timeout = timeout
  16. 387 super(message)
  17. end
  18. # clones this error into a HTTPX::ConnectionTimeoutError.
  19. 23 def to_connection_error
  20. 18 ex = ConnectTimeoutError.new(@timeout, message)
  21. 18 ex.set_backtrace(backtrace)
  22. 18 ex
  23. end
  24. end
  25. # Raise when it can't acquire a connection for a given origin.
  26. 23 class PoolTimeoutError < TimeoutError
  27. 23 attr_reader :origin
  28. # initializes the +origin+ it refers to, and the
  29. # +timeout+ causing the error.
  30. 23 def initialize(origin, timeout)
  31. 6 @origin = origin
  32. 6 super(timeout, "Timed out after #{timeout} seconds while waiting for a connection to #{origin}")
  33. end
  34. end
  35. # Error raised when there was a timeout establishing the connection to a server.
  36. # This may be raised due to timeouts during TCP and TLS (when applicable) connection
  37. # establishment.
  38. 23 class ConnectTimeoutError < TimeoutError; end
  39. # Error raised when there was a timeout while sending a request, or receiving a response
  40. # from the server.
  41. 23 class RequestTimeoutError < TimeoutError
  42. # The HTTPX::Request request object this exception refers to.
  43. 23 attr_reader :request
  44. # initializes the exception with the +request+ and +response+ it refers to, and the
  45. # +timeout+ causing the error, and the
  46. 23 def initialize(request, response, timeout)
  47. 307 @request = request
  48. 307 @response = response
  49. 307 super(timeout, "Timed out after #{timeout} seconds")
  50. end
  51. 23 def marshal_dump
  52. [message]
  53. end
  54. end
  55. # Error raised when there was a timeout while receiving a response from the server.
  56. 23 class ReadTimeoutError < RequestTimeoutError; end
  57. # Error raised when there was a timeout while sending a request from the server.
  58. 23 class WriteTimeoutError < RequestTimeoutError; end
  59. # Error raised when there was a timeout while waiting for the HTTP/2 settings frame from the server.
  60. 23 class SettingsTimeoutError < TimeoutError; end
  61. # Error raised when there was a timeout while resolving a domain to an IP.
  62. 23 class ResolveTimeoutError < TimeoutError; end
  63. # Error raised when there was an error while resolving a domain to an IP.
  64. 23 class ResolveError < Error; end
  65. # Error raised when there was an error while resolving a domain to an IP
  66. # using a HTTPX::Resolver::Native resolver.
  67. 23 class NativeResolveError < ResolveError
  68. 23 attr_reader :connection, :host
  69. # initializes the exception with the +connection+ it refers to, the +host+ domain
  70. # which failed to resolve, and the error +message+.
  71. 23 def initialize(connection, host, message = "Can't resolve #{host}")
  72. 94 @connection = connection
  73. 94 @host = host
  74. 94 super(message)
  75. end
  76. end
  77. # The exception class for HTTP responses with 4xx or 5xx status.
  78. 23 class HTTPError < Error
  79. # The HTTPX::Response response object this exception refers to.
  80. 23 attr_reader :response
  81. # Creates the instance and assigns the HTTPX::Response +response+.
  82. 23 def initialize(response)
  83. 76 @response = response
  84. 76 super("HTTP Error: #{@response.status} #{@response.headers}\n#{@response.body}")
  85. end
  86. # The HTTP response status.
  87. #
  88. # error.status #=> 404
  89. 23 def status
  90. 12 @response.status
  91. end
  92. end
  93. # error raised when a request was sent a server which can't reproduce a response, and
  94. # has therefore returned an HTTP response using the 421 status code.
  95. 23 class MisdirectedRequestError < HTTPError; end
  96. end

lib/httpx/extensions.rb

67.86% lines covered

28 relevant lines. 19 lines covered and 9 lines missed.
    
  1. # frozen_string_literal: true
  2. 23 require "uri"
  3. 23 module HTTPX
  4. 23 module ArrayExtensions
  5. 23 module FilterMap
  6. refine Array do
  7. # Ruby 2.7 backport
  8. def filter_map
  9. return to_enum(:filter_map) unless block_given?
  10. each_with_object([]) do |item, res|
  11. processed = yield(item)
  12. res << processed if processed
  13. end
  14. end
  15. 22 end unless Array.method_defined?(:filter_map)
  16. end
  17. 23 module Intersect
  18. refine Array do
  19. # Ruby 3.1 backport
  20. 4 def intersect?(arr)
  21. if size < arr.size
  22. smaller = self
  23. else
  24. smaller, arr = arr, self
  25. end
  26. (arr & smaller).size > 0
  27. end
  28. 22 end unless Array.method_defined?(:intersect?)
  29. end
  30. end
  31. 23 module URIExtensions
  32. # uri 0.11 backport, ships with ruby 3.1
  33. 23 refine URI::Generic do
  34. 23 def non_ascii_hostname
  35. 339 @non_ascii_hostname
  36. end
  37. 23 def non_ascii_hostname=(hostname)
  38. 24 @non_ascii_hostname = hostname
  39. end
  40. def authority
  41. 5341 return host if port == default_port
  42. 558 "#{host}:#{port}"
  43. 22 end unless URI::HTTP.method_defined?(:authority)
  44. def origin
  45. 4346 "#{scheme}://#{authority}"
  46. 22 end unless URI::HTTP.method_defined?(:origin)
  47. end
  48. end
  49. end

lib/httpx/headers.rb

100.0% lines covered

71 relevant lines. 71 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 23 module HTTPX
  3. 23 class Headers
  4. 23 class << self
  5. 23 def new(headers = nil)
  6. 18887 return headers if headers.is_a?(self)
  7. 8663 super
  8. end
  9. end
  10. 23 def initialize(headers = nil)
  11. 8663 @headers = {}
  12. 8663 return unless headers
  13. 8514 headers.each do |field, value|
  14. 44224 array_value(value).each do |v|
  15. 44266 add(downcased(field), v)
  16. end
  17. end
  18. end
  19. # cloned initialization
  20. 23 def initialize_clone(orig)
  21. 6 super
  22. 6 @headers = orig.instance_variable_get(:@headers).clone
  23. end
  24. # dupped initialization
  25. 23 def initialize_dup(orig)
  26. 10931 super
  27. 10931 @headers = orig.instance_variable_get(:@headers).dup
  28. end
  29. # freezes the headers hash
  30. 23 def freeze
  31. 12148 @headers.freeze
  32. 12148 super
  33. end
  34. 23 def same_headers?(headers)
  35. 24 @headers.empty? || begin
  36. 24 headers.each do |k, v|
  37. 54 next unless key?(k)
  38. 54 return false unless v == self[k]
  39. end
  40. 12 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. 23 def merge(other)
  48. 3389 headers = dup
  49. 3389 other.each do |field, value|
  50. 2883 headers[downcased(field)] = value
  51. end
  52. 3389 headers
  53. end
  54. # returns the comma-separated values of the header field
  55. # identified by +field+, or nil otherwise.
  56. #
  57. 23 def [](field)
  58. 66475 a = @headers[downcased(field)] || return
  59. 20157 a.join(", ")
  60. end
  61. # sets +value+ (if not nil) as single value for the +field+ header.
  62. #
  63. 23 def []=(field, value)
  64. 29552 return unless value
  65. 29552 @headers[downcased(field)] = array_value(value)
  66. end
  67. # deletes all values associated with +field+ header.
  68. #
  69. 23 def delete(field)
  70. 195 canonical = downcased(field)
  71. 195 @headers.delete(canonical) if @headers.key?(canonical)
  72. end
  73. # adds additional +value+ to the existing, for header +field+.
  74. #
  75. 23 def add(field, value)
  76. 44620 (@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. 23 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. 23 def each(extra_headers = nil)
  90. 47483 return enum_for(__method__, extra_headers) { @headers.size } unless block_given?
  91. 25322 @headers.each do |field, value|
  92. 31972 yield(field, value.join(", ")) unless value.empty?
  93. end
  94. 5388 extra_headers.each do |field, value|
  95. 17631 yield(field, value) unless value.empty?
  96. 25309 end if extra_headers
  97. end
  98. 23 def ==(other)
  99. 14744 other == to_hash
  100. end
  101. # the headers store in Hash format
  102. 23 def to_hash
  103. 15748 Hash[to_a]
  104. end
  105. 23 alias_method :to_h, :to_hash
  106. # the headers store in array of pairs format
  107. 23 def to_a
  108. 15765 Array(each)
  109. end
  110. # headers as string
  111. 23 def to_s
  112. 1588 @headers.to_s
  113. end
  114. skipped # :nocov:
  115. skipped def inspect
  116. skipped to_hash.inspect
  117. skipped end
  118. skipped # :nocov:
  119. # this is internal API and doesn't abide to other public API
  120. # guarantees, like downcasing strings.
  121. # Please do not use this outside of core!
  122. #
  123. 23 def key?(downcased_key)
  124. 46613 @headers.key?(downcased_key)
  125. end
  126. # returns the values for the +field+ header in array format.
  127. # This method is more internal, and for this reason doesn't try
  128. # to "correct" the user input, i.e. it doesn't downcase the key.
  129. #
  130. 23 def get(field)
  131. 222 @headers[field] || EMPTY
  132. end
  133. 23 private
  134. 23 def array_value(value)
  135. 73776 case value
  136. when Array
  137. 71530 value.map { |val| String(val).strip }
  138. else
  139. 41691 [String(value).strip]
  140. end
  141. end
  142. 23 def downcased(field)
  143. 187991 String(field).downcase
  144. end
  145. end
  146. end

lib/httpx/io.rb

100.0% lines covered

5 relevant lines. 5 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 23 require "socket"
  3. 23 require "httpx/io/udp"
  4. 23 require "httpx/io/tcp"
  5. 23 require "httpx/io/unix"
  6. begin
  7. 23 require "httpx/io/ssl"
  8. rescue LoadError
  9. end

lib/httpx/io/ssl.rb

96.2% lines covered

79 relevant lines. 76 lines covered and 3 lines missed.
    
  1. # frozen_string_literal: true
  2. 23 require "openssl"
  3. 23 module HTTPX
  4. 23 TLSError = OpenSSL::SSL::SSLError
  5. 23 class SSL < TCP
  6. # rubocop:disable Style/MutableConstant
  7. 23 TLS_OPTIONS = { alpn_protocols: %w[h2 http/1.1].freeze }
  8. # https://github.com/jruby/jruby-openssl/issues/284
  9. 23 TLS_OPTIONS[:verify_hostname] = true if RUBY_ENGINE == "jruby"
  10. # rubocop:enable Style/MutableConstant
  11. 23 TLS_OPTIONS.freeze
  12. 23 attr_writer :ssl_session
  13. 23 def initialize(_, _, options)
  14. 2100 super
  15. 2100 ctx_options = TLS_OPTIONS.merge(options.ssl)
  16. 2100 @sni_hostname = ctx_options.delete(:hostname) || @hostname
  17. 2100 if @keep_open && @io.is_a?(OpenSSL::SSL::SSLSocket)
  18. # externally initiated ssl socket
  19. 17 @ctx = @io.context
  20. 17 @state = :negotiated
  21. else
  22. 2083 @ctx = OpenSSL::SSL::SSLContext.new
  23. 2083 @ctx.set_params(ctx_options) unless ctx_options.empty?
  24. 2083 unless @ctx.session_cache_mode.nil? # a dummy method on JRuby
  25. 1776 @ctx.session_cache_mode =
  26. OpenSSL::SSL::SSLContext::SESSION_CACHE_CLIENT | OpenSSL::SSL::SSLContext::SESSION_CACHE_NO_INTERNAL_STORE
  27. end
  28. 2083 yield(self) if block_given?
  29. end
  30. 2100 @verify_hostname = @ctx.verify_hostname
  31. end
  32. 23 if OpenSSL::SSL::SSLContext.method_defined?(:session_new_cb=)
  33. 22 def session_new_cb(&pr)
  34. 5115 @ctx.session_new_cb = proc { |_, sess| pr.call(sess) }
  35. end
  36. else
  37. # session_new_cb not implemented under JRuby
  38. 1 def session_new_cb; end
  39. end
  40. 23 def protocol
  41. 1943 @io.alpn_protocol || super
  42. rescue StandardError
  43. 5 super
  44. end
  45. 23 if RUBY_ENGINE == "jruby"
  46. # in jruby, alpn_protocol may return ""
  47. # https://github.com/jruby/jruby-openssl/issues/287
  48. 1 def protocol
  49. 336 proto = @io.alpn_protocol
  50. 335 return super if proto.nil? || proto.empty?
  51. 334 proto
  52. rescue StandardError
  53. 1 super
  54. end
  55. end
  56. 23 def can_verify_peer?
  57. 11 @ctx.verify_mode == OpenSSL::SSL::VERIFY_PEER
  58. end
  59. 23 def verify_hostname(host)
  60. 13 return false if @ctx.verify_mode == OpenSSL::SSL::VERIFY_NONE
  61. 13 return false if !@io.respond_to?(:peer_cert) || @io.peer_cert.nil?
  62. 13 OpenSSL::SSL.verify_certificate_identity(@io.peer_cert, host)
  63. end
  64. 23 def connected?
  65. 8658 @state == :negotiated
  66. end
  67. 23 def expired?
  68. super || ssl_session_expired?
  69. end
  70. 23 def ssl_session_expired?
  71. 2246 @ssl_session.nil? || Process.clock_gettime(Process::CLOCK_REALTIME) >= (@ssl_session.time.to_f + @ssl_session.timeout)
  72. end
  73. 23 def connect
  74. 8697 super
  75. 8676 return if @state == :negotiated ||
  76. @state != :connected
  77. 6132 unless @io.is_a?(OpenSSL::SSL::SSLSocket)
  78. 2246 if (hostname_is_ip = (@ip == @sni_hostname))
  79. # IPv6 address would be "[::1]", must turn to "0000:0000:0000:0000:0000:0000:0000:0001" for cert SAN check
  80. 24 @sni_hostname = @ip.to_string
  81. # IP addresses in SNI is not valid per RFC 6066, section 3.
  82. 24 @ctx.verify_hostname = false
  83. end
  84. 2246 @io = OpenSSL::SSL::SSLSocket.new(@io, @ctx)
  85. 2246 @io.hostname = @sni_hostname unless hostname_is_ip
  86. 2246 @io.session = @ssl_session unless ssl_session_expired?
  87. 2246 @io.sync_close = true
  88. end
  89. 6132 try_ssl_connect
  90. end
  91. 23 def try_ssl_connect
  92. 6132 ret = @io.connect_nonblock(exception: false)
  93. 6145 log(level: 3, color: :cyan) { "TLS CONNECT: #{ret}..." }
  94. 6115 case ret
  95. when :wait_readable
  96. 3904 @interests = :r
  97. 3904 return
  98. when :wait_writable
  99. @interests = :w
  100. return
  101. end
  102. 2211 @io.post_connection_check(@sni_hostname) if @ctx.verify_mode != OpenSSL::SSL::VERIFY_NONE && @verify_hostname
  103. 2210 transition(:negotiated)
  104. 2210 @interests = :w
  105. end
  106. 23 private
  107. 23 def transition(nextstate)
  108. 8722 case nextstate
  109. when :negotiated
  110. 2210 return unless @state == :connected
  111. when :closed
  112. 2135 return unless @state == :negotiated ||
  113. @state == :connected
  114. end
  115. 8722 do_transition(nextstate)
  116. end
  117. 23 def log_transition_state(nextstate)
  118. 53 return super unless nextstate == :negotiated
  119. 12 server_cert = @io.peer_cert
  120. 12 "#{super}\n\n" \
  121. "SSL connection using #{@io.ssl_version} / #{Array(@io.cipher).first}\n" \
  122. "ALPN, server accepted to use #{protocol}\n" \
  123. "Server certificate:\n " \
  124. "subject: #{server_cert.subject}\n " \
  125. "start date: #{server_cert.not_before}\n " \
  126. "expire date: #{server_cert.not_after}\n " \
  127. "issuer: #{server_cert.issuer}\n " \
  128. "SSL certificate verify ok."
  129. end
  130. end
  131. end

lib/httpx/io/tcp.rb

90.35% lines covered

114 relevant lines. 103 lines covered and 11 lines missed.
    
  1. # frozen_string_literal: true
  2. 23 require "resolv"
  3. 23 require "ipaddr"
  4. 23 module HTTPX
  5. 23 class TCP
  6. 23 include Loggable
  7. 23 using URIExtensions
  8. 23 attr_reader :ip, :port, :addresses, :state, :interests
  9. 23 alias_method :host, :ip
  10. 23 def initialize(origin, addresses, options)
  11. 5031 @state = :idle
  12. 5031 @addresses = []
  13. 5031 @hostname = origin.host
  14. 5031 @options = options
  15. 5031 @fallback_protocol = @options.fallback_protocol
  16. 5031 @port = origin.port
  17. 5031 @interests = :w
  18. 5031 if @options.io
  19. 40 @io = case @options.io
  20. when Hash
  21. 12 @options.io[origin.authority]
  22. else
  23. 28 @options.io
  24. end
  25. 40 raise Error, "Given IO objects do not match the request authority" unless @io
  26. 40 _, _, _, @ip = @io.addr
  27. 40 @addresses << @ip
  28. 40 @keep_open = true
  29. 40 @state = :connected
  30. else
  31. 4991 add_addresses(addresses)
  32. end
  33. 5031 @ip_index = @addresses.size - 1
  34. end
  35. 23 def socket
  36. 147 @io
  37. end
  38. 23 def add_addresses(addrs)
  39. 5157 return if addrs.empty?
  40. 16762 addrs = addrs.map { |addr| addr.is_a?(IPAddr) ? addr : IPAddr.new(addr) }
  41. 5157 ip_index = @ip_index || (@addresses.size - 1)
  42. 5157 if addrs.first.ipv6?
  43. # should be the next in line
  44. 176 @addresses = [*@addresses[0, ip_index], *addrs, *@addresses[ip_index..-1]]
  45. else
  46. 4981 @addresses.unshift(*addrs)
  47. 4981 @ip_index += addrs.size if @ip_index
  48. end
  49. end
  50. 23 def to_io
  51. 16340 @io.to_io
  52. end
  53. 23 def protocol
  54. 3213 @fallback_protocol
  55. end
  56. 23 def connect
  57. 18713 return unless closed?
  58. 14663 if !@io || @io.closed?
  59. 5533 transition(:idle)
  60. 5533 @io = build_socket
  61. end
  62. 14663 try_connect
  63. rescue Errno::ECONNREFUSED,
  64. Errno::EADDRNOTAVAIL,
  65. Errno::EHOSTUNREACH,
  66. SocketError,
  67. IOError => e
  68. 413 raise e if @ip_index <= 0
  69. 369 log { "failed connecting to #{@ip} (#{e.message}), trying next..." }
  70. 359 @ip_index -= 1
  71. 359 @io = build_socket
  72. 359 retry
  73. rescue Errno::ETIMEDOUT => e
  74. raise ConnectTimeoutError.new(@options.timeout[:connect_timeout], e.message) if @ip_index <= 0
  75. log { "failed connecting to #{@ip} (#{e.message}), trying next..." }
  76. @ip_index -= 1
  77. @io = build_socket
  78. retry
  79. end
  80. 23 def try_connect
  81. 14663 ret = @io.connect_nonblock(Socket.sockaddr_in(@port, @ip.to_s), exception: false)
  82. 11441 log(level: 3, color: :cyan) { "TCP CONNECT: #{ret}..." }
  83. 11361 case ret
  84. when :wait_readable
  85. @interests = :r
  86. return
  87. when :wait_writable
  88. 5882 @interests = :w
  89. 5882 return
  90. end
  91. 5479 transition(:connected)
  92. 5479 @interests = :w
  93. rescue Errno::EALREADY
  94. 2889 @interests = :w
  95. end
  96. 23 private :try_connect
  97. 23 def read(size, buffer)
  98. 36209 ret = @io.read_nonblock(size, buffer, exception: false)
  99. 36209 if ret == :wait_readable
  100. 7490 buffer.clear
  101. 7490 return 0
  102. end
  103. 28719 return if ret.nil?
  104. 28765 log { "READ: #{buffer.bytesize} bytes..." }
  105. 28705 buffer.bytesize
  106. end
  107. 23 def write(buffer)
  108. 15103 siz = @io.write_nonblock(buffer, exception: false)
  109. 15077 return 0 if siz == :wait_writable
  110. 15059 return if siz.nil?
  111. 15124 log { "WRITE: #{siz} bytes..." }
  112. 15059 buffer.shift!(siz)
  113. 15059 siz
  114. end
  115. 23 def close
  116. 5991 return if @keep_open || closed?
  117. 808 begin
  118. 5316 @io.close
  119. ensure
  120. 5316 transition(:closed)
  121. end
  122. end
  123. 23 def connected?
  124. 9634 @state == :connected
  125. end
  126. 23 def closed?
  127. 24693 @state == :idle || @state == :closed
  128. end
  129. 23 def expired?
  130. # do not mess with external sockets
  131. return false if @options.io
  132. return true unless @addresses
  133. resolver_addresses = Resolver.nolookup_resolve(@hostname)
  134. (Array(resolver_addresses) & @addresses).empty?
  135. end
  136. skipped # :nocov:
  137. skipped def inspect
  138. skipped "#<#{self.class}: #{@ip}:#{@port} (state: #{@state})>"
  139. skipped end
  140. skipped # :nocov:
  141. 23 private
  142. 23 def build_socket
  143. 5892 @ip = @addresses[@ip_index]
  144. 5892 Socket.new(@ip.family, :STREAM, 0)
  145. end
  146. 23 def transition(nextstate)
  147. 9831 case nextstate
  148. # when :idle
  149. when :connected
  150. 3311 return unless @state == :idle
  151. when :closed
  152. 3181 return unless @state == :connected
  153. end
  154. 9831 do_transition(nextstate)
  155. end
  156. 23 def do_transition(nextstate)
  157. 18665 log(level: 1) { log_transition_state(nextstate) }
  158. 18553 @state = nextstate
  159. end
  160. 23 def log_transition_state(nextstate)
  161. 112 case nextstate
  162. when :connected
  163. 30 "Connected to #{host} (##{@io.fileno})"
  164. else
  165. 82 "#{host} #{@state} -> #{nextstate}"
  166. end
  167. end
  168. end
  169. end

lib/httpx/io/udp.rb

100.0% lines covered

35 relevant lines. 35 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 23 require "ipaddr"
  3. 23 module HTTPX
  4. 23 class UDP
  5. 23 include Loggable
  6. 23 def initialize(ip, port, options)
  7. 291 @host = ip
  8. 291 @port = port
  9. 291 @io = UDPSocket.new(IPAddr.new(ip).family)
  10. 291 @options = options
  11. end
  12. 23 def to_io
  13. 935 @io.to_io
  14. end
  15. 23 def connect; end
  16. 23 def connected?
  17. 291 true
  18. end
  19. 23 def close
  20. 299 @io.close
  21. end
  22. 23 if RUBY_ENGINE == "jruby"
  23. # In JRuby, sendmsg_nonblock is not implemented
  24. 1 def write(buffer)
  25. 54 siz = @io.send(buffer.to_s, 0, @host, @port)
  26. 54 log { "WRITE: #{siz} bytes..." }
  27. 54 buffer.shift!(siz)
  28. 54 siz
  29. end
  30. else
  31. 22 def write(buffer)
  32. 489 siz = @io.sendmsg_nonblock(buffer.to_s, 0, Socket.sockaddr_in(@port, @host.to_s), exception: false)
  33. 489 return 0 if siz == :wait_writable
  34. 489 return if siz.nil?
  35. 489 log { "WRITE: #{siz} bytes..." }
  36. 489 buffer.shift!(siz)
  37. 489 siz
  38. end
  39. end
  40. 23 def read(size, buffer)
  41. 729 ret = @io.recvfrom_nonblock(size, 0, buffer, exception: false)
  42. 729 return 0 if ret == :wait_readable
  43. 495 return if ret.nil?
  44. 495 log { "READ: #{buffer.bytesize} bytes..." }
  45. 495 buffer.bytesize
  46. rescue IOError
  47. end
  48. end
  49. end

lib/httpx/io/unix.rb

94.29% lines covered

35 relevant lines. 33 lines covered and 2 lines missed.
    
  1. # frozen_string_literal: true
  2. 23 module HTTPX
  3. 23 class UNIX < TCP
  4. 23 using URIExtensions
  5. 23 attr_reader :path
  6. 23 alias_method :host, :path
  7. 23 def initialize(origin, path, options)
  8. 20 @addresses = []
  9. 20 @hostname = origin.host
  10. 20 @state = :idle
  11. 20 @options = options
  12. 20 @fallback_protocol = @options.fallback_protocol
  13. 20 if @options.io
  14. 10 @io = case @options.io
  15. when Hash
  16. 5 @options.io[origin.authority]
  17. else
  18. 5 @options.io
  19. end
  20. 10 raise Error, "Given IO objects do not match the request authority" unless @io
  21. 10 @path = @io.path
  22. 10 @keep_open = true
  23. 10 @state = :connected
  24. 10 elsif path
  25. 10 @path = path
  26. else
  27. raise Error, "No path given where to store the socket"
  28. end
  29. 20 @io ||= build_socket
  30. end
  31. 23 def connect
  32. 15 return unless closed?
  33. begin
  34. 15 if @io.closed?
  35. 5 transition(:idle)
  36. 5 @io = build_socket
  37. end
  38. 15 @io.connect_nonblock(Socket.sockaddr_un(@path))
  39. rescue Errno::EISCONN
  40. end
  41. 10 transition(:connected)
  42. rescue Errno::EINPROGRESS,
  43. Errno::EALREADY,
  44. ::IO::WaitReadable
  45. end
  46. 23 def expired?
  47. false
  48. end
  49. skipped # :nocov:
  50. skipped def inspect
  51. skipped "#<#{self.class}(path: #{@path}): (state: #{@state})>"
  52. skipped end
  53. skipped # :nocov:
  54. 23 private
  55. 23 def build_socket
  56. 15 Socket.new(Socket::PF_UNIX, :STREAM, 0)
  57. end
  58. end
  59. end

lib/httpx/loggable.rb

100.0% lines covered

13 relevant lines. 13 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 23 module HTTPX
  3. 23 module Loggable
  4. 23 COLORS = {
  5. black: 30,
  6. red: 31,
  7. green: 32,
  8. yellow: 33,
  9. blue: 34,
  10. magenta: 35,
  11. cyan: 36,
  12. white: 37,
  13. }.freeze
  14. 23 USE_DEBUG_LOG = ENV.key?("HTTPX_DEBUG")
  15. 23 def log(level: @options.debug_level, color: nil, &msg)
  16. 283717 return unless @options.debug_level >= level
  17. 127872 debug_stream = @options.debug || ($stderr if USE_DEBUG_LOG)
  18. 127872 return unless debug_stream
  19. 1236 message = (+"" << msg.call << "\n")
  20. 1236 message = "\e[#{COLORS[color]}m#{message}\e[0m" if color && debug_stream.respond_to?(:isatty) && debug_stream.isatty
  21. 1236 debug_stream << message
  22. end
  23. 23 def log_exception(ex, level: @options.debug_level, color: nil)
  24. 895 log(level: level, color: color) { ex.full_message }
  25. end
  26. end
  27. end

lib/httpx/options.rb

98.71% lines covered

155 relevant lines. 153 lines covered and 2 lines missed.
    
  1. # frozen_string_literal: true
  2. 23 require "socket"
  3. 23 module HTTPX
  4. # Contains a set of options which are passed and shared across from session to its requests or
  5. # responses.
  6. 23 class Options
  7. 23 BUFFER_SIZE = 1 << 14
  8. 23 WINDOW_SIZE = 1 << 14 # 16K
  9. 23 MAX_BODY_THRESHOLD_SIZE = (1 << 10) * 112 # 112K
  10. 23 KEEP_ALIVE_TIMEOUT = 20
  11. 23 SETTINGS_TIMEOUT = 10
  12. 23 CLOSE_HANDSHAKE_TIMEOUT = 10
  13. 23 CONNECT_TIMEOUT = READ_TIMEOUT = WRITE_TIMEOUT = 60
  14. 23 REQUEST_TIMEOUT = OPERATION_TIMEOUT = nil
  15. # https://github.com/ruby/resolv/blob/095f1c003f6073730500f02acbdbc55f83d70987/lib/resolv.rb#L408
  16. 1 ip_address_families = begin
  17. 23 list = Socket.ip_address_list
  18. 94 if list.any? { |a| a.ipv6? && !a.ipv6_loopback? && !a.ipv6_linklocal? && !a.ipv6_unique_local? }
  19. [Socket::AF_INET6, Socket::AF_INET]
  20. else
  21. 23 [Socket::AF_INET]
  22. end
  23. rescue NotImplementedError
  24. [Socket::AF_INET]
  25. end.freeze
  26. 1 DEFAULT_OPTIONS = {
  27. 22 :max_requests => Float::INFINITY,
  28. :debug => nil,
  29. 23 :debug_level => (ENV["HTTPX_DEBUG"] || 1).to_i,
  30. :ssl => EMPTY_HASH,
  31. :http2_settings => { settings_enable_push: 0 }.freeze,
  32. :fallback_protocol => "http/1.1",
  33. :supported_compression_formats => %w[gzip deflate],
  34. :decompress_response_body => true,
  35. :compress_request_body => true,
  36. :timeout => {
  37. connect_timeout: CONNECT_TIMEOUT,
  38. settings_timeout: SETTINGS_TIMEOUT,
  39. close_handshake_timeout: CLOSE_HANDSHAKE_TIMEOUT,
  40. operation_timeout: OPERATION_TIMEOUT,
  41. keep_alive_timeout: KEEP_ALIVE_TIMEOUT,
  42. read_timeout: READ_TIMEOUT,
  43. write_timeout: WRITE_TIMEOUT,
  44. request_timeout: REQUEST_TIMEOUT,
  45. },
  46. :headers_class => Class.new(Headers),
  47. :headers => {},
  48. :window_size => WINDOW_SIZE,
  49. :buffer_size => BUFFER_SIZE,
  50. :body_threshold_size => MAX_BODY_THRESHOLD_SIZE,
  51. :request_class => Class.new(Request),
  52. :response_class => Class.new(Response),
  53. :request_body_class => Class.new(Request::Body),
  54. :response_body_class => Class.new(Response::Body),
  55. :pool_class => Class.new(Pool),
  56. :connection_class => Class.new(Connection),
  57. :options_class => Class.new(self),
  58. :transport => nil,
  59. :addresses => nil,
  60. :persistent => false,
  61. 23 :resolver_class => (ENV["HTTPX_RESOLVER"] || :native).to_sym,
  62. :resolver_options => { cache: true }.freeze,
  63. :pool_options => EMPTY_HASH,
  64. :ip_families => ip_address_families,
  65. }.freeze
  66. 23 class << self
  67. 23 def new(options = {})
  68. # let enhanced options go through
  69. 8676 return options if self == Options && options.class < self
  70. 6729 return options if options.is_a?(self)
  71. 3377 super
  72. end
  73. 23 def method_added(meth)
  74. 15086 super
  75. 15086 return unless meth =~ /^option_(.+)$/
  76. 6982 optname = Regexp.last_match(1).to_sym
  77. 6982 attr_reader(optname)
  78. end
  79. end
  80. # creates a new options instance from a given hash, which optionally define the following:
  81. #
  82. # :debug :: an object which log messages are written to (must respond to <tt><<</tt>)
  83. # :debug_level :: the log level of messages (can be 1, 2, or 3).
  84. # :ssl :: a hash of options which can be set as params of OpenSSL::SSL::SSLContext (see HTTPX::IO::SSL)
  85. # :http2_settings :: a hash of options to be passed to a HTTP2::Connection (ex: <tt>{ max_concurrent_streams: 2 }</tt>)
  86. # :fallback_protocol :: version of HTTP protocol to use by default in the absence of protocol negotiation
  87. # like ALPN (defaults to <tt>"http/1.1"</tt>)
  88. # :supported_compression_formats :: list of compressions supported by the transcoder layer (defaults to <tt>%w[gzip deflate]</tt>).
  89. # :decompress_response_body :: whether to auto-decompress response body (defaults to <tt>true</tt>).
  90. # :compress_request_body :: whether to auto-decompress response body (defaults to <tt>true</tt>)
  91. # :timeout :: hash of timeout configurations (supports <tt>:connect_timeout</tt>, <tt>:settings_timeout</tt>,
  92. # <tt>:operation_timeout</tt>, <tt>:keep_alive_timeout</tt>, <tt>:read_timeout</tt>, <tt>:write_timeout</tt>
  93. # and <tt>:request_timeout</tt>
  94. # :headers :: hash of HTTP headers (ex: <tt>{ "x-custom-foo" => "bar" }</tt>)
  95. # :window_size :: number of bytes to read from a socket
  96. # :buffer_size :: internal read and write buffer size in bytes
  97. # :body_threshold_size :: maximum size in bytes of response payload that is buffered in memory.
  98. # :request_class :: class used to instantiate a request
  99. # :response_class :: class used to instantiate a response
  100. # :headers_class :: class used to instantiate headers
  101. # :request_body_class :: class used to instantiate a request body
  102. # :response_body_class :: class used to instantiate a response body
  103. # :connection_class :: class used to instantiate connections
  104. # :pool_class :: class used to instantiate the session connection pool
  105. # :options_class :: class used to instantiate options
  106. # :transport :: type of transport to use (set to "unix" for UNIX sockets)
  107. # :addresses :: bucket of peer addresses (can be a list of IP addresses, a hash of domain to list of adddresses;
  108. # paths should be used for UNIX sockets instead)
  109. # :io :: open socket, or domain/ip-to-socket hash, which requests should be sent to
  110. # :persistent :: whether to persist connections in between requests (defaults to <tt>true</tt>)
  111. # :resolver_class :: which resolver to use (defaults to <tt>:native</tt>, can also be <tt>:system<tt> for
  112. # using getaddrinfo or <tt>:https</tt> for DoH resolver, or a custom class)
  113. # :resolver_options :: hash of options passed to the resolver. Accepted keys depend on the resolver type.
  114. # :pool_options :: hash of options passed to the connection pool (See Pool#initialize).
  115. # :ip_families :: which socket families are supported (system-dependent)
  116. # :origin :: HTTP origin to set on requests with relative path (ex: "https://api.serv.com")
  117. # :base_path :: path to prefix given relative paths with (ex: "/v2")
  118. # :max_concurrent_requests :: max number of requests which can be set concurrently
  119. # :max_requests :: max number of requests which can be made on socket before it reconnects.
  120. #
  121. # This list of options are enhanced with each loaded plugin, see the plugin docs for details.
  122. 23 def initialize(options = {})
  123. 3377 do_initialize(options)
  124. 3365 freeze
  125. end
  126. 23 def freeze
  127. 8649 super
  128. 8649 @origin.freeze
  129. 8649 @base_path.freeze
  130. 8649 @timeout.freeze
  131. 8649 @headers.freeze
  132. 8649 @addresses.freeze
  133. 8649 @supported_compression_formats.freeze
  134. end
  135. 23 def option_origin(value)
  136. 480 URI(value)
  137. end
  138. 23 def option_base_path(value)
  139. 24 String(value)
  140. end
  141. 23 def option_headers(value)
  142. 5960 headers_class.new(value)
  143. end
  144. 23 def option_timeout(value)
  145. 6306 Hash[value]
  146. end
  147. 23 def option_supported_compression_formats(value)
  148. 5516 Array(value).map(&:to_s)
  149. end
  150. 23 def option_max_concurrent_requests(value)
  151. 727 raise TypeError, ":max_concurrent_requests must be positive" unless value.positive?
  152. 727 value
  153. end
  154. 23 def option_max_requests(value)
  155. 5504 raise TypeError, ":max_requests must be positive" unless value.positive?
  156. 5504 value
  157. end
  158. 23 def option_window_size(value)
  159. 5508 value = Integer(value)
  160. 5508 raise TypeError, ":window_size must be positive" unless value.positive?
  161. 5508 value
  162. end
  163. 23 def option_buffer_size(value)
  164. 5508 value = Integer(value)
  165. 5508 raise TypeError, ":buffer_size must be positive" unless value.positive?
  166. 5508 value
  167. end
  168. 23 def option_body_threshold_size(value)
  169. 5496 bytes = Integer(value)
  170. 5496 raise TypeError, ":body_threshold_size must be positive" unless bytes.positive?
  171. 5496 bytes
  172. end
  173. 23 def option_transport(value)
  174. 35 transport = value.to_s
  175. 35 raise TypeError, "#{transport} is an unsupported transport type" unless %w[unix].include?(transport)
  176. 35 transport
  177. end
  178. 23 def option_addresses(value)
  179. 32 Array(value)
  180. end
  181. 23 def option_ip_families(value)
  182. 5496 Array(value)
  183. end
  184. 23 %i[
  185. ssl http2_settings
  186. request_class response_class headers_class request_body_class
  187. response_body_class connection_class options_class
  188. pool_class pool_options
  189. io fallback_protocol debug debug_level resolver_class resolver_options
  190. compress_request_body decompress_response_body
  191. persistent
  192. ].each do |method_name|
  193. 460 class_eval(<<-OUT, __FILE__, __LINE__ + 1)
  194. # sets +v+ as the value of #{method_name}
  195. def option_#{method_name}(v); v; end # def option_smth(v); v; end
  196. OUT
  197. end
  198. 23 REQUEST_BODY_IVARS = %i[@headers].freeze
  199. 23 def ==(other)
  200. 1573 super || options_equals?(other)
  201. end
  202. 23 def options_equals?(other, ignore_ivars = REQUEST_BODY_IVARS)
  203. # headers and other request options do not play a role, as they are
  204. # relevant only for the request.
  205. 340 ivars = instance_variables - ignore_ivars
  206. 340 other_ivars = other.instance_variables - ignore_ivars
  207. 340 return false if ivars.size != other_ivars.size
  208. 328 return false if ivars.sort != other_ivars.sort
  209. 328 ivars.all? do |ivar|
  210. 8001 instance_variable_get(ivar) == other.instance_variable_get(ivar)
  211. end
  212. end
  213. 23 def merge(other)
  214. 25523 ivar_map = nil
  215. 25523 other_ivars = case other
  216. when Hash
  217. 30542 ivar_map = other.keys.to_h { |k| [:"@#{k}", k] }
  218. 17709 ivar_map.keys
  219. else
  220. 7814 other.instance_variables
  221. end
  222. 25523 return self if other_ivars.empty?
  223. 209137 return self if other_ivars.all? { |ivar| instance_variable_get(ivar) == access_option(other, ivar, ivar_map) }
  224. 9350 opts = dup
  225. 9350 other_ivars.each do |ivar|
  226. 69109 v = access_option(other, ivar, ivar_map)
  227. 69109 unless v
  228. 2626 opts.instance_variable_set(ivar, v)
  229. 2626 next
  230. end
  231. 66483 v = opts.__send__(:"option_#{ivar[1..-1]}", v)
  232. 66471 orig_v = instance_variable_get(ivar)
  233. 66471 v = orig_v.merge(v) if orig_v.respond_to?(:merge) && v.respond_to?(:merge)
  234. 66471 opts.instance_variable_set(ivar, v)
  235. end
  236. 9338 opts
  237. end
  238. 23 def to_hash
  239. 2513 instance_variables.each_with_object({}) do |ivar, hs|
  240. 66109 hs[ivar[1..-1].to_sym] = instance_variable_get(ivar)
  241. end
  242. end
  243. 23 def extend_with_plugin_classes(pl)
  244. 5249 if defined?(pl::RequestMethods) || defined?(pl::RequestClassMethods)
  245. 1487 @request_class = @request_class.dup
  246. 1487 @request_class.__send__(:include, pl::RequestMethods) if defined?(pl::RequestMethods)
  247. 1487 @request_class.extend(pl::RequestClassMethods) if defined?(pl::RequestClassMethods)
  248. end
  249. 5249 if defined?(pl::ResponseMethods) || defined?(pl::ResponseClassMethods)
  250. 1658 @response_class = @response_class.dup
  251. 1658 @response_class.__send__(:include, pl::ResponseMethods) if defined?(pl::ResponseMethods)
  252. 1658 @response_class.extend(pl::ResponseClassMethods) if defined?(pl::ResponseClassMethods)
  253. end
  254. 5249 if defined?(pl::HeadersMethods) || defined?(pl::HeadersClassMethods)
  255. 114 @headers_class = @headers_class.dup
  256. 114 @headers_class.__send__(:include, pl::HeadersMethods) if defined?(pl::HeadersMethods)
  257. 114 @headers_class.extend(pl::HeadersClassMethods) if defined?(pl::HeadersClassMethods)
  258. end
  259. 5249 if defined?(pl::RequestBodyMethods) || defined?(pl::RequestBodyClassMethods)
  260. 249 @request_body_class = @request_body_class.dup
  261. 249 @request_body_class.__send__(:include, pl::RequestBodyMethods) if defined?(pl::RequestBodyMethods)
  262. 249 @request_body_class.extend(pl::RequestBodyClassMethods) if defined?(pl::RequestBodyClassMethods)
  263. end
  264. 5249 if defined?(pl::ResponseBodyMethods) || defined?(pl::ResponseBodyClassMethods)
  265. 601 @response_body_class = @response_body_class.dup
  266. 601 @response_body_class.__send__(:include, pl::ResponseBodyMethods) if defined?(pl::ResponseBodyMethods)
  267. 601 @response_body_class.extend(pl::ResponseBodyClassMethods) if defined?(pl::ResponseBodyClassMethods)
  268. end
  269. 5249 if defined?(pl::PoolMethods)
  270. 427 @pool_class = @pool_class.dup
  271. 427 @pool_class.__send__(:include, pl::PoolMethods)
  272. end
  273. 5249 if defined?(pl::ConnectionMethods)
  274. 2209 @connection_class = @connection_class.dup
  275. 2209 @connection_class.__send__(:include, pl::ConnectionMethods)
  276. end
  277. 5249 return unless defined?(pl::OptionsMethods)
  278. 2161 @options_class = @options_class.dup
  279. 2161 @options_class.__send__(:include, pl::OptionsMethods)
  280. end
  281. 23 private
  282. 23 def do_initialize(options = {})
  283. 3377 defaults = DEFAULT_OPTIONS.merge(options)
  284. 3377 defaults.each do |k, v|
  285. 98644 next if v.nil?
  286. 88513 option_method_name = :"option_#{k}"
  287. 88513 raise Error, "unknown option: #{k}" unless respond_to?(option_method_name)
  288. 88507 value = __send__(option_method_name, v)
  289. 88501 instance_variable_set(:"@#{k}", value)
  290. end
  291. end
  292. 23 def access_option(obj, k, ivar_map)
  293. 262663 case obj
  294. when Hash
  295. 20537 obj[ivar_map[k]]
  296. else
  297. 242126 obj.instance_variable_get(k)
  298. end
  299. end
  300. end
  301. end

lib/httpx/parser/http1.rb

100.0% lines covered

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

lib/httpx/plugins/auth.rb

100.0% lines covered

9 relevant lines. 9 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 6 module HTTPX
  3. 6 module Plugins
  4. #
  5. # This plugin adds a shim +authorization+ method to the session, which will fill
  6. # the HTTP Authorization header, and another, +bearer_auth+, which fill the "Bearer " prefix
  7. # in its value.
  8. #
  9. # https://gitlab.com/os85/httpx/wikis/Auth#auth
  10. #
  11. 6 module Auth
  12. 6 module InstanceMethods
  13. 6 def authorization(token)
  14. 108 with(headers: { "authorization" => token })
  15. end
  16. 6 def bearer_auth(token)
  17. 12 authorization("Bearer #{token}")
  18. end
  19. end
  20. end
  21. 6 register_plugin :auth, Auth
  22. end
  23. end

lib/httpx/plugins/auth/basic.rb

100.0% lines covered

10 relevant lines. 10 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 7 require "httpx/base64"
  3. 7 module HTTPX
  4. 7 module Plugins
  5. 7 module Authentication
  6. 7 class Basic
  7. 7 def initialize(user, password, **)
  8. 207 @user = user
  9. 207 @password = password
  10. end
  11. 7 def authenticate(*)
  12. 193 "Basic #{Base64.strict_encode64("#{@user}:#{@password}")}"
  13. end
  14. end
  15. end
  16. end
  17. end

lib/httpx/plugins/auth/digest.rb

100.0% lines covered

57 relevant lines. 57 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 6 require "time"
  3. 6 require "securerandom"
  4. 6 require "digest"
  5. 6 module HTTPX
  6. 6 module Plugins
  7. 6 module Authentication
  8. 6 class Digest
  9. 6 def initialize(user, password, hashed: false, **)
  10. 132 @user = user
  11. 132 @password = password
  12. 132 @nonce = 0
  13. 132 @hashed = hashed
  14. end
  15. 6 def can_authenticate?(authenticate)
  16. 120 authenticate && /Digest .*/.match?(authenticate)
  17. end
  18. 6 def authenticate(request, authenticate)
  19. 120 "Digest #{generate_header(request.verb, request.path, authenticate)}"
  20. end
  21. 6 private
  22. 6 def generate_header(meth, uri, authenticate)
  23. # discard first token, it's Digest
  24. 120 auth_info = authenticate[/^(\w+) (.*)/, 2]
  25. 120 params = auth_info.split(/ *, */)
  26. 624 .to_h { |val| val.split("=", 2) }
  27. 624 .transform_values { |v| v.delete("\"") }
  28. 120 nonce = params["nonce"]
  29. 120 nc = next_nonce
  30. # verify qop
  31. 120 qop = params["qop"]
  32. 120 if params["algorithm"] =~ /(.*?)(-sess)?$/
  33. 108 alg = Regexp.last_match(1)
  34. 108 algorithm = ::Digest.const_get(alg)
  35. 108 raise DigestError, "unknown algorithm \"#{alg}\"" unless algorithm
  36. 108 sess = Regexp.last_match(2)
  37. else
  38. 12 algorithm = ::Digest::MD5
  39. end
  40. 120 if qop || sess
  41. 120 cnonce = make_cnonce
  42. 120 nc = format("%<nonce>08x", nonce: nc)
  43. end
  44. 120 a1 = if sess
  45. [
  46. 24 (@hashed ? @password : algorithm.hexdigest("#{@user}:#{params["realm"]}:#{@password}")),
  47. nonce,
  48. cnonce,
  49. 3 ].join ":"
  50. else
  51. 96 @hashed ? @password : "#{@user}:#{params["realm"]}:#{@password}"
  52. end
  53. 120 ha1 = algorithm.hexdigest(a1)
  54. 120 ha2 = algorithm.hexdigest("#{meth}:#{uri}")
  55. 120 request_digest = [ha1, nonce]
  56. 120 request_digest.push(nc, cnonce, qop) if qop
  57. 120 request_digest << ha2
  58. 120 request_digest = request_digest.join(":")
  59. 20 header = [
  60. 100 %(username="#{@user}"),
  61. %(nonce="#{nonce}"),
  62. %(uri="#{uri}"),
  63. %(response="#{algorithm.hexdigest(request_digest)}"),
  64. ]
  65. 120 header << %(realm="#{params["realm"]}") if params.key?("realm")
  66. 120 header << %(algorithm=#{params["algorithm"]}) if params.key?("algorithm")
  67. 120 header << %(cnonce="#{cnonce}") if cnonce
  68. 120 header << %(nc=#{nc})
  69. 120 header << %(qop=#{qop}) if qop
  70. 120 header << %(opaque="#{params["opaque"]}") if params.key?("opaque")
  71. 120 header.join ", "
  72. end
  73. 6 def make_cnonce
  74. 140 ::Digest::MD5.hexdigest [
  75. Time.now.to_i,
  76. Process.pid,
  77. SecureRandom.random_number(2**32),
  78. ].join ":"
  79. end
  80. 6 def next_nonce
  81. 120 @nonce += 1
  82. end
  83. end
  84. end
  85. end
  86. end

lib/httpx/plugins/auth/ntlm.rb

100.0% lines covered

19 relevant lines. 19 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 5 require "httpx/base64"
  3. 5 require "ntlm"
  4. 5 module HTTPX
  5. 5 module Plugins
  6. 5 module Authentication
  7. 5 class Ntlm
  8. 5 def initialize(user, password, domain: nil)
  9. 4 @user = user
  10. 4 @password = password
  11. 4 @domain = domain
  12. end
  13. 5 def can_authenticate?(authenticate)
  14. 2 authenticate && /NTLM .*/.match?(authenticate)
  15. end
  16. 5 def negotiate
  17. 4 "NTLM #{NTLM.negotiate(domain: @domain).to_base64}"
  18. end
  19. 5 def authenticate(_req, www)
  20. 2 challenge = www[/NTLM (.*)/, 1]
  21. 2 challenge = Base64.decode64(challenge)
  22. 2 ntlm_challenge = NTLM.authenticate(challenge, @user, @domain, @password).to_base64
  23. 2 "NTLM #{ntlm_challenge}"
  24. end
  25. end
  26. end
  27. end
  28. end

lib/httpx/plugins/auth/socks5.rb

100.0% lines covered

11 relevant lines. 11 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 8 module HTTPX
  3. 8 module Plugins
  4. 8 module Authentication
  5. 8 class Socks5
  6. 8 def initialize(user, password, **)
  7. 36 @user = user
  8. 36 @password = password
  9. end
  10. 8 def can_authenticate?(*)
  11. 36 @user && @password
  12. end
  13. 8 def authenticate(*)
  14. 36 [0x01, @user.bytesize, @user, @password.bytesize, @password].pack("CCA*CA*")
  15. end
  16. end
  17. end
  18. end
  19. end

lib/httpx/plugins/aws_sdk_authentication.rb

100.0% lines covered

43 relevant lines. 43 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 6 module HTTPX
  3. 6 module Plugins
  4. #
  5. # This plugin applies AWS Sigv4 to requests, using the AWS SDK credentials and configuration.
  6. #
  7. # It requires the "aws-sdk-core" gem.
  8. #
  9. 6 module AwsSdkAuthentication
  10. # Mock configuration, to be used only when resolving credentials
  11. 6 class Configuration
  12. 6 attr_reader :profile
  13. 6 def initialize(profile)
  14. 24 @profile = profile
  15. end
  16. 6 def respond_to_missing?(*)
  17. 12 true
  18. end
  19. 6 def method_missing(*); end
  20. end
  21. #
  22. # encapsulates access to an AWS SDK credentials store.
  23. #
  24. 6 class Credentials
  25. 6 def initialize(aws_credentials)
  26. 12 @aws_credentials = aws_credentials
  27. end
  28. 6 def username
  29. 12 @aws_credentials.access_key_id
  30. end
  31. 6 def password
  32. 12 @aws_credentials.secret_access_key
  33. end
  34. 6 def security_token
  35. 12 @aws_credentials.session_token
  36. end
  37. end
  38. 6 class << self
  39. 6 def load_dependencies(_klass)
  40. 12 require "aws-sdk-core"
  41. end
  42. 6 def configure(klass)
  43. 12 klass.plugin(:aws_sigv4)
  44. end
  45. 6 def extra_options(options)
  46. 12 options.merge(max_concurrent_requests: 1)
  47. end
  48. 6 def credentials(profile)
  49. 12 mock_configuration = Configuration.new(profile)
  50. 12 Credentials.new(Aws::CredentialProviderChain.new(mock_configuration).resolve)
  51. end
  52. 6 def region(profile)
  53. # https://github.com/aws/aws-sdk-ruby/blob/version-3/gems/aws-sdk-core/lib/aws-sdk-core/plugins/regional_endpoint.rb#L62
  54. 12 keys = %w[AWS_REGION AMAZON_REGION AWS_DEFAULT_REGION]
  55. 12 env_region = ENV.values_at(*keys).compact.first
  56. 12 env_region = nil if env_region == ""
  57. 12 cfg_region = Aws.shared_config.region(profile: profile)
  58. 12 env_region || cfg_region
  59. end
  60. end
  61. # adds support for the following options:
  62. #
  63. # :aws_profile :: AWS account profile to retrieve credentials from.
  64. 6 module OptionsMethods
  65. 6 def option_aws_profile(value)
  66. 60 String(value)
  67. end
  68. end
  69. 6 module InstanceMethods
  70. #
  71. # aws_authentication
  72. # aws_authentication(credentials: Aws::Credentials.new('akid', 'secret'))
  73. # aws_authentication()
  74. #
  75. 6 def aws_sdk_authentication(
  76. credentials: AwsSdkAuthentication.credentials(@options.aws_profile),
  77. region: AwsSdkAuthentication.region(@options.aws_profile),
  78. **options
  79. )
  80. 12 aws_sigv4_authentication(
  81. credentials: credentials,
  82. region: region,
  83. provider_prefix: "aws",
  84. header_provider_field: "amz",
  85. **options
  86. )
  87. end
  88. 6 alias_method :aws_auth, :aws_sdk_authentication
  89. end
  90. end
  91. 6 register_plugin :aws_sdk_authentication, AwsSdkAuthentication
  92. end
  93. end

lib/httpx/plugins/aws_sigv4.rb

100.0% lines covered

105 relevant lines. 105 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 6 module HTTPX
  3. 6 module Plugins
  4. #
  5. # This plugin adds AWS Sigv4 authentication.
  6. #
  7. # https://docs.aws.amazon.com/IAM/latest/UserGuide/signing-elements.html
  8. #
  9. # https://gitlab.com/os85/httpx/wikis/AWS-SigV4
  10. #
  11. 6 module AWSSigV4
  12. 6 Credentials = Struct.new(:username, :password, :security_token)
  13. # Signs requests using the AWS sigv4 signing.
  14. 6 class Signer
  15. 6 def initialize(
  16. service:,
  17. region:,
  18. credentials: nil,
  19. username: nil,
  20. password: nil,
  21. security_token: nil,
  22. provider_prefix: "aws",
  23. header_provider_field: "amz",
  24. unsigned_headers: [],
  25. apply_checksum_header: true,
  26. algorithm: "SHA256"
  27. )
  28. 114 @credentials = credentials || Credentials.new(username, password, security_token)
  29. 114 @service = service
  30. 114 @region = region
  31. 114 @unsigned_headers = Set.new(unsigned_headers.map(&:downcase))
  32. 114 @unsigned_headers << "authorization"
  33. 114 @unsigned_headers << "x-amzn-trace-id"
  34. 114 @unsigned_headers << "expect"
  35. 114 @apply_checksum_header = apply_checksum_header
  36. 114 @provider_prefix = provider_prefix
  37. 114 @header_provider_field = header_provider_field
  38. 114 @algorithm = algorithm
  39. end
  40. 6 def sign!(request)
  41. 114 lower_provider_prefix = "#{@provider_prefix}4"
  42. 114 upper_provider_prefix = lower_provider_prefix.upcase
  43. 114 downcased_algorithm = @algorithm.downcase
  44. 114 datetime = (request.headers["x-#{@header_provider_field}-date"] ||= Time.now.utc.strftime("%Y%m%dT%H%M%SZ"))
  45. 114 date = datetime[0, 8]
  46. 114 content_hashed = request.headers["x-#{@header_provider_field}-content-#{downcased_algorithm}"] || hexdigest(request.body)
  47. 108 request.headers["x-#{@header_provider_field}-content-#{downcased_algorithm}"] ||= content_hashed if @apply_checksum_header
  48. 108 request.headers["x-#{@header_provider_field}-security-token"] ||= @credentials.security_token if @credentials.security_token
  49. 108 signature_headers = request.headers.each.reject do |k, _|
  50. 738 @unsigned_headers.include?(k)
  51. end
  52. # aws sigv4 needs to declare the host, regardless of protocol version
  53. 108 signature_headers << ["host", request.authority] unless request.headers.key?("host")
  54. 108 signature_headers.sort_by!(&:first)
  55. 108 signed_headers = signature_headers.map(&:first).join(";")
  56. 108 canonical_headers = signature_headers.map do |k, v|
  57. # eliminate whitespace between value fields, unless it's a quoted value
  58. 726 "#{k}:#{v.start_with?("\"") && v.end_with?("\"") ? v : v.gsub(/\s+/, " ").strip}\n"
  59. end.join
  60. # canonical request
  61. 108 creq = "#{request.verb}" \
  62. "\n#{request.canonical_path}" \
  63. "\n#{request.canonical_query}" \
  64. "\n#{canonical_headers}" \
  65. "\n#{signed_headers}" \
  66. "\n#{content_hashed}"
  67. 108 credential_scope = "#{date}" \
  68. "/#{@region}" \
  69. "/#{@service}" \
  70. "/#{lower_provider_prefix}_request"
  71. 108 algo_line = "#{upper_provider_prefix}-HMAC-#{@algorithm}"
  72. # string to sign
  73. 108 sts = "#{algo_line}" \
  74. "\n#{datetime}" \
  75. "\n#{credential_scope}" \
  76. "\n#{OpenSSL::Digest.new(@algorithm).hexdigest(creq)}"
  77. # signature
  78. 108 k_date = hmac("#{upper_provider_prefix}#{@credentials.password}", date)
  79. 108 k_region = hmac(k_date, @region)
  80. 108 k_service = hmac(k_region, @service)
  81. 108 k_credentials = hmac(k_service, "#{lower_provider_prefix}_request")
  82. 108 sig = hexhmac(k_credentials, sts)
  83. 108 credential = "#{@credentials.username}/#{credential_scope}"
  84. # apply signature
  85. 108 request.headers["authorization"] =
  86. "#{algo_line} " \
  87. "Credential=#{credential}, " \
  88. "SignedHeaders=#{signed_headers}, " \
  89. "Signature=#{sig}"
  90. end
  91. 6 private
  92. 6 def hexdigest(value)
  93. 108 digest = OpenSSL::Digest.new(@algorithm)
  94. 108 if value.respond_to?(:read)
  95. 24 if value.respond_to?(:to_path)
  96. # files, pathnames
  97. 6 digest.file(value.to_path).hexdigest
  98. else
  99. # gzipped request bodies
  100. 18 raise Error, "request body must be rewindable" unless value.respond_to?(:rewind)
  101. 18 buffer = Tempfile.new("httpx", encoding: Encoding::BINARY, mode: File::RDWR)
  102. 2 begin
  103. 18 IO.copy_stream(value, buffer)
  104. 18 buffer.flush
  105. 18 digest.file(buffer.to_path).hexdigest
  106. ensure
  107. 18 value.rewind
  108. 18 buffer.close
  109. 18 buffer.unlink
  110. end
  111. end
  112. else
  113. # error on endless generators
  114. 84 raise Error, "hexdigest for endless enumerators is not supported" if value.unbounded_body?
  115. 78 mb_buffer = value.each.with_object("".b) do |chunk, b|
  116. 42 b << chunk
  117. 42 break if b.bytesize >= 1024 * 1024
  118. end
  119. 78 digest.hexdigest(mb_buffer)
  120. end
  121. end
  122. 6 def hmac(key, value)
  123. 432 OpenSSL::HMAC.digest(OpenSSL::Digest.new(@algorithm), key, value)
  124. end
  125. 6 def hexhmac(key, value)
  126. 108 OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new(@algorithm), key, value)
  127. end
  128. end
  129. 6 class << self
  130. 6 def load_dependencies(*)
  131. 114 require "set"
  132. 114 require "digest/sha2"
  133. end
  134. 6 def configure(klass)
  135. 114 klass.plugin(:expect)
  136. end
  137. end
  138. # adds support for the following options:
  139. #
  140. # :sigv4_signer :: instance of HTTPX::Plugins::AWSSigV4 used to sign requests.
  141. 6 module OptionsMethods
  142. 6 def option_sigv4_signer(value)
  143. 240 value.is_a?(Signer) ? value : Signer.new(value)
  144. end
  145. end
  146. 6 module InstanceMethods
  147. 6 def aws_sigv4_authentication(**options)
  148. 114 with(sigv4_signer: Signer.new(**options))
  149. end
  150. 6 def build_request(*)
  151. 114 request = super
  152. 114 return request if request.headers.key?("authorization")
  153. 114 signer = request.options.sigv4_signer
  154. 114 return request unless signer
  155. 114 signer.sign!(request)
  156. 108 request
  157. end
  158. end
  159. 6 module RequestMethods
  160. 6 def canonical_path
  161. 108 path = uri.path.dup
  162. 108 path << "/" if path.empty?
  163. 132 path.gsub(%r{[^/]+}) { |part| CGI.escape(part.encode("UTF-8")).gsub("+", "%20").gsub("%7E", "~") }
  164. end
  165. 6 def canonical_query
  166. 132 params = query.split("&")
  167. # params = params.map { |p| p.match(/=/) ? p : p + '=' }
  168. # From: https://docs.aws.amazon.com/IAM/latest/UserGuide/create-signed-request.html#create-canonical-request
  169. # Sort the parameter names by character code point in ascending order.
  170. # Parameters with duplicate names should be sorted by value.
  171. #
  172. # Default sort <=> in JRuby will swap members
  173. # occasionally when <=> is 0 (considered still sorted), but this
  174. # causes our normalized query string to not match the sent querystring.
  175. # When names match, we then sort by their values. When values also
  176. # match then we sort by their original order
  177. 132 params.each.with_index.sort do |a, b|
  178. 48 a, a_offset = a
  179. 48 b, b_offset = b
  180. 48 a_name, a_value = a.split("=", 2)
  181. 48 b_name, b_value = b.split("=", 2)
  182. 48 if a_name == b_name
  183. 24 if a_value == b_value
  184. 12 a_offset <=> b_offset
  185. else
  186. 12 a_value <=> b_value
  187. end
  188. else
  189. 24 a_name <=> b_name
  190. end
  191. end.map(&:first).join("&")
  192. end
  193. end
  194. end
  195. 6 register_plugin :aws_sigv4, AWSSigV4
  196. end
  197. end

lib/httpx/plugins/basic_auth.rb

100.0% lines covered

12 relevant lines. 12 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 6 module HTTPX
  3. 6 module Plugins
  4. #
  5. # This plugin adds helper methods to implement HTTP Basic Auth (https://datatracker.ietf.org/doc/html/rfc7617)
  6. #
  7. # https://gitlab.com/os85/httpx/wikis/Auth#basic-auth
  8. #
  9. 6 module BasicAuth
  10. 6 class << self
  11. 6 def load_dependencies(_klass)
  12. 84 require_relative "auth/basic"
  13. end
  14. 6 def configure(klass)
  15. 84 klass.plugin(:auth)
  16. end
  17. end
  18. 6 module InstanceMethods
  19. 6 def basic_auth(user, password)
  20. 96 authorization(Authentication::Basic.new(user, password).authenticate)
  21. end
  22. end
  23. end
  24. 6 register_plugin :basic_auth, BasicAuth
  25. end
  26. end

lib/httpx/plugins/brotli.rb

100.0% lines covered

25 relevant lines. 25 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 5 module HTTPX
  3. 5 module Plugins
  4. 5 module Brotli
  5. 5 class Deflater < Transcoder::Deflater
  6. 5 def deflate(chunk)
  7. 20 return unless chunk
  8. 10 ::Brotli.deflate(chunk)
  9. end
  10. end
  11. 5 module RequestBodyClassMethods
  12. 5 def initialize_deflater_body(body, encoding)
  13. 20 return Brotli.encode(body) if encoding == "br"
  14. 10 super
  15. end
  16. end
  17. 5 module ResponseBodyClassMethods
  18. 5 def initialize_inflater_by_encoding(encoding, response, **kwargs)
  19. 20 return Brotli.decode(response, **kwargs) if encoding == "br"
  20. 10 super
  21. end
  22. end
  23. 5 module_function
  24. 5 def load_dependencies(*)
  25. 20 require "brotli"
  26. end
  27. 5 def self.extra_options(options)
  28. 20 options.merge(supported_compression_formats: %w[br] + options.supported_compression_formats)
  29. end
  30. 5 def encode(body)
  31. 10 Deflater.new(body)
  32. end
  33. 5 def decode(_response, **)
  34. 10 ::Brotli.method(:inflate)
  35. end
  36. end
  37. 5 register_plugin :brotli, Brotli
  38. end
  39. end

lib/httpx/plugins/callbacks.rb

100.0% lines covered

46 relevant lines. 46 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 23 module HTTPX
  3. 23 module Plugins
  4. #
  5. # This plugin adds suppoort for callbacks around the request/response lifecycle.
  6. #
  7. # https://gitlab.com/os85/httpx/-/wikis/Events
  8. #
  9. 23 module Callbacks
  10. # connection closed user-space errors happen after errors can be surfaced to requests,
  11. # so they need to pierce through the scheduler, which is only possible by simulating an
  12. # interrupt.
  13. 23 class CallbackError < Exception; end # rubocop:disable Lint/InheritException
  14. 23 module InstanceMethods
  15. 23 include HTTPX::Callbacks
  16. 23 %i[
  17. connection_opened connection_closed
  18. request_error
  19. request_started request_body_chunk request_completed
  20. response_started response_body_chunk response_completed
  21. ].each do |meth|
  22. 207 class_eval(<<-MOD, __FILE__, __LINE__ + 1)
  23. def on_#{meth}(&blk) # def on_connection_opened(&blk)
  24. on(:#{meth}, &blk) # on(:connection_opened, &blk)
  25. end # end
  26. MOD
  27. end
  28. 23 private
  29. 23 def do_init_connection(connection, selector)
  30. 157 super
  31. 157 connection.on(:open) do
  32. 147 next unless connection.current_session == self
  33. 147 emit_or_callback_error(:connection_opened, connection.origin, connection.io.socket)
  34. end
  35. 157 connection.on(:close) do
  36. 140 next unless connection.current_session == self
  37. 140 emit_or_callback_error(:connection_closed, connection.origin) if connection.used?
  38. end
  39. 157 connection
  40. end
  41. 23 def set_request_callbacks(request)
  42. 159 super
  43. 159 request.on(:headers) do
  44. 135 emit_or_callback_error(:request_started, request)
  45. end
  46. 159 request.on(:body_chunk) do |chunk|
  47. 12 emit_or_callback_error(:request_body_chunk, request, chunk)
  48. end
  49. 159 request.on(:done) do
  50. 123 emit_or_callback_error(:request_completed, request)
  51. end
  52. 159 request.on(:response_started) do |res|
  53. 123 if res.is_a?(Response)
  54. 111 emit_or_callback_error(:response_started, request, res)
  55. 99 res.on(:chunk_received) do |chunk|
  56. 114 emit_or_callback_error(:response_body_chunk, request, res, chunk)
  57. end
  58. else
  59. 12 emit_or_callback_error(:request_error, request, res.error)
  60. end
  61. end
  62. 159 request.on(:response) do |res|
  63. 99 emit_or_callback_error(:response_completed, request, res)
  64. end
  65. end
  66. 23 def emit_or_callback_error(*args)
  67. 881 emit(*args)
  68. rescue StandardError => e
  69. 84 ex = CallbackError.new(e.message)
  70. 84 ex.set_backtrace(e.backtrace)
  71. 84 raise ex
  72. end
  73. 23 def receive_requests(*)
  74. 159 super
  75. rescue CallbackError => e
  76. 78 raise e.cause
  77. end
  78. 23 def close(*)
  79. 157 super
  80. rescue CallbackError => e
  81. 6 raise e.cause
  82. end
  83. end
  84. end
  85. 23 register_plugin :callbacks, Callbacks
  86. end
  87. end

lib/httpx/plugins/circuit_breaker.rb

100.0% lines covered

63 relevant lines. 63 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 6 module HTTPX
  3. 6 module Plugins
  4. #
  5. # This plugin implements a circuit breaker around connection errors.
  6. #
  7. # https://gitlab.com/os85/httpx/wikis/Circuit-Breaker
  8. #
  9. 6 module CircuitBreaker
  10. 6 using URIExtensions
  11. 6 def self.load_dependencies(*)
  12. 42 require_relative "circuit_breaker/circuit"
  13. 42 require_relative "circuit_breaker/circuit_store"
  14. end
  15. 6 def self.extra_options(options)
  16. 42 options.merge(
  17. circuit_breaker_max_attempts: 3,
  18. circuit_breaker_reset_attempts_in: 60,
  19. circuit_breaker_break_in: 60,
  20. circuit_breaker_half_open_drip_rate: 1
  21. )
  22. end
  23. 6 module InstanceMethods
  24. 6 include HTTPX::Callbacks
  25. 6 def initialize(*)
  26. 42 super
  27. 42 @circuit_store = CircuitStore.new(@options)
  28. end
  29. 6 %i[circuit_open].each do |meth|
  30. 6 class_eval(<<-MOD, __FILE__, __LINE__ + 1)
  31. def on_#{meth}(&blk) # def on_circuit_open(&blk)
  32. on(:#{meth}, &blk) # on(:circuit_open, &blk)
  33. end # end
  34. MOD
  35. end
  36. 6 private
  37. 6 def send_requests(*requests)
  38. # @type var short_circuit_responses: Array[response]
  39. 168 short_circuit_responses = []
  40. # run all requests through the circuit breaker, see if the circuit is
  41. # open for any of them.
  42. 168 real_requests = requests.each_with_index.with_object([]) do |(req, idx), real_reqs|
  43. 168 short_circuit_response = @circuit_store.try_respond(req)
  44. 168 if short_circuit_response.nil?
  45. 132 real_reqs << req
  46. 132 next
  47. end
  48. 36 short_circuit_responses[idx] = short_circuit_response
  49. end
  50. # run requests for the remainder
  51. 168 unless real_requests.empty?
  52. 132 responses = super(*real_requests)
  53. 132 real_requests.each_with_index do |request, idx|
  54. 132 short_circuit_responses[requests.index(request)] = responses[idx]
  55. end
  56. end
  57. 168 short_circuit_responses
  58. end
  59. 6 def on_response(request, response)
  60. 132 emit(:circuit_open, request) if try_circuit_open(request, response)
  61. 132 super
  62. end
  63. 6 def try_circuit_open(request, response)
  64. 132 if response.is_a?(ErrorResponse)
  65. 96 case response.error
  66. when RequestTimeoutError
  67. 60 @circuit_store.try_open(request.uri, response)
  68. else
  69. 36 @circuit_store.try_open(request.origin, response)
  70. end
  71. 36 elsif (break_on = request.options.circuit_breaker_break_on) && break_on.call(response)
  72. 12 @circuit_store.try_open(request.uri, response)
  73. else
  74. 24 @circuit_store.try_close(request.uri)
  75. 4 nil
  76. end
  77. end
  78. end
  79. # adds support for the following options:
  80. #
  81. # :circuit_breaker_max_attempts :: the number of attempts the circuit allows, before it is opened (defaults to <tt>3</tt>).
  82. # :circuit_breaker_reset_attempts_in :: the time a circuit stays open at most, before it resets (defaults to <tt>60</tt>).
  83. # :circuit_breaker_break_on :: callable defining an alternative rule for a response to break
  84. # (i.e. <tt>->(res) { res.status == 429 } </tt>)
  85. # :circuit_breaker_break_in :: the time that must elapse before an open circuit can transit to the half-open state
  86. # (defaults to <tt><60</tt>).
  87. # :circuit_breaker_half_open_drip_rate :: the rate of requests a circuit allows to be performed when in an half-open state
  88. # (defaults to <tt>1</tt>).
  89. 6 module OptionsMethods
  90. 6 def option_circuit_breaker_max_attempts(value)
  91. 84 attempts = Integer(value)
  92. 84 raise TypeError, ":circuit_breaker_max_attempts must be positive" unless attempts.positive?
  93. 84 attempts
  94. end
  95. 6 def option_circuit_breaker_reset_attempts_in(value)
  96. 48 timeout = Float(value)
  97. 48 raise TypeError, ":circuit_breaker_reset_attempts_in must be positive" unless timeout.positive?
  98. 48 timeout
  99. end
  100. 6 def option_circuit_breaker_break_in(value)
  101. 66 timeout = Float(value)
  102. 66 raise TypeError, ":circuit_breaker_break_in must be positive" unless timeout.positive?
  103. 66 timeout
  104. end
  105. 6 def option_circuit_breaker_half_open_drip_rate(value)
  106. 66 ratio = Float(value)
  107. 66 raise TypeError, ":circuit_breaker_half_open_drip_rate must be a number between 0 and 1" unless (0..1).cover?(ratio)
  108. 66 ratio
  109. end
  110. 6 def option_circuit_breaker_break_on(value)
  111. 12 raise TypeError, ":circuit_breaker_break_on must be called with the response" unless value.respond_to?(:call)
  112. 12 value
  113. end
  114. end
  115. end
  116. 6 register_plugin :circuit_breaker, CircuitBreaker
  117. end
  118. end

lib/httpx/plugins/circuit_breaker/circuit.rb

100.0% lines covered

47 relevant lines. 47 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 6 module HTTPX
  3. 6 module Plugins::CircuitBreaker
  4. #
  5. # A circuit is assigned to a given absoolute url or origin.
  6. #
  7. # It sets +max_attempts+, the number of attempts the circuit allows, before it is opened.
  8. # It sets +reset_attempts_in+, the time a circuit stays open at most, before it resets.
  9. # It sets +break_in+, the time that must elapse before an open circuit can transit to the half-open state.
  10. # It sets +circuit_breaker_half_open_drip_rate+, the rate of requests a circuit allows to be performed when in an half-open state.
  11. #
  12. 6 class Circuit
  13. 6 def initialize(max_attempts, reset_attempts_in, break_in, circuit_breaker_half_open_drip_rate)
  14. 42 @max_attempts = max_attempts
  15. 42 @reset_attempts_in = reset_attempts_in
  16. 42 @break_in = break_in
  17. 42 @circuit_breaker_half_open_drip_rate = circuit_breaker_half_open_drip_rate
  18. 42 @attempts = 0
  19. 42 total_real_attempts = @max_attempts * @circuit_breaker_half_open_drip_rate
  20. 42 @drip_factor = (@max_attempts / total_real_attempts).round
  21. 42 @state = :closed
  22. end
  23. 6 def respond
  24. 168 try_close
  25. 168 case @state
  26. when :closed
  27. 17 nil
  28. when :half_open
  29. 42 @attempts += 1
  30. # do real requests while drip rate valid
  31. 42 if (@real_attempts % @drip_factor).zero?
  32. 30 @real_attempts += 1
  33. 30 return
  34. end
  35. 12 @response
  36. when :open
  37. 24 @response
  38. end
  39. end
  40. 6 def try_open(response)
  41. 108 case @state
  42. when :closed
  43. 90 now = Utils.now
  44. 90 if @attempts.positive?
  45. # reset if error happened long ago
  46. 36 @attempts = 0 if now - @attempted_at > @reset_attempts_in
  47. else
  48. 54 @attempted_at = now
  49. end
  50. 90 @attempts += 1
  51. 90 return unless @attempts >= @max_attempts
  52. 48 @state = :open
  53. 48 @opened_at = now
  54. 48 @response = response
  55. when :half_open
  56. # open immediately
  57. 18 @state = :open
  58. 18 @attempted_at = @opened_at = Utils.now
  59. 18 @response = response
  60. end
  61. end
  62. 6 def try_close
  63. 192 case @state
  64. when :closed
  65. 17 nil
  66. when :half_open
  67. # do not close circuit unless attempts exhausted
  68. 36 return unless @attempts >= @max_attempts
  69. # reset!
  70. 12 @attempts = 0
  71. 12 @opened_at = @attempted_at = @response = nil
  72. 12 @state = :closed
  73. when :open
  74. 54 if Utils.elapsed_time(@opened_at) > @break_in
  75. 30 @state = :half_open
  76. 30 @attempts = 0
  77. 30 @real_attempts = 0
  78. end
  79. end
  80. end
  81. end
  82. end
  83. end

lib/httpx/plugins/circuit_breaker/circuit_store.rb

100.0% lines covered

23 relevant lines. 23 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 6 module HTTPX::Plugins::CircuitBreaker
  3. 6 using HTTPX::URIExtensions
  4. 6 class CircuitStore
  5. 6 def initialize(options)
  6. 42 @circuits = Hash.new do |h, k|
  7. 42 h[k] = Circuit.new(
  8. options.circuit_breaker_max_attempts,
  9. options.circuit_breaker_reset_attempts_in,
  10. options.circuit_breaker_break_in,
  11. options.circuit_breaker_half_open_drip_rate
  12. )
  13. end
  14. 42 @circuits_mutex = Thread::Mutex.new
  15. end
  16. 6 def try_open(uri, response)
  17. 216 circuit = @circuits_mutex.synchronize { get_circuit_for_uri(uri) }
  18. 108 circuit.try_open(response)
  19. end
  20. 6 def try_close(uri)
  21. 24 circuit = @circuits_mutex.synchronize do
  22. 24 return unless @circuits.key?(uri.origin) || @circuits.key?(uri.to_s)
  23. 24 get_circuit_for_uri(uri)
  24. end
  25. 24 circuit.try_close
  26. end
  27. # if circuit is open, it'll respond with the stored response.
  28. # if not, nil.
  29. 6 def try_respond(request)
  30. 336 circuit = @circuits_mutex.synchronize { get_circuit_for_uri(request.uri) }
  31. 168 circuit.respond
  32. end
  33. 6 private
  34. 6 def get_circuit_for_uri(uri)
  35. 300 if uri.respond_to?(:origin) && @circuits.key?(uri.origin)
  36. 216 @circuits[uri.origin]
  37. else
  38. 84 @circuits[uri.to_s]
  39. end
  40. end
  41. end
  42. end

lib/httpx/plugins/content_digest.rb

100.0% lines covered

100 relevant lines. 100 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 6 module HTTPX
  3. 6 module Plugins
  4. #
  5. # This plugin adds `Content-Digest` headers to requests
  6. # and can validate these headers on responses
  7. #
  8. # https://datatracker.ietf.org/doc/html/rfc9530
  9. #
  10. 6 module ContentDigest
  11. 6 class Error < HTTPX::Error; end
  12. # Error raised on response "content-digest" header validation.
  13. 6 class ValidationError < Error
  14. 6 attr_reader :response
  15. 6 def initialize(message, response)
  16. 36 super(message)
  17. 36 @response = response
  18. end
  19. end
  20. 6 class MissingContentDigestError < ValidationError; end
  21. 6 class InvalidContentDigestError < ValidationError; end
  22. 1 SUPPORTED_ALGORITHMS = {
  23. 5 "sha-256" => OpenSSL::Digest::SHA256,
  24. "sha-512" => OpenSSL::Digest::SHA512,
  25. }.freeze
  26. 6 class << self
  27. 6 def extra_options(options)
  28. 156 options.merge(encode_content_digest: true, validate_content_digest: false, content_digest_algorithm: "sha-256")
  29. end
  30. end
  31. # add support for the following options:
  32. #
  33. # :content_digest_algorithm :: the digest algorithm to use. Currently supports `sha-256` and `sha-512`. (defaults to `sha-256`)
  34. # :encode_content_digest :: whether a <tt>Content-Digest</tt> header should be computed for the request;
  35. # can also be a callable object (i.e. <tt>->(req) { ... }</tt>, defaults to <tt>true</tt>)
  36. # :validate_content_digest :: whether a <tt>Content-Digest</tt> header in the response should be validated;
  37. # can also be a callable object (i.e. <tt>->(res) { ... }</tt>, defaults to <tt>false</tt>)
  38. 6 module OptionsMethods
  39. 6 def option_content_digest_algorithm(value)
  40. 162 raise TypeError, ":content_digest_algorithm must be one of 'sha-256', 'sha-512'" unless SUPPORTED_ALGORITHMS.key?(value)
  41. 162 value
  42. end
  43. 6 def option_encode_content_digest(value)
  44. 156 value
  45. end
  46. 6 def option_validate_content_digest(value)
  47. 108 value
  48. end
  49. end
  50. 6 module ResponseBodyMethods
  51. 6 attr_reader :content_digest_buffer
  52. 6 def initialize(response, options)
  53. 132 super
  54. 132 return unless response.headers.key?("content-digest")
  55. 96 should_validate = options.validate_content_digest
  56. 96 should_validate = should_validate.call(response) if should_validate.respond_to?(:call)
  57. 96 return unless should_validate
  58. 84 @content_digest_buffer = Response::Buffer.new(
  59. threshold_size: @options.body_threshold_size,
  60. bytesize: @length,
  61. encoding: @encoding
  62. )
  63. end
  64. 6 def write(chunk)
  65. 216 @content_digest_buffer.write(chunk) if @content_digest_buffer
  66. 216 super
  67. end
  68. 6 def close
  69. 84 if @content_digest_buffer
  70. 84 @content_digest_buffer.close
  71. 84 @content_digest_buffer = nil
  72. end
  73. 84 super
  74. end
  75. end
  76. 6 module InstanceMethods
  77. 6 def build_request(*)
  78. 168 request = super
  79. 168 return request if request.empty?
  80. 36 return request if request.headers.key?("content-digest")
  81. 36 perform_encoding = @options.encode_content_digest
  82. 36 perform_encoding = perform_encoding.call(request) if perform_encoding.respond_to?(:call)
  83. 36 return request unless perform_encoding
  84. 30 digest = base64digest(request.body)
  85. 30 request.headers.add("content-digest", "#{@options.content_digest_algorithm}=:#{digest}:")
  86. 30 request
  87. end
  88. 6 private
  89. 6 def fetch_response(request, _, _)
  90. 426 response = super
  91. 426 return response unless response.is_a?(Response)
  92. 132 perform_validation = @options.validate_content_digest
  93. 132 perform_validation = perform_validation.call(response) if perform_validation.respond_to?(:call)
  94. 132 validate_content_digest(response) if perform_validation
  95. 96 response
  96. rescue ValidationError => e
  97. 36 ErrorResponse.new(request, e)
  98. end
  99. 6 def validate_content_digest(response)
  100. 96 content_digest_header = response.headers["content-digest"]
  101. 96 raise MissingContentDigestError.new("response is missing a `content-digest` header", response) unless content_digest_header
  102. 84 digests = extract_content_digests(content_digest_header)
  103. 84 included_algorithms = SUPPORTED_ALGORITHMS.keys & digests.keys
  104. 84 raise MissingContentDigestError.new("unsupported algorithms: #{digests.keys.join(", ")}", response) if included_algorithms.empty?
  105. 84 content_buffer = response.body.content_digest_buffer
  106. 84 included_algorithms.each do |algorithm|
  107. 84 digest = SUPPORTED_ALGORITHMS.fetch(algorithm).new
  108. 84 digest_received = digests[algorithm]
  109. digest_computed =
  110. 83 if content_buffer.respond_to?(:to_path)
  111. 12 content_buffer.flush
  112. 12 digest.file(content_buffer.to_path).base64digest
  113. else
  114. 72 digest.base64digest(content_buffer.to_s)
  115. end
  116. raise InvalidContentDigestError.new("#{algorithm} digest does not match content",
  117. 83 response) unless digest_received == digest_computed
  118. end
  119. end
  120. 6 def extract_content_digests(header)
  121. 84 header.split(",").to_h do |entry|
  122. 96 algorithm, digest = entry.split("=", 2)
  123. 96 raise Error, "#{entry} is an invalid digest format" unless algorithm && digest
  124. 96 [algorithm, digest.byteslice(1..-2)]
  125. end
  126. end
  127. 6 def base64digest(body)
  128. 30 digest = SUPPORTED_ALGORITHMS.fetch(@options.content_digest_algorithm).new
  129. 30 if body.respond_to?(:read)
  130. 24 if body.respond_to?(:to_path)
  131. 6 digest.file(body.to_path).base64digest
  132. else
  133. 18 raise ContentDigestError, "request body must be rewindable" unless body.respond_to?(:rewind)
  134. 18 buffer = Tempfile.new("httpx", encoding: Encoding::BINARY, mode: File::RDWR)
  135. 2 begin
  136. 18 IO.copy_stream(body, buffer)
  137. 18 buffer.flush
  138. 18 digest.file(buffer.to_path).base64digest
  139. ensure
  140. 18 body.rewind
  141. 18 buffer.close
  142. 18 buffer.unlink
  143. end
  144. end
  145. else
  146. 6 raise ContentDigestError, "base64digest for endless enumerators is not supported" if body.unbounded_body?
  147. 6 buffer = "".b
  148. 12 body.each { |chunk| buffer << chunk }
  149. 6 digest.base64digest(buffer)
  150. end
  151. end
  152. end
  153. end
  154. 6 register_plugin :content_digest, ContentDigest
  155. end
  156. end

lib/httpx/plugins/cookies.rb

100.0% lines covered

51 relevant lines. 51 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 6 require "forwardable"
  3. 6 module HTTPX
  4. 6 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/os85/httpx/wikis/Cookies
  11. #
  12. 6 module Cookies
  13. 6 def self.load_dependencies(*)
  14. 108 require "httpx/plugins/cookies/jar"
  15. 108 require "httpx/plugins/cookies/cookie"
  16. 108 require "httpx/plugins/cookies/set_cookie_parser"
  17. end
  18. 6 module InstanceMethods
  19. 6 extend Forwardable
  20. 6 def_delegator :@options, :cookies
  21. 6 def initialize(options = {}, &blk)
  22. 216 super({ cookies: Jar.new }.merge(options), &blk)
  23. end
  24. 6 def wrap
  25. 12 return super unless block_given?
  26. 12 super do |session|
  27. 12 old_cookies_jar = @options.cookies.dup
  28. 1 begin
  29. 12 yield session
  30. ensure
  31. 12 @options = @options.merge(cookies: old_cookies_jar)
  32. end
  33. end
  34. end
  35. 6 def build_request(*)
  36. 240 request = super
  37. 240 request.headers.set_cookie(request.options.cookies[request.uri])
  38. 240 request
  39. end
  40. 6 private
  41. 6 def on_response(_request, response)
  42. 240 if response && response.respond_to?(:headers) && (set_cookie = response.headers["set-cookie"])
  43. 48 log { "cookies: set-cookie is over #{Cookie::MAX_LENGTH}" } if set_cookie.bytesize > Cookie::MAX_LENGTH
  44. 48 @options.cookies.parse(set_cookie)
  45. end
  46. 240 super
  47. end
  48. end
  49. 6 module HeadersMethods
  50. 6 def set_cookie(cookies)
  51. 240 return if cookies.empty?
  52. 204 header_value = cookies.sort.join("; ")
  53. 204 add("cookie", header_value)
  54. end
  55. end
  56. # adds support for the following options:
  57. #
  58. # :cookies :: cookie jar for the session (can be a Hash, an Array, an instance of HTTPX::Plugins::Cookies::CookieJar)
  59. 6 module OptionsMethods
  60. 6 def option_headers(*)
  61. 240 value = super
  62. 240 merge_cookie_in_jar(value.delete("cookie"), @cookies) if defined?(@cookies) && value.key?("cookie")
  63. 240 value
  64. end
  65. 6 def option_cookies(value)
  66. 360 jar = value.is_a?(Jar) ? value : Jar.new(value)
  67. 360 merge_cookie_in_jar(@headers.delete("cookie"), jar) if defined?(@headers) && @headers.key?("cookie")
  68. 360 jar
  69. end
  70. 6 private
  71. 6 def merge_cookie_in_jar(cookies, jar)
  72. 12 cookies.each do |ck|
  73. 12 ck.split(/ *; */).each do |cookie|
  74. 24 name, value = cookie.split("=", 2)
  75. 24 jar.add(Cookie.new(name, value))
  76. end
  77. end
  78. end
  79. end
  80. end
  81. 6 register_plugin :cookies, Cookies
  82. end
  83. end

lib/httpx/plugins/cookies/cookie.rb

100.0% lines covered

76 relevant lines. 76 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 6 module HTTPX
  3. 6 module Plugins::Cookies
  4. # The HTTP Cookie.
  5. #
  6. # Contains the single cookie info: name, value and attributes.
  7. 6 class Cookie
  8. 6 include Comparable
  9. # Maximum number of bytes per cookie (RFC 6265 6.1 requires 4096 at
  10. # least)
  11. 6 MAX_LENGTH = 4096
  12. 6 attr_reader :domain, :path, :name, :value, :created_at
  13. 6 def path=(path)
  14. 138 path = String(path)
  15. 138 @path = path.start_with?("/") ? path : "/"
  16. end
  17. # See #domain.
  18. 6 def domain=(domain)
  19. 30 domain = String(domain)
  20. 30 if domain.start_with?(".")
  21. 12 @for_domain = true
  22. 12 domain = domain[1..-1]
  23. end
  24. 30 return if domain.empty?
  25. 30 @domain_name = DomainName.new(domain)
  26. # RFC 6265 5.3 5.
  27. 30 @for_domain = false if @domain_name.domain.nil? # a public suffix or IP address
  28. 30 @domain = @domain_name.hostname
  29. end
  30. # Compares the cookie with another. When there are many cookies with
  31. # the same name for a URL, the value of the smallest must be used.
  32. 6 def <=>(other)
  33. # RFC 6265 5.4
  34. # Precedence: 1. longer path 2. older creation
  35. 515 (@name <=> other.name).nonzero? ||
  36. 45 (other.path.length <=> @path.length).nonzero? ||
  37. 26 (@created_at <=> other.created_at).nonzero? || 0
  38. end
  39. 6 class << self
  40. 6 def new(cookie, *args)
  41. 378 return cookie if cookie.is_a?(self)
  42. 378 super
  43. end
  44. # Tests if +target_path+ is under +base_path+ as described in RFC
  45. # 6265 5.1.4. +base_path+ must be an absolute path.
  46. # +target_path+ may be empty, in which case it is treated as the
  47. # root path.
  48. #
  49. # e.g.
  50. #
  51. # path_match?('/admin/', '/admin/index') == true
  52. # path_match?('/admin/', '/Admin/index') == false
  53. # path_match?('/admin/', '/admin/') == true
  54. # path_match?('/admin/', '/admin') == false
  55. #
  56. # path_match?('/admin', '/admin') == true
  57. # path_match?('/admin', '/Admin') == false
  58. # path_match?('/admin', '/admins') == false
  59. # path_match?('/admin', '/admin/') == true
  60. # path_match?('/admin', '/admin/index') == true
  61. 6 def path_match?(base_path, target_path)
  62. 1014 base_path.start_with?("/") || (return false)
  63. # RFC 6265 5.1.4
  64. 1014 bsize = base_path.size
  65. 1014 tsize = target_path.size
  66. 1014 return bsize == 1 if tsize.zero? # treat empty target_path as "/"
  67. 1014 return false unless target_path.start_with?(base_path)
  68. 1008 return true if bsize == tsize || base_path.end_with?("/")
  69. 12 target_path[bsize] == "/"
  70. end
  71. end
  72. 6 def initialize(arg, *attrs)
  73. 378 @created_at = Time.now
  74. 378 if attrs.empty?
  75. 18 attr_hash = Hash.try_convert(arg)
  76. else
  77. 360 @name = arg
  78. 360 @value, attr_hash = attrs
  79. 360 attr_hash = Hash.try_convert(attr_hash)
  80. end
  81. attr_hash.each do |key, val|
  82. 234 key = key.downcase.tr("-", "_").to_sym unless key.is_a?(Symbol)
  83. 234 case key
  84. when :domain, :path
  85. 145 __send__(:"#{key}=", val)
  86. else
  87. 66 instance_variable_set(:"@#{key}", val)
  88. end
  89. 377 end if attr_hash
  90. 378 @path ||= "/"
  91. 378 raise ArgumentError, "name must be specified" if @name.nil?
  92. 378 @name = @name.to_s
  93. end
  94. 6 def expires
  95. 570 @expires || (@created_at && @max_age ? @created_at + @max_age : nil)
  96. end
  97. 6 def expired?(time = Time.now)
  98. 546 return false unless expires
  99. 24 expires <= time
  100. end
  101. # Returns a string for use in the Cookie header, i.e. `name=value`
  102. # or `name="value"`.
  103. 6 def cookie_value
  104. 414 "#{@name}=#{Scanner.quote(@value.to_s)}"
  105. end
  106. 6 alias_method :to_s, :cookie_value
  107. # Tests if it is OK to send this cookie to a given `uri`. A
  108. # RuntimeError is raised if the cookie's domain is unknown.
  109. 6 def valid_for_uri?(uri)
  110. 534 uri = URI(uri)
  111. # RFC 6265 5.4
  112. 534 return false if @secure && uri.scheme != "https"
  113. 528 acceptable_from_uri?(uri) && Cookie.path_match?(@path, uri.path)
  114. end
  115. 6 private
  116. # Tests if it is OK to accept this cookie if it is sent from a given
  117. # URI/URL, `uri`.
  118. 6 def acceptable_from_uri?(uri)
  119. 552 uri = URI(uri)
  120. 552 host = DomainName.new(uri.host)
  121. # RFC 6265 5.3
  122. 552 if host.hostname == @domain
  123. 12 true
  124. 539 elsif @for_domain # !host-only-flag
  125. 24 host.cookie_domain?(@domain_name)
  126. else
  127. 516 @domain.nil?
  128. end
  129. end
  130. 6 module Scanner
  131. 6 RE_BAD_CHAR = /([\x00-\x20\x7F",;\\])/.freeze
  132. 6 module_function
  133. 6 def quote(s)
  134. 414 return s unless s.match(RE_BAD_CHAR)
  135. 6 "\"#{s.gsub(/([\\"])/, "\\\\\\1")}\""
  136. end
  137. end
  138. end
  139. end
  140. end

lib/httpx/plugins/cookies/jar.rb

100.0% lines covered

47 relevant lines. 47 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 6 module HTTPX
  3. 6 module Plugins::Cookies
  4. # The Cookie Jar
  5. #
  6. # It holds a bunch of cookies.
  7. 6 class Jar
  8. 6 using URIExtensions
  9. 6 include Enumerable
  10. 6 def initialize_dup(orig)
  11. 162 super
  12. 162 @cookies = orig.instance_variable_get(:@cookies).dup
  13. end
  14. 6 def initialize(cookies = nil)
  15. 402 @cookies = []
  16. 100 cookies.each do |elem|
  17. 132 cookie = case elem
  18. when Cookie
  19. 12 elem
  20. when Array
  21. 108 Cookie.new(*elem)
  22. else
  23. 12 Cookie.new(elem)
  24. end
  25. 132 @cookies << cookie
  26. 401 end if cookies
  27. end
  28. 6 def parse(set_cookie)
  29. 108 SetCookieParser.call(set_cookie) do |name, value, attrs|
  30. 156 add(Cookie.new(name, value, attrs))
  31. end
  32. end
  33. 6 def add(cookie, path = nil)
  34. 342 c = cookie.dup
  35. 342 c.path = path if path && c.path == "/"
  36. # If the user agent receives a new cookie with the same cookie-name, domain-value, and path-value
  37. # as a cookie that it has already stored, the existing cookie is evicted and replaced with the new cookie.
  38. 648 @cookies.delete_if { |ck| ck.name == c.name && ck.domain == c.domain && ck.path == c.path }
  39. 342 @cookies << c
  40. end
  41. 6 def [](uri)
  42. 354 each(uri).sort
  43. end
  44. 6 def each(uri = nil, &blk)
  45. 888 return enum_for(__method__, uri) unless blk
  46. 510 return @cookies.each(&blk) unless uri
  47. 354 uri = URI(uri)
  48. 354 now = Time.now
  49. 354 tpath = uri.path
  50. 354 @cookies.delete_if do |cookie|
  51. 546 if cookie.expired?(now)
  52. 12 true
  53. else
  54. 534 yield cookie if cookie.valid_for_uri?(uri) && Cookie.path_match?(cookie.path, tpath)
  55. 534 false
  56. end
  57. end
  58. end
  59. 6 def merge(other)
  60. 150 cookies_dup = dup
  61. 150 other.each do |elem|
  62. 162 cookie = case elem
  63. when Cookie
  64. 150 elem
  65. when Array
  66. 6 Cookie.new(*elem)
  67. else
  68. 6 Cookie.new(elem)
  69. end
  70. 162 cookies_dup.add(cookie)
  71. end
  72. 150 cookies_dup
  73. end
  74. end
  75. end
  76. end

lib/httpx/plugins/cookies/set_cookie_parser.rb

100.0% lines covered

70 relevant lines. 70 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 6 require "strscan"
  3. 6 require "time"
  4. 6 module HTTPX
  5. 6 module Plugins::Cookies
  6. 6 module SetCookieParser
  7. # Whitespace.
  8. 6 RE_WSP = /[ \t]+/.freeze
  9. # A pattern that matches a cookie name or attribute name which may
  10. # be empty, capturing trailing whitespace.
  11. 6 RE_NAME = /(?!#{RE_WSP})[^,;\\"=]*/.freeze
  12. 6 RE_BAD_CHAR = /([\x00-\x20\x7F",;\\])/.freeze
  13. # A pattern that matches the comma in a (typically date) value.
  14. 6 RE_COOKIE_COMMA = /,(?=#{RE_WSP}?#{RE_NAME}=)/.freeze
  15. 6 module_function
  16. 6 def scan_dquoted(scanner)
  17. 12 s = +""
  18. 12 until scanner.eos?
  19. 48 break if scanner.skip(/"/)
  20. 36 if scanner.skip(/\\/)
  21. 12 s << scanner.getch
  22. 23 elsif scanner.scan(/[^"\\]+/)
  23. 24 s << scanner.matched
  24. end
  25. end
  26. 12 s
  27. end
  28. 6 def scan_value(scanner, comma_as_separator = false)
  29. 330 value = +""
  30. 330 until scanner.eos?
  31. 570 if scanner.scan(/[^,;"]+/)
  32. 324 value << scanner.matched
  33. 245 elsif scanner.skip(/"/)
  34. # RFC 6265 2.2
  35. # A cookie-value may be DQUOTE'd.
  36. 12 value << scan_dquoted(scanner)
  37. 233 elsif scanner.check(/;/)
  38. 174 break
  39. 59 elsif comma_as_separator && scanner.check(RE_COOKIE_COMMA)
  40. 48 break
  41. else
  42. 12 value << scanner.getch
  43. end
  44. end
  45. 330 value.rstrip!
  46. 330 value
  47. end
  48. 6 def scan_name_value(scanner, comma_as_separator = false)
  49. 330 name = scanner.scan(RE_NAME)
  50. 330 name.rstrip! if name
  51. 330 if scanner.skip(/=/)
  52. 324 value = scan_value(scanner, comma_as_separator)
  53. else
  54. 6 scan_value(scanner, comma_as_separator)
  55. 6 value = nil
  56. end
  57. 330 [name, value]
  58. end
  59. 6 def call(set_cookie)
  60. 108 scanner = StringScanner.new(set_cookie)
  61. # RFC 6265 4.1.1 & 5.2
  62. 108 until scanner.eos?
  63. 156 start = scanner.pos
  64. 156 len = nil
  65. 156 scanner.skip(RE_WSP)
  66. 156 name, value = scan_name_value(scanner, true)
  67. 156 value = nil if name.empty?
  68. 156 attrs = {}
  69. 156 until scanner.eos?
  70. 222 if scanner.skip(/,/)
  71. # The comma is used as separator for concatenating multiple
  72. # values of a header.
  73. 48 len = (scanner.pos - 1) - start
  74. 48 break
  75. 173 elsif scanner.skip(/;/)
  76. 174 scanner.skip(RE_WSP)
  77. 174 aname, avalue = scan_name_value(scanner, true)
  78. 174 next if aname.empty? || value.nil?
  79. 174 aname.downcase!
  80. 174 case aname
  81. when "expires"
  82. # RFC 6265 5.2.1
  83. 12 (avalue &&= Time.parse(avalue)) || next
  84. when "max-age"
  85. # RFC 6265 5.2.2
  86. 6 next unless /\A-?\d+\z/.match?(avalue)
  87. 6 avalue = Integer(avalue)
  88. when "domain"
  89. # RFC 6265 5.2.3
  90. # An empty value SHOULD be ignored.
  91. 18 next if avalue.nil? || avalue.empty?
  92. when "path"
  93. # RFC 6265 5.2.4
  94. # A relative path must be ignored rather than normalizing it
  95. # to "/".
  96. 132 next unless avalue.start_with?("/")
  97. when "secure", "httponly"
  98. # RFC 6265 5.2.5, 5.2.6
  99. 5 avalue = true
  100. end
  101. 174 attrs[aname] = avalue
  102. end
  103. end
  104. 156 len ||= scanner.pos - start
  105. 156 next if len > Cookie::MAX_LENGTH
  106. 156 yield(name, value, attrs) if name && !name.empty? && value
  107. end
  108. end
  109. end
  110. end
  111. end

lib/httpx/plugins/digest_auth.rb

100.0% lines covered

29 relevant lines. 29 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 6 module HTTPX
  3. 6 module Plugins
  4. #
  5. # This plugin adds helper methods to implement HTTP Digest Auth (https://datatracker.ietf.org/doc/html/rfc7616)
  6. #
  7. # https://gitlab.com/os85/httpx/wikis/Auth#digest-auth
  8. #
  9. 6 module DigestAuth
  10. 6 DigestError = Class.new(Error)
  11. 6 class << self
  12. 6 def extra_options(options)
  13. 120 options.merge(max_concurrent_requests: 1)
  14. end
  15. 6 def load_dependencies(*)
  16. 120 require_relative "auth/digest"
  17. end
  18. end
  19. # adds support for the following options:
  20. #
  21. # :digest :: instance of HTTPX::Plugins::Authentication::Digest, used to authenticate requests in the session.
  22. 6 module OptionsMethods
  23. 6 def option_digest(value)
  24. 240 raise TypeError, ":digest must be a #{Authentication::Digest}" unless value.is_a?(Authentication::Digest)
  25. 240 value
  26. end
  27. end
  28. 6 module InstanceMethods
  29. 6 def digest_auth(user, password, hashed: false)
  30. 120 with(digest: Authentication::Digest.new(user, password, hashed: hashed))
  31. end
  32. 6 private
  33. 6 def send_requests(*requests)
  34. 144 requests.flat_map do |request|
  35. 144 digest = request.options.digest
  36. 144 next super(request) unless digest
  37. 240 probe_response = wrap { super(request).first }
  38. 120 return probe_response unless probe_response.is_a?(Response)
  39. 120 if probe_response.status == 401 && digest.can_authenticate?(probe_response.headers["www-authenticate"])
  40. 108 request.transition(:idle)
  41. 108 request.headers["authorization"] = digest.authenticate(request, probe_response.headers["www-authenticate"])
  42. 108 super(request)
  43. else
  44. 12 probe_response
  45. end
  46. end
  47. end
  48. end
  49. end
  50. 6 register_plugin :digest_auth, DigestAuth
  51. end
  52. end

lib/httpx/plugins/expect.rb

100.0% lines covered

56 relevant lines. 56 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 6 module HTTPX
  3. 6 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/os85/httpx/wikis/Expect#expect
  8. #
  9. 6 module Expect
  10. 6 EXPECT_TIMEOUT = 2
  11. 6 class << self
  12. 6 def no_expect_store
  13. 134 @no_expect_store ||= []
  14. end
  15. 6 def extra_options(options)
  16. 162 options.merge(expect_timeout: EXPECT_TIMEOUT)
  17. end
  18. end
  19. # adds support for the following options:
  20. #
  21. # :expect_timeout :: time (in seconds) to wait for a 100-expect response,
  22. # before retrying without the Expect header (defaults to <tt>2</tt>).
  23. # :expect_threshold_size :: min threshold (in bytes) of the request payload to enable the 100-continue negotiation on.
  24. 6 module OptionsMethods
  25. 6 def option_expect_timeout(value)
  26. 288 seconds = Float(value)
  27. 288 raise TypeError, ":expect_timeout must be positive" unless seconds.positive?
  28. 288 seconds
  29. end
  30. 6 def option_expect_threshold_size(value)
  31. 12 bytes = Integer(value)
  32. 12 raise TypeError, ":expect_threshold_size must be positive" unless bytes.positive?
  33. 12 bytes
  34. end
  35. end
  36. 6 module RequestMethods
  37. 6 def initialize(*)
  38. 186 super
  39. 186 return if @body.empty?
  40. 126 threshold = @options.expect_threshold_size
  41. 126 return if threshold && !@body.unbounded_body? && @body.bytesize < threshold
  42. 114 return if Expect.no_expect_store.include?(origin)
  43. 108 @headers["expect"] = "100-continue"
  44. end
  45. 6 def response=(response)
  46. 138 if response.is_a?(Response) &&
  47. response.status == 100 &&
  48. !@headers.key?("expect") &&
  49. 1 (@state == :body || @state == :done)
  50. # if we're past this point, this means that we just received a 100-Continue response,
  51. # but the request doesn't have the expect flag, and is already flushing (or flushed) the body.
  52. #
  53. # this means that expect was deactivated for this request too soon, i.e. response took longer.
  54. #
  55. # so we have to reactivate it again.
  56. 7 @headers["expect"] = "100-continue"
  57. 7 @informational_status = 100
  58. 7 Expect.no_expect_store.delete(origin)
  59. end
  60. 138 super
  61. end
  62. end
  63. 6 module ConnectionMethods
  64. 6 def send_request_to_parser(request)
  65. 84 super
  66. 84 return unless request.headers["expect"] == "100-continue"
  67. 60 expect_timeout = request.options.expect_timeout
  68. 60 return if expect_timeout.nil? || expect_timeout.infinite?
  69. 60 set_request_timeout(request, expect_timeout, :expect, %i[body response]) do
  70. # expect timeout expired
  71. 13 if request.state == :expect && !request.expects?
  72. 13 Expect.no_expect_store << request.origin
  73. 13 request.headers.delete("expect")
  74. 13 consume
  75. end
  76. end
  77. end
  78. end
  79. 6 module InstanceMethods
  80. 6 def fetch_response(request, selector, options)
  81. 315 response = super
  82. 315 return unless response
  83. 84 if response.is_a?(Response) && response.status == 417 && request.headers.key?("expect")
  84. 12 response.close
  85. 12 request.headers.delete("expect")
  86. 12 request.transition(:idle)
  87. 12 send_request(request, selector, options)
  88. 12 return
  89. end
  90. 72 response
  91. end
  92. end
  93. end
  94. 6 register_plugin :expect, Expect
  95. end
  96. end

lib/httpx/plugins/follow_redirects.rb

100.0% lines covered

106 relevant lines. 106 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 12 module HTTPX
  3. 12 InsecureRedirectError = Class.new(Error)
  4. 12 module Plugins
  5. #
  6. # This plugin adds support for automatically following redirect (status 30X) responses.
  7. #
  8. # It has a default upper bound of followed redirects (see *MAX_REDIRECTS* and the *max_redirects* option),
  9. # after which it will return the last redirect response. It will **not** raise an exception.
  10. #
  11. # It doesn't follow insecure redirects (https -> http) by default (see *follow_insecure_redirects*).
  12. #
  13. # It doesn't propagate authorization related headers to requests redirecting to different origins
  14. # (see *allow_auth_to_other_origins*) to override.
  15. #
  16. # It allows customization of when to redirect via the *redirect_on* callback option).
  17. #
  18. # https://gitlab.com/os85/httpx/wikis/Follow-Redirects
  19. #
  20. 12 module FollowRedirects
  21. 12 MAX_REDIRECTS = 3
  22. 12 REDIRECT_STATUS = (300..399).freeze
  23. 12 REQUEST_BODY_HEADERS = %w[transfer-encoding content-encoding content-type content-length content-language content-md5 trailer].freeze
  24. 12 using URIExtensions
  25. # adds support for the following options:
  26. #
  27. # :max_redirects :: max number of times a request will be redirected (defaults to <tt>3</tt>).
  28. # :follow_insecure_redirects :: whether redirects to an "http://" URI, when coming from an "https//", are allowed
  29. # (defaults to <tt>false</tt>).
  30. # :allow_auth_to_other_origins :: whether auth-related headers, such as "Authorization", are propagated on redirection
  31. # (defaults to <tt>false</tt>).
  32. # :redirect_on :: optional callback which receives the redirect location and can halt the redirect chain if it returns <tt>false</tt>.
  33. 12 module OptionsMethods
  34. 12 def option_max_redirects(value)
  35. 350 num = Integer(value)
  36. 350 raise TypeError, ":max_redirects must be positive" if num.negative?
  37. 350 num
  38. end
  39. 12 def option_follow_insecure_redirects(value)
  40. 18 value
  41. end
  42. 12 def option_allow_auth_to_other_origins(value)
  43. 18 value
  44. end
  45. 12 def option_redirect_on(value)
  46. 36 raise TypeError, ":redirect_on must be callable" unless value.respond_to?(:call)
  47. 36 value
  48. end
  49. end
  50. 12 module InstanceMethods
  51. # returns a session with the *max_redirects* option set to +n+
  52. 12 def max_redirects(n)
  53. 36 with(max_redirects: n.to_i)
  54. end
  55. 12 private
  56. 12 def fetch_response(request, selector, options)
  57. 1332179 redirect_request = request.redirect_request
  58. 1332179 response = super(redirect_request, selector, options)
  59. 1332179 return unless response
  60. 429 max_redirects = redirect_request.max_redirects
  61. 429 return response unless response.is_a?(Response)
  62. 417 return response unless REDIRECT_STATUS.include?(response.status) && response.headers.key?("location")
  63. 273 return response unless max_redirects.positive?
  64. 249 redirect_uri = __get_location_from_response(response)
  65. 249 if options.redirect_on
  66. 24 redirect_allowed = options.redirect_on.call(redirect_uri)
  67. 24 return response unless redirect_allowed
  68. end
  69. # build redirect request
  70. 237 request_body = redirect_request.body
  71. 237 redirect_method = "GET"
  72. 237 redirect_params = {}
  73. 237 if response.status == 305 && options.respond_to?(:proxy)
  74. 6 request_body.rewind
  75. # The requested resource MUST be accessed through the proxy given by
  76. # the Location field. The Location field gives the URI of the proxy.
  77. 6 redirect_options = options.merge(headers: redirect_request.headers,
  78. proxy: { uri: redirect_uri },
  79. max_redirects: max_redirects - 1)
  80. 6 redirect_params[:body] = request_body
  81. 6 redirect_uri = redirect_request.uri
  82. 6 options = redirect_options
  83. else
  84. 231 redirect_headers = redirect_request_headers(redirect_request.uri, redirect_uri, request.headers, options)
  85. 231 redirect_opts = Hash[options]
  86. 231 redirect_params[:max_redirects] = max_redirects - 1
  87. 231 unless request_body.empty?
  88. 18 if response.status == 307
  89. # The method and the body of the original request are reused to perform the redirected request.
  90. 6 redirect_method = redirect_request.verb
  91. 6 request_body.rewind
  92. 6 redirect_params[:body] = request_body
  93. else
  94. # redirects are **ALWAYS** GET, so remove body-related headers
  95. 12 REQUEST_BODY_HEADERS.each do |h|
  96. 84 redirect_headers.delete(h)
  97. end
  98. 12 redirect_params[:body] = nil
  99. end
  100. end
  101. 231 options = options.class.new(redirect_opts.merge(headers: redirect_headers.to_h))
  102. end
  103. 237 redirect_uri = Utils.to_uri(redirect_uri)
  104. 237 if !options.follow_insecure_redirects &&
  105. response.uri.scheme == "https" &&
  106. redirect_uri.scheme == "http"
  107. 6 error = InsecureRedirectError.new(redirect_uri.to_s)
  108. 6 error.set_backtrace(caller)
  109. 6 return ErrorResponse.new(request, error)
  110. end
  111. 231 retry_request = build_request(redirect_method, redirect_uri, redirect_params, options)
  112. 231 request.redirect_request = retry_request
  113. 231 redirect_after = response.headers["retry-after"]
  114. 231 if redirect_after
  115. # Servers send the "Retry-After" header field to indicate how long the
  116. # user agent ought to wait before making a follow-up request.
  117. # When sent with any 3xx (Redirection) response, Retry-After indicates
  118. # the minimum time that the user agent is asked to wait before issuing
  119. # the redirected request.
  120. #
  121. 24 redirect_after = Utils.parse_retry_after(redirect_after)
  122. 24 retry_start = Utils.now
  123. 24 log { "redirecting after #{redirect_after} secs..." }
  124. 24 selector.after(redirect_after) do
  125. 24 if request.response
  126. # request has terminated abruptly meanwhile
  127. 12 retry_request.emit(:response, request.response)
  128. else
  129. 12 log { "redirecting (elapsed time: #{Utils.elapsed_time(retry_start)})!!" }
  130. 12 send_request(retry_request, selector, options)
  131. end
  132. end
  133. else
  134. 207 send_request(retry_request, selector, options)
  135. end
  136. 37 nil
  137. end
  138. # :nodoc:
  139. 12 def redirect_request_headers(original_uri, redirect_uri, headers, options)
  140. 231 headers = headers.dup
  141. 231 return headers if options.allow_auth_to_other_origins
  142. 225 return headers unless headers.key?("authorization")
  143. 6 return headers if original_uri.origin == redirect_uri.origin
  144. 6 headers.delete("authorization")
  145. 6 headers
  146. end
  147. # :nodoc:
  148. 12 def __get_location_from_response(response)
  149. # @type var location_uri: http_uri
  150. 249 location_uri = URI(response.headers["location"])
  151. 249 location_uri = response.uri.merge(location_uri) if location_uri.relative?
  152. 249 location_uri
  153. end
  154. end
  155. 12 module RequestMethods
  156. # returns the top-most original HTTPX::Request from the redirect chain
  157. 12 attr_accessor :root_request
  158. # returns the follow-up redirect request, or itself
  159. 12 def redirect_request
  160. 1332179 @redirect_request || self
  161. end
  162. # sets the follow-up redirect request
  163. 12 def redirect_request=(req)
  164. 231 @redirect_request = req
  165. 231 req.root_request = @root_request || self
  166. 231 @response = nil
  167. end
  168. 12 def response
  169. 1444 return super unless @redirect_request && @response.nil?
  170. 58 @redirect_request.response
  171. end
  172. 12 def max_redirects
  173. 429 @options.max_redirects || MAX_REDIRECTS
  174. end
  175. end
  176. 12 module ConnectionMethods
  177. 12 private
  178. 12 def set_request_request_timeout(request)
  179. 407 return unless request.root_request.nil?
  180. 193 super
  181. end
  182. end
  183. end
  184. 12 register_plugin :follow_redirects, FollowRedirects
  185. end
  186. end

lib/httpx/plugins/grpc.rb

100.0% lines covered

133 relevant lines. 133 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 5 module HTTPX
  3. 5 GRPCError = Class.new(Error) do
  4. 5 attr_reader :status, :details, :metadata
  5. 5 def initialize(status, details, metadata)
  6. 20 @status = status
  7. 20 @details = details
  8. 20 @metadata = metadata
  9. 20 super("GRPC error, code=#{status}, details=#{details}, metadata=#{metadata}")
  10. end
  11. end
  12. 5 module Plugins
  13. #
  14. # This plugin adds DSL to build GRPC interfaces.
  15. #
  16. # https://gitlab.com/os85/httpx/wikis/GRPC
  17. #
  18. 5 module GRPC
  19. 5 unless String.method_defined?(:underscore)
  20. 5 module StringExtensions
  21. 5 refine String do
  22. 5 def underscore
  23. 260 s = dup # Avoid mutating the argument, as it might be frozen.
  24. 260 s.gsub!(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
  25. 260 s.gsub!(/([a-z\d])([A-Z])/, '\1_\2')
  26. 260 s.tr!("-", "_")
  27. 260 s.downcase!
  28. 260 s
  29. end
  30. end
  31. end
  32. 5 using StringExtensions
  33. end
  34. 5 DEADLINE = 60
  35. 5 MARSHAL_METHOD = :encode
  36. 5 UNMARSHAL_METHOD = :decode
  37. 5 HEADERS = {
  38. "content-type" => "application/grpc",
  39. "te" => "trailers",
  40. "accept" => "application/grpc",
  41. # metadata fits here
  42. # ex "foo-bin" => base64("bar")
  43. }.freeze
  44. 5 class << self
  45. 5 def load_dependencies(*)
  46. 115 require "stringio"
  47. 115 require "httpx/plugins/grpc/grpc_encoding"
  48. 115 require "httpx/plugins/grpc/message"
  49. 115 require "httpx/plugins/grpc/call"
  50. end
  51. 5 def configure(klass)
  52. 115 klass.plugin(:persistent)
  53. 115 klass.plugin(:stream)
  54. end
  55. 5 def extra_options(options)
  56. 115 options.merge(
  57. fallback_protocol: "h2",
  58. grpc_rpcs: {}.freeze,
  59. grpc_compression: false,
  60. grpc_deadline: DEADLINE
  61. )
  62. end
  63. end
  64. 5 module OptionsMethods
  65. 5 def option_grpc_service(value)
  66. 100 String(value)
  67. end
  68. 5 def option_grpc_compression(value)
  69. 135 case value
  70. when true, false
  71. 115 value
  72. else
  73. 20 value.to_s
  74. end
  75. end
  76. 5 def option_grpc_rpcs(value)
  77. 930 Hash[value]
  78. end
  79. 5 def option_grpc_deadline(value)
  80. 670 raise TypeError, ":grpc_deadline must be positive" unless value.positive?
  81. 670 value
  82. end
  83. 5 def option_call_credentials(value)
  84. 15 raise TypeError, ":call_credentials must respond to #call" unless value.respond_to?(:call)
  85. 15 value
  86. end
  87. end
  88. 5 module ResponseMethods
  89. 5 attr_reader :trailing_metadata
  90. 5 def merge_headers(trailers)
  91. 95 @trailing_metadata = Hash[trailers]
  92. 95 super
  93. end
  94. end
  95. 5 module RequestBodyMethods
  96. 5 def initialize(*, **)
  97. 105 super
  98. 105 if (compression = @headers["grpc-encoding"])
  99. 10 deflater_body = self.class.initialize_deflater_body(@body, compression)
  100. 10 @body = Transcoder::GRPCEncoding.encode(deflater_body || @body, compressed: !deflater_body.nil?)
  101. else
  102. 95 @body = Transcoder::GRPCEncoding.encode(@body, compressed: false)
  103. end
  104. end
  105. end
  106. 5 module InstanceMethods
  107. 5 def with_channel_credentials(ca_path, key = nil, cert = nil, **ssl_opts)
  108. # @type var ssl_params: ::Hash[::Symbol, untyped]
  109. 60 ssl_params = {
  110. **ssl_opts,
  111. ca_file: ca_path,
  112. }
  113. 60 if key
  114. 60 key = File.read(key) if File.file?(key)
  115. 60 ssl_params[:key] = OpenSSL::PKey.read(key)
  116. end
  117. 60 if cert
  118. 60 cert = File.read(cert) if File.file?(cert)
  119. 60 ssl_params[:cert] = OpenSSL::X509::Certificate.new(cert)
  120. end
  121. 60 with(ssl: ssl_params)
  122. end
  123. 5 def rpc(rpc_name, input, output, **opts)
  124. 260 rpc_name = rpc_name.to_s
  125. 260 raise Error, "rpc #{rpc_name} already defined" if @options.grpc_rpcs.key?(rpc_name)
  126. rpc_opts = {
  127. 260 deadline: @options.grpc_deadline,
  128. }.merge(opts)
  129. 260 local_rpc_name = rpc_name.underscore
  130. 260 session_class = Class.new(self.class) do
  131. # define rpc method with ruby style name
  132. 260 class_eval(<<-OUT, __FILE__, __LINE__ + 1)
  133. def #{local_rpc_name}(input, **opts) # def grpc_action(input, **opts)
  134. rpc_execute("#{local_rpc_name}", input, **opts) # rpc_execute("grpc_action", input, **opts)
  135. end # end
  136. OUT
  137. # define rpc method with original name
  138. 260 unless local_rpc_name == rpc_name
  139. 10 class_eval(<<-OUT, __FILE__, __LINE__ + 1)
  140. def #{rpc_name}(input, **opts) # def grpcAction(input, **opts)
  141. rpc_execute("#{local_rpc_name}", input, **opts) # rpc_execute("grpc_action", input, **opts)
  142. end # end
  143. OUT
  144. end
  145. end
  146. 260 session_class.new(@options.merge(
  147. grpc_rpcs: @options.grpc_rpcs.merge(
  148. local_rpc_name => [rpc_name, input, output, rpc_opts]
  149. ).freeze
  150. ))
  151. end
  152. 5 def build_stub(origin, service: nil, compression: false)
  153. 115 scheme = @options.ssl.empty? ? "http" : "https"
  154. 115 origin = URI.parse("#{scheme}://#{origin}")
  155. 115 session = self
  156. 115 if service && service.respond_to?(:rpc_descs)
  157. # it's a grpc generic service
  158. 50 service.rpc_descs.each do |rpc_name, rpc_desc|
  159. rpc_opts = {
  160. 250 marshal_method: rpc_desc.marshal_method,
  161. unmarshal_method: rpc_desc.unmarshal_method,
  162. }
  163. 250 input = rpc_desc.input
  164. 250 input = input.type if input.respond_to?(:type)
  165. 250 output = rpc_desc.output
  166. 250 if output.respond_to?(:type)
  167. 100 rpc_opts[:stream] = true
  168. 100 output = output.type
  169. end
  170. 250 session = session.rpc(rpc_name, input, output, **rpc_opts)
  171. end
  172. 50 service = service.service_name
  173. end
  174. 115 session.with(origin: origin, grpc_service: service, grpc_compression: compression)
  175. end
  176. 5 def execute(rpc_method, input,
  177. deadline: DEADLINE,
  178. metadata: nil,
  179. **opts)
  180. 105 grpc_request = build_grpc_request(rpc_method, input, deadline: deadline, metadata: metadata, **opts)
  181. 105 response = request(grpc_request, **opts)
  182. 105 response.raise_for_status unless opts[:stream]
  183. 95 GRPC::Call.new(response)
  184. end
  185. 5 private
  186. 5 def rpc_execute(rpc_name, input, **opts)
  187. 50 rpc_name, input_enc, output_enc, rpc_opts = @options.grpc_rpcs[rpc_name]
  188. 50 exec_opts = rpc_opts.merge(opts)
  189. 50 marshal_method ||= exec_opts.delete(:marshal_method) || MARSHAL_METHOD
  190. 50 unmarshal_method ||= exec_opts.delete(:unmarshal_method) || UNMARSHAL_METHOD
  191. 50 messages = if input.respond_to?(:each)
  192. 20 Enumerator.new do |y|
  193. 20 input.each do |message|
  194. 40 y << input_enc.__send__(marshal_method, message)
  195. end
  196. end
  197. else
  198. 30 input_enc.__send__(marshal_method, input)
  199. end
  200. 50 call = execute(rpc_name, messages, **exec_opts)
  201. 50 call.decoder = output_enc.method(unmarshal_method)
  202. 50 call
  203. end
  204. 5 def build_grpc_request(rpc_method, input, deadline:, metadata: nil, **)
  205. 105 uri = @options.origin.dup
  206. 105 rpc_method = "/#{rpc_method}" unless rpc_method.start_with?("/")
  207. 105 rpc_method = "/#{@options.grpc_service}#{rpc_method}" if @options.grpc_service
  208. 105 uri.path = rpc_method
  209. 105 headers = HEADERS.merge(
  210. "grpc-accept-encoding" => ["identity", *@options.supported_compression_formats]
  211. )
  212. 105 unless deadline == Float::INFINITY
  213. # convert to milliseconds
  214. 105 deadline = (deadline * 1000.0).to_i
  215. 105 headers["grpc-timeout"] = "#{deadline}m"
  216. end
  217. 105 headers = headers.merge(metadata.transform_keys(&:to_s)) if metadata
  218. # prepare compressor
  219. 105 compression = @options.grpc_compression == true ? "gzip" : @options.grpc_compression
  220. 105 headers["grpc-encoding"] = compression if compression
  221. 105 headers.merge!(@options.call_credentials.call.transform_keys(&:to_s)) if @options.call_credentials
  222. 105 build_request("POST", uri, headers: headers, body: input)
  223. end
  224. end
  225. end
  226. 5 register_plugin :grpc, GRPC
  227. end
  228. end

lib/httpx/plugins/grpc/call.rb

90.91% lines covered

33 relevant lines. 30 lines covered and 3 lines missed.
    
  1. # frozen_string_literal: true
  2. 5 module HTTPX
  3. 5 module Plugins
  4. 5 module GRPC
  5. # Encapsulates call information
  6. 5 class Call
  7. 5 attr_writer :decoder
  8. 5 def initialize(response)
  9. 95 @response = response
  10. 130 @decoder = ->(z) { z }
  11. 95 @consumed = false
  12. 95 @grpc_response = nil
  13. end
  14. 5 def inspect
  15. "#GRPC::Call(#{grpc_response})"
  16. end
  17. 5 def to_s
  18. 55 grpc_response.to_s
  19. end
  20. 5 def metadata
  21. response.headers
  22. end
  23. 5 def trailing_metadata
  24. 60 return unless @consumed
  25. 40 @response.trailing_metadata
  26. end
  27. 5 private
  28. 5 def grpc_response
  29. 155 @grpc_response ||= if @response.respond_to?(:each)
  30. 20 Enumerator.new do |y|
  31. 20 Message.stream(@response).each do |message|
  32. 40 y << @decoder.call(message)
  33. end
  34. 20 @consumed = true
  35. end
  36. else
  37. 75 @consumed = true
  38. 75 @decoder.call(Message.unary(@response))
  39. end
  40. end
  41. 5 def respond_to_missing?(meth, *args, &blk)
  42. 20 grpc_response.respond_to?(meth, *args) || super
  43. end
  44. 5 def method_missing(meth, *args, &blk)
  45. 40 return grpc_response.__send__(meth, *args, &blk) if grpc_response.respond_to?(meth)
  46. super
  47. end
  48. end
  49. end
  50. end
  51. end

lib/httpx/plugins/grpc/grpc_encoding.rb

97.83% lines covered

46 relevant lines. 45 lines covered and 1 lines missed.
    
  1. # frozen_string_literal: true
  2. 5 module HTTPX
  3. 5 module Transcoder
  4. 5 module GRPCEncoding
  5. 5 class Deflater
  6. 5 extend Forwardable
  7. 5 attr_reader :content_type
  8. 5 def initialize(body, compressed:)
  9. 105 @content_type = body.content_type
  10. 105 @body = BodyReader.new(body)
  11. 105 @compressed = compressed
  12. end
  13. 5 def bytesize
  14. 335 return @body.bytesize if @body.respond_to?(:bytesize)
  15. Float::INFINITY
  16. end
  17. 5 def read(length = nil, outbuf = nil)
  18. 220 buf = @body.read(length, outbuf)
  19. 210 return unless buf
  20. 115 compressed_flag = @compressed ? 1 : 0
  21. 115 buf = outbuf if outbuf
  22. 115 buf.prepend([compressed_flag, buf.bytesize].pack("CL>"))
  23. 115 buf
  24. end
  25. end
  26. 5 class Inflater
  27. 5 def initialize(response)
  28. 75 @response = response
  29. 75 @grpc_encodings = nil
  30. end
  31. 5 def call(message, &blk)
  32. 95 data = "".b
  33. 95 until message.empty?
  34. 95 compressed, size = message.unpack("CL>")
  35. 95 encoded_data = message.byteslice(5..size + 5 - 1)
  36. 95 if compressed == 1
  37. 10 grpc_encodings.reverse_each do |encoding|
  38. 10 decoder = @response.body.class.initialize_inflater_by_encoding(encoding, @response, bytesize: encoded_data.bytesize)
  39. 10 encoded_data = decoder.call(encoded_data)
  40. 10 blk.call(encoded_data) if blk
  41. 10 data << encoded_data
  42. end
  43. else
  44. 85 blk.call(encoded_data) if blk
  45. 85 data << encoded_data
  46. end
  47. 95 message = message.byteslice((size + 5)..-1)
  48. end
  49. 95 data
  50. end
  51. 5 private
  52. 5 def grpc_encodings
  53. 10 @grpc_encodings ||= @response.headers.get("grpc-encoding")
  54. end
  55. end
  56. 5 def self.encode(*args, **kwargs)
  57. 105 Deflater.new(*args, **kwargs)
  58. end
  59. 5 def self.decode(response)
  60. 75 Inflater.new(response)
  61. end
  62. end
  63. end
  64. end

lib/httpx/plugins/grpc/message.rb

95.83% lines covered

24 relevant lines. 23 lines covered and 1 lines missed.
    
  1. # frozen_string_literal: true
  2. 5 module HTTPX
  3. 5 module Plugins
  4. 5 module GRPC
  5. # Encoding module for GRPC responses
  6. #
  7. # Can encode and decode grpc messages.
  8. 5 module Message
  9. 5 module_function
  10. # decodes a unary grpc response
  11. 5 def unary(response)
  12. 75 verify_status(response)
  13. 55 decoder = Transcoder::GRPCEncoding.decode(response)
  14. 55 decoder.call(response.to_s)
  15. end
  16. # lazy decodes a grpc stream response
  17. 5 def stream(response, &block)
  18. 40 return enum_for(__method__, response) unless block
  19. 20 decoder = Transcoder::GRPCEncoding.decode(response)
  20. 20 response.each do |frame|
  21. 40 decoder.call(frame, &block)
  22. end
  23. 20 verify_status(response)
  24. end
  25. 5 def cancel(request)
  26. request.emit(:refuse, :client_cancellation)
  27. end
  28. # interprets the grpc call trailing metadata, and raises an
  29. # exception in case of error code
  30. 5 def verify_status(response)
  31. # return standard errors if need be
  32. 95 response.raise_for_status
  33. 95 status = Integer(response.headers["grpc-status"])
  34. 95 message = response.headers["grpc-message"]
  35. 95 return if status.zero?
  36. 20 response.close
  37. 20 raise GRPCError.new(status, message, response.trailing_metadata)
  38. end
  39. end
  40. end
  41. end
  42. end

lib/httpx/plugins/h2c.rb

94.64% lines covered

56 relevant lines. 53 lines covered and 3 lines missed.
    
  1. # frozen_string_literal: true
  2. 6 module HTTPX
  3. 6 module Plugins
  4. #
  5. # This plugin adds support for upgrading a plaintext HTTP/1.1 connection to HTTP/2
  6. # (https://datatracker.ietf.org/doc/html/rfc7540#section-3.2)
  7. #
  8. # https://gitlab.com/os85/httpx/wikis/Connection-Upgrade#h2c
  9. #
  10. 6 module H2C
  11. 6 VALID_H2C_VERBS = %w[GET OPTIONS HEAD].freeze
  12. 6 class << self
  13. 6 def load_dependencies(klass)
  14. 12 klass.plugin(:upgrade)
  15. end
  16. 6 def call(connection, request, response)
  17. 12 connection.upgrade_to_h2c(request, response)
  18. end
  19. 6 def extra_options(options)
  20. 12 options.merge(max_concurrent_requests: 1, upgrade_handlers: options.upgrade_handlers.merge("h2c" => self))
  21. end
  22. end
  23. 6 class H2CParser < Connection::HTTP2
  24. 6 def upgrade(request, response)
  25. # skip checks, it is assumed that this is the first
  26. # request in the connection
  27. 12 stream = @connection.upgrade
  28. # on_settings
  29. 12 handle_stream(stream, request)
  30. 12 @streams[request] = stream
  31. # clean up data left behind in the buffer, if the server started
  32. # sending frames
  33. 12 data = response.read
  34. 12 @connection << data
  35. end
  36. end
  37. 6 module ConnectionMethods
  38. 6 using URIExtensions
  39. 6 def initialize(*)
  40. 12 super
  41. 12 @h2c_handshake = false
  42. end
  43. 6 def send(request)
  44. 42 return super if @h2c_handshake
  45. 12 return super unless VALID_H2C_VERBS.include?(request.verb) && request.scheme == "http"
  46. 12 return super if @upgrade_protocol == "h2c"
  47. 12 @h2c_handshake = true
  48. # build upgrade request
  49. 12 request.headers.add("connection", "upgrade")
  50. 12 request.headers.add("connection", "http2-settings")
  51. 12 request.headers["upgrade"] = "h2c"
  52. 12 request.headers["http2-settings"] = ::HTTP2::Client.settings_header(request.options.http2_settings)
  53. 12 super
  54. end
  55. 6 def upgrade_to_h2c(request, response)
  56. 12 prev_parser = @parser
  57. 12 if prev_parser
  58. 12 prev_parser.reset
  59. 12 @inflight -= prev_parser.requests.size
  60. end
  61. 12 @parser = H2CParser.new(@write_buffer, @options)
  62. 12 set_parser_callbacks(@parser)
  63. 12 @inflight += 1
  64. 12 @parser.upgrade(request, response)
  65. 12 @upgrade_protocol = "h2c"
  66. 12 prev_parser.requests.each do |req|
  67. 12 req.transition(:idle)
  68. 12 send(req)
  69. end
  70. end
  71. 6 private
  72. 6 def send_request_to_parser(request)
  73. 42 super
  74. 42 return unless request.headers["upgrade"] == "h2c" && parser.is_a?(Connection::HTTP1)
  75. 12 max_concurrent_requests = parser.max_concurrent_requests
  76. 12 return if max_concurrent_requests == 1
  77. parser.max_concurrent_requests = 1
  78. request.once(:response) do
  79. parser.max_concurrent_requests = max_concurrent_requests
  80. end
  81. end
  82. end
  83. end
  84. 6 register_plugin(:h2c, H2C)
  85. end
  86. end

lib/httpx/plugins/ntlm_auth.rb

100.0% lines covered

30 relevant lines. 30 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 5 module HTTPX
  3. 5 module Plugins
  4. #
  5. # https://gitlab.com/os85/httpx/wikis/Auth#ntlm-auth
  6. #
  7. 5 module NTLMAuth
  8. 5 class << self
  9. 5 def load_dependencies(_klass)
  10. 2 require_relative "auth/ntlm"
  11. end
  12. 5 def extra_options(options)
  13. 2 options.merge(max_concurrent_requests: 1)
  14. end
  15. end
  16. 5 module OptionsMethods
  17. 5 def option_ntlm(value)
  18. 8 raise TypeError, ":ntlm must be a #{Authentication::Ntlm}" unless value.is_a?(Authentication::Ntlm)
  19. 8 value
  20. end
  21. end
  22. 5 module InstanceMethods
  23. 5 def ntlm_auth(user, password, domain = nil)
  24. 4 with(ntlm: Authentication::Ntlm.new(user, password, domain: domain))
  25. end
  26. 5 private
  27. 5 def send_requests(*requests)
  28. 8 requests.flat_map do |request|
  29. 8 ntlm = request.options.ntlm
  30. 8 if ntlm
  31. 4 request.headers["authorization"] = ntlm.negotiate
  32. 8 probe_response = wrap { super(request).first }
  33. 4 return probe_response unless probe_response.is_a?(Response)
  34. 4 if probe_response.status == 401 && ntlm.can_authenticate?(probe_response.headers["www-authenticate"])
  35. 2 request.transition(:idle)
  36. 2 request.headers["authorization"] = ntlm.authenticate(request, probe_response.headers["www-authenticate"])
  37. 2 super(request)
  38. else
  39. 2 probe_response
  40. end
  41. else
  42. 4 super(request)
  43. end
  44. end
  45. end
  46. end
  47. end
  48. 5 register_plugin :ntlm_auth, NTLMAuth
  49. end
  50. end

lib/httpx/plugins/oauth.rb

100.0% lines covered

87 relevant lines. 87 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 6 module HTTPX
  3. 6 module Plugins
  4. #
  5. # https://gitlab.com/os85/httpx/wikis/OAuth
  6. #
  7. 6 module OAuth
  8. 6 class << self
  9. 6 def load_dependencies(_klass)
  10. 108 require_relative "auth/basic"
  11. end
  12. end
  13. 6 SUPPORTED_GRANT_TYPES = %w[client_credentials refresh_token].freeze
  14. 6 SUPPORTED_AUTH_METHODS = %w[client_secret_basic client_secret_post].freeze
  15. 6 class OAuthSession
  16. 6 attr_reader :grant_type, :client_id, :client_secret, :access_token, :refresh_token, :scope
  17. 6 def initialize(
  18. issuer:,
  19. client_id:,
  20. client_secret:,
  21. access_token: nil,
  22. refresh_token: nil,
  23. scope: nil,
  24. token_endpoint: nil,
  25. response_type: nil,
  26. grant_type: nil,
  27. token_endpoint_auth_method: nil
  28. )
  29. 96 @issuer = URI(issuer)
  30. 96 @client_id = client_id
  31. 96 @client_secret = client_secret
  32. 96 @token_endpoint = URI(token_endpoint) if token_endpoint
  33. 96 @response_type = response_type
  34. 96 @scope = case scope
  35. when String
  36. 36 scope.split
  37. when Array
  38. 24 scope
  39. end
  40. 96 @access_token = access_token
  41. 96 @refresh_token = refresh_token
  42. 96 @token_endpoint_auth_method = String(token_endpoint_auth_method) if token_endpoint_auth_method
  43. 96 @grant_type = grant_type || (@refresh_token ? "refresh_token" : "client_credentials")
  44. 96 unless @token_endpoint_auth_method.nil? || SUPPORTED_AUTH_METHODS.include?(@token_endpoint_auth_method)
  45. 12 raise Error, "#{@token_endpoint_auth_method} is not a supported auth method"
  46. end
  47. 84 return if SUPPORTED_GRANT_TYPES.include?(@grant_type)
  48. 12 raise Error, "#{@grant_type} is not a supported grant type"
  49. end
  50. 6 def token_endpoint
  51. 84 @token_endpoint || "#{@issuer}/token"
  52. end
  53. 6 def token_endpoint_auth_method
  54. 120 @token_endpoint_auth_method || "client_secret_basic"
  55. end
  56. 6 def load(http)
  57. 36 return if @grant_type && @scope
  58. 12 metadata = http.get("#{@issuer}/.well-known/oauth-authorization-server").raise_for_status.json
  59. 12 @token_endpoint = metadata["token_endpoint"]
  60. 12 @scope = metadata["scopes_supported"]
  61. 48 @grant_type = Array(metadata["grant_types_supported"]).find { |gr| SUPPORTED_GRANT_TYPES.include?(gr) }
  62. 12 @token_endpoint_auth_method = Array(metadata["token_endpoint_auth_methods_supported"]).find do |am|
  63. 12 SUPPORTED_AUTH_METHODS.include?(am)
  64. end
  65. 2 nil
  66. end
  67. 6 def merge(other)
  68. 72 obj = dup
  69. 72 case other
  70. when OAuthSession
  71. 36 other.instance_variables.each do |ivar|
  72. 324 val = other.instance_variable_get(ivar)
  73. 324 next unless val
  74. 252 obj.instance_variable_set(ivar, val)
  75. end
  76. when Hash
  77. 36 other.each do |k, v|
  78. 72 obj.instance_variable_set(:"@#{k}", v) if obj.instance_variable_defined?(:"@#{k}")
  79. end
  80. end
  81. 72 obj
  82. end
  83. end
  84. 6 module OptionsMethods
  85. 6 def option_oauth_session(value)
  86. 228 case value
  87. when Hash
  88. 12 OAuthSession.new(**value)
  89. when OAuthSession
  90. 204 value
  91. else
  92. 12 raise TypeError, ":oauth_session must be a #{OAuthSession}"
  93. end
  94. end
  95. end
  96. 6 module InstanceMethods
  97. 6 def oauth_auth(**args)
  98. 84 with(oauth_session: OAuthSession.new(**args))
  99. end
  100. 6 def with_access_token
  101. 36 oauth_session = @options.oauth_session
  102. 36 oauth_session.load(self)
  103. 36 grant_type = oauth_session.grant_type
  104. 36 headers = {}
  105. 36 form_post = { "grant_type" => grant_type, "scope" => Array(oauth_session.scope).join(" ") }.compact
  106. # auth
  107. 36 case oauth_session.token_endpoint_auth_method
  108. when "client_secret_post"
  109. 12 form_post["client_id"] = oauth_session.client_id
  110. 12 form_post["client_secret"] = oauth_session.client_secret
  111. when "client_secret_basic"
  112. 24 headers["authorization"] = Authentication::Basic.new(oauth_session.client_id, oauth_session.client_secret).authenticate
  113. end
  114. 36 case grant_type
  115. when "client_credentials"
  116. # do nothing
  117. when "refresh_token"
  118. 12 form_post["refresh_token"] = oauth_session.refresh_token
  119. end
  120. 36 token_request = build_request("POST", oauth_session.token_endpoint, headers: headers, form: form_post)
  121. 36 token_request.headers.delete("authorization") unless oauth_session.token_endpoint_auth_method == "client_secret_basic"
  122. 36 token_response = request(token_request)
  123. 36 token_response.raise_for_status
  124. 36 payload = token_response.json
  125. 36 access_token = payload["access_token"]
  126. 36 refresh_token = payload["refresh_token"]
  127. 36 with(oauth_session: oauth_session.merge(access_token: access_token, refresh_token: refresh_token))
  128. end
  129. 6 def build_request(*)
  130. 96 request = super
  131. 96 return request if request.headers.key?("authorization")
  132. 72 oauth_session = @options.oauth_session
  133. 72 return request unless oauth_session && oauth_session.access_token
  134. 48 request.headers["authorization"] = "Bearer #{oauth_session.access_token}"
  135. 48 request
  136. end
  137. end
  138. end
  139. 6 register_plugin :oauth, OAuth
  140. end
  141. end

lib/httpx/plugins/persistent.rb

100.0% lines covered

19 relevant lines. 19 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 8 module HTTPX
  3. 8 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/os85/httpx/wikis/Persistent
  18. #
  19. 8 module Persistent
  20. 8 def self.load_dependencies(klass)
  21. 314 max_retries = if klass.default_options.respond_to?(:max_retries)
  22. 6 [klass.default_options.max_retries, 1].max
  23. else
  24. 308 1
  25. end
  26. 314 klass.plugin(:retries, max_retries: max_retries, retry_change_requests: true)
  27. end
  28. 8 def self.extra_options(options)
  29. 314 options.merge(persistent: true)
  30. end
  31. 8 module InstanceMethods
  32. 8 private
  33. 8 def get_current_selector
  34. 298 super(&nil) || begin
  35. 292 return unless block_given?
  36. 292 default = yield
  37. 292 set_current_selector(default)
  38. 292 default
  39. end
  40. end
  41. end
  42. end
  43. 8 register_plugin :persistent, Persistent
  44. end
  45. end

lib/httpx/plugins/proxy.rb

98.0% lines covered

150 relevant lines. 147 lines covered and 3 lines missed.
    
  1. # frozen_string_literal: true
  2. 8 module HTTPX
  3. 8 class HTTPProxyError < ConnectionError; end
  4. 8 module Plugins
  5. #
  6. # This plugin adds support for proxies. It ships with support for:
  7. #
  8. # * HTTP proxies
  9. # * HTTPS proxies
  10. # * Socks4/4a proxies
  11. # * Socks5 proxies
  12. #
  13. # https://gitlab.com/os85/httpx/wikis/Proxy
  14. #
  15. 8 module Proxy
  16. 8 Error = HTTPProxyError
  17. 8 PROXY_ERRORS = [TimeoutError, IOError, SystemCallError, Error].freeze
  18. 8 class << self
  19. 8 def configure(klass)
  20. 254 klass.plugin(:"proxy/http")
  21. 254 klass.plugin(:"proxy/socks4")
  22. 254 klass.plugin(:"proxy/socks5")
  23. end
  24. 8 def extra_options(options)
  25. 254 options.merge(supported_proxy_protocols: [])
  26. end
  27. end
  28. 8 class Parameters
  29. 8 attr_reader :uri, :username, :password, :scheme, :no_proxy
  30. 8 def initialize(uri: nil, scheme: nil, username: nil, password: nil, no_proxy: nil, **extra)
  31. 280 @no_proxy = Array(no_proxy) if no_proxy
  32. 280 @uris = Array(uri)
  33. 280 uri = @uris.first
  34. 280 @username = username
  35. 280 @password = password
  36. 280 @ns = 0
  37. 280 if uri
  38. 250 @uri = uri.is_a?(URI::Generic) ? uri : URI(uri)
  39. 250 @username ||= @uri.user
  40. 250 @password ||= @uri.password
  41. end
  42. 280 @scheme = scheme
  43. 280 return unless @uri && @username && @password
  44. 159 @authenticator = nil
  45. 159 @scheme ||= infer_default_auth_scheme(@uri)
  46. 159 return unless @scheme
  47. 123 @authenticator = load_authenticator(@scheme, @username, @password, **extra)
  48. end
  49. 8 def shift
  50. # TODO: this operation must be synchronized
  51. 90 @ns += 1
  52. 90 @uri = @uris[@ns]
  53. 90 return unless @uri
  54. 12 @uri = URI(@uri) unless @uri.is_a?(URI::Generic)
  55. 12 scheme = infer_default_auth_scheme(@uri)
  56. 12 return unless scheme != @scheme
  57. 12 @scheme = scheme
  58. 12 @username = username || @uri.user
  59. 12 @password = password || @uri.password
  60. 12 @authenticator = load_authenticator(scheme, @username, @password)
  61. end
  62. 8 def can_authenticate?(*args)
  63. 138 return false unless @authenticator
  64. 48 @authenticator.can_authenticate?(*args)
  65. end
  66. 8 def authenticate(*args)
  67. 121 return unless @authenticator
  68. 121 @authenticator.authenticate(*args)
  69. end
  70. 8 def ==(other)
  71. 329 case other
  72. when Parameters
  73. 299 @uri == other.uri &&
  74. @username == other.username &&
  75. @password == other.password &&
  76. @scheme == other.scheme
  77. when URI::Generic, String
  78. 18 proxy_uri = @uri.dup
  79. 18 proxy_uri.user = @username
  80. 18 proxy_uri.password = @password
  81. 18 other_uri = other.is_a?(URI::Generic) ? other : URI.parse(other)
  82. 18 proxy_uri == other_uri
  83. else
  84. 12 super
  85. end
  86. end
  87. 8 private
  88. 8 def infer_default_auth_scheme(uri)
  89. 159 case uri.scheme
  90. when "socks5"
  91. 36 uri.scheme
  92. when "http", "https"
  93. 74 "basic"
  94. end
  95. end
  96. 8 def load_authenticator(scheme, username, password, **extra)
  97. 135 auth_scheme = scheme.to_s.capitalize
  98. 135 require_relative "auth/#{scheme}" unless defined?(Authentication) && Authentication.const_defined?(auth_scheme, false)
  99. 135 Authentication.const_get(auth_scheme).new(username, password, **extra)
  100. end
  101. end
  102. # adds support for the following options:
  103. #
  104. # :proxy :: proxy options defining *:uri*, *:username*, *:password* or
  105. # *:scheme* (i.e. <tt>{ uri: "http://proxy" }</tt>)
  106. 8 module OptionsMethods
  107. 8 def option_proxy(value)
  108. 507 value.is_a?(Parameters) ? value : Parameters.new(**Hash[value])
  109. end
  110. 8 def option_supported_proxy_protocols(value)
  111. 1281 raise TypeError, ":supported_proxy_protocols must be an Array" unless value.is_a?(Array)
  112. 1281 value.map(&:to_s)
  113. end
  114. end
  115. 8 module InstanceMethods
  116. 8 def find_connection(request_uri, selector, options)
  117. 316 return super unless options.respond_to?(:proxy)
  118. 316 if (next_proxy = request_uri.find_proxy)
  119. 4 return super(request_uri, selector, options.merge(proxy: Parameters.new(uri: next_proxy)))
  120. end
  121. 312 proxy = options.proxy
  122. 312 return super unless proxy
  123. 304 next_proxy = proxy.uri
  124. 304 raise Error, "Failed to connect to proxy" unless next_proxy
  125. raise Error,
  126. 292 "#{next_proxy.scheme}: unsupported proxy protocol" unless options.supported_proxy_protocols.include?(next_proxy.scheme)
  127. 286 if (no_proxy = proxy.no_proxy)
  128. 12 no_proxy = no_proxy.join(",") if no_proxy.is_a?(Array)
  129. # TODO: setting proxy to nil leaks the connection object in the pool
  130. 12 return super(request_uri, selector, options.merge(proxy: nil)) unless URI::Generic.use_proxy?(request_uri.host, next_proxy.host,
  131. next_proxy.port, no_proxy)
  132. end
  133. 280 super(request_uri, selector, options.merge(proxy: proxy))
  134. end
  135. 8 private
  136. 8 def fetch_response(request, selector, options)
  137. 1160 response = super
  138. 1160 if response.is_a?(ErrorResponse) && proxy_error?(request, response, options)
  139. 90 options.proxy.shift
  140. # return last error response if no more proxies to try
  141. 90 return response if options.proxy.uri.nil?
  142. 12 log { "failed connecting to proxy, trying next..." }
  143. 12 request.transition(:idle)
  144. 12 send_request(request, selector, options)
  145. 12 return
  146. end
  147. 1070 response
  148. end
  149. 8 def proxy_error?(_request, response, options)
  150. 127 return false unless options.proxy
  151. 126 error = response.error
  152. 126 case error
  153. when NativeResolveError
  154. 12 proxy_uri = URI(options.proxy.uri)
  155. 12 peer = error.connection.peer
  156. # failed resolving proxy domain
  157. 12 peer.host == proxy_uri.host && peer.port == proxy_uri.port
  158. when ResolveError
  159. proxy_uri = URI(options.proxy.uri)
  160. error.message.end_with?(proxy_uri.to_s)
  161. when *PROXY_ERRORS
  162. # timeout errors connecting to proxy
  163. 114 true
  164. else
  165. false
  166. end
  167. end
  168. end
  169. 8 module ConnectionMethods
  170. 8 using URIExtensions
  171. 8 def initialize(*)
  172. 291 super
  173. 291 return unless @options.proxy
  174. # redefining the connection origin as the proxy's URI,
  175. # as this will be used as the tcp peer ip.
  176. 277 @proxy_uri = URI(@options.proxy.uri)
  177. end
  178. 8 def peer
  179. 805 @proxy_uri || super
  180. end
  181. 8 def connecting?
  182. 3448 return super unless @options.proxy
  183. 3329 super || @state == :connecting || @state == :connected
  184. end
  185. 8 def call
  186. 794 super
  187. 794 return unless @options.proxy
  188. 768 case @state
  189. when :connecting
  190. 160 consume
  191. end
  192. end
  193. 8 def reset
  194. 302 return super unless @options.proxy
  195. 289 @state = :open
  196. 289 super
  197. # emit(:close)
  198. end
  199. 8 private
  200. 8 def initialize_type(uri, options)
  201. 291 return super unless options.proxy
  202. 277 "tcp"
  203. end
  204. 8 def connect
  205. 793 return super unless @options.proxy
  206. 767 case @state
  207. when :idle
  208. 538 transition(:connecting)
  209. when :connected
  210. 229 transition(:open)
  211. end
  212. end
  213. 8 def handle_transition(nextstate)
  214. 1630 return super unless @options.proxy
  215. 1563 case nextstate
  216. when :closing
  217. # this is a hack so that we can use the super method
  218. # and it'll think that the current state is open
  219. 295 @state = :open if @state == :connecting
  220. end
  221. 1563 super
  222. end
  223. end
  224. end
  225. 8 register_plugin :proxy, Proxy
  226. end
  227. 8 class ProxySSL < SSL
  228. 8 def initialize(tcp, request_uri, options)
  229. 68 @io = tcp.to_io
  230. 68 super(request_uri, tcp.addresses, options)
  231. 68 @hostname = request_uri.host
  232. 68 @state = :connected
  233. end
  234. end
  235. end

lib/httpx/plugins/proxy/http.rb

100.0% lines covered

102 relevant lines. 102 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 8 module HTTPX
  3. 8 module Plugins
  4. 8 module Proxy
  5. 8 module HTTP
  6. 8 class << self
  7. 8 def extra_options(options)
  8. 254 options.merge(supported_proxy_protocols: options.supported_proxy_protocols + %w[http])
  9. end
  10. end
  11. 8 module InstanceMethods
  12. 8 def with_proxy_basic_auth(opts)
  13. 6 with(proxy: opts.merge(scheme: "basic"))
  14. end
  15. 8 def with_proxy_digest_auth(opts)
  16. 18 with(proxy: opts.merge(scheme: "digest"))
  17. end
  18. 8 def with_proxy_ntlm_auth(opts)
  19. 6 with(proxy: opts.merge(scheme: "ntlm"))
  20. end
  21. 8 def fetch_response(request, selector, options)
  22. 1160 response = super
  23. 1160 if response &&
  24. response.is_a?(Response) &&
  25. response.status == 407 &&
  26. !request.headers.key?("proxy-authorization") &&
  27. response.headers.key?("proxy-authenticate") && options.proxy.can_authenticate?(response.headers["proxy-authenticate"])
  28. 6 request.transition(:idle)
  29. 6 request.headers["proxy-authorization"] =
  30. options.proxy.authenticate(request, response.headers["proxy-authenticate"])
  31. 6 send_request(request, selector, options)
  32. 6 return
  33. end
  34. 1154 response
  35. end
  36. end
  37. 8 module ConnectionMethods
  38. 8 def connecting?
  39. 3448 super || @state == :connecting || @state == :connected
  40. end
  41. 8 private
  42. 8 def handle_transition(nextstate)
  43. 1812 return super unless @options.proxy && @options.proxy.uri.scheme == "http"
  44. 900 case nextstate
  45. when :connecting
  46. 232 return unless @state == :idle
  47. 232 @io.connect
  48. 232 return unless @io.connected?
  49. 116 @parser || begin
  50. 110 @parser = self.class.parser_type(@io.protocol).new(@write_buffer, @options.merge(max_concurrent_requests: 1))
  51. 110 parser = @parser
  52. 110 parser.extend(ProxyParser)
  53. 110 parser.on(:response, &method(:__http_on_connect))
  54. 110 parser.on(:close) do |force|
  55. 44 next unless @parser
  56. 6 if force
  57. 6 reset
  58. 6 emit(:terminate)
  59. end
  60. end
  61. 110 parser.on(:reset) do
  62. 12 if parser.empty?
  63. 6 reset
  64. else
  65. 6 transition(:closing)
  66. 6 transition(:closed)
  67. 6 parser.reset if @parser
  68. 6 transition(:idle)
  69. 6 transition(:connecting)
  70. end
  71. end
  72. 110 __http_proxy_connect(parser)
  73. end
  74. 116 return if @state == :connected
  75. when :connected
  76. 104 return unless @state == :idle || @state == :connecting
  77. 104 case @state
  78. when :connecting
  79. 38 parser = @parser
  80. 38 @parser = nil
  81. 38 parser.close
  82. when :idle
  83. 66 @parser.callbacks.clear
  84. 66 set_parser_callbacks(@parser)
  85. end
  86. end
  87. 718 super
  88. end
  89. 8 def __http_proxy_connect(parser)
  90. 110 req = @pending.first
  91. 110 if req && req.uri.scheme == "https"
  92. # if the first request after CONNECT is to an https address, it is assumed that
  93. # all requests in the queue are not only ALL HTTPS, but they also share the certificate,
  94. # and therefore, will share the connection.
  95. #
  96. 44 connect_request = ConnectRequest.new(req.uri, @options)
  97. 44 @inflight += 1
  98. 44 parser.send(connect_request)
  99. else
  100. 66 handle_transition(:connected)
  101. end
  102. end
  103. 8 def __http_on_connect(request, response)
  104. 50 @inflight -= 1
  105. 50 if response.is_a?(Response) && response.status == 200
  106. 38 req = @pending.first
  107. 38 request_uri = req.uri
  108. 38 @io = ProxySSL.new(@io, request_uri, @options)
  109. 38 transition(:connected)
  110. 38 throw(:called)
  111. 11 elsif response.is_a?(Response) &&
  112. response.status == 407 &&
  113. !request.headers.key?("proxy-authorization") &&
  114. @options.proxy.can_authenticate?(response.headers["proxy-authenticate"])
  115. 6 request.transition(:idle)
  116. 6 request.headers["proxy-authorization"] = @options.proxy.authenticate(request, response.headers["proxy-authenticate"])
  117. 6 @parser.send(request)
  118. 6 @inflight += 1
  119. else
  120. 6 pending = @pending + @parser.pending
  121. 16 while (req = pending.shift)
  122. 6 req.emit(:response, response)
  123. end
  124. 6 reset
  125. end
  126. end
  127. end
  128. 8 module ProxyParser
  129. 8 def join_headline(request)
  130. 110 return super if request.verb == "CONNECT"
  131. 60 "#{request.verb} #{request.uri} HTTP/#{@version.join(".")}"
  132. end
  133. 8 def set_protocol_headers(request)
  134. 116 extra_headers = super
  135. 116 proxy_params = @options.proxy
  136. 116 if proxy_params.scheme == "basic"
  137. # opt for basic auth
  138. 73 extra_headers["proxy-authorization"] = proxy_params.authenticate(extra_headers)
  139. end
  140. 116 extra_headers["proxy-connection"] = extra_headers.delete("connection") if extra_headers.key?("connection")
  141. 116 extra_headers
  142. end
  143. end
  144. 8 class ConnectRequest < Request
  145. 8 def initialize(uri, options)
  146. 44 super("CONNECT", uri, options)
  147. 44 @headers.delete("accept")
  148. end
  149. 8 def path
  150. 56 "#{@uri.hostname}:#{@uri.port}"
  151. end
  152. end
  153. end
  154. end
  155. 8 register_plugin :"proxy/http", Proxy::HTTP
  156. end
  157. end

lib/httpx/plugins/proxy/socks4.rb

97.47% lines covered

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

lib/httpx/plugins/proxy/socks5.rb

99.12% lines covered

113 relevant lines. 112 lines covered and 1 lines missed.
    
  1. # frozen_string_literal: true
  2. 8 module HTTPX
  3. 8 class Socks5Error < HTTPProxyError; end
  4. 8 module Plugins
  5. 8 module Proxy
  6. 8 module Socks5
  7. 8 VERSION = 5
  8. 8 NOAUTH = 0
  9. 8 PASSWD = 2
  10. 8 NONE = 0xff
  11. 8 CONNECT = 1
  12. 8 IPV4 = 1
  13. 8 DOMAIN = 3
  14. 8 IPV6 = 4
  15. 8 SUCCESS = 0
  16. 8 Error = Socks5Error
  17. 8 class << self
  18. 8 def load_dependencies(*)
  19. 254 require_relative "../auth/socks5"
  20. end
  21. 8 def extra_options(options)
  22. 254 options.merge(supported_proxy_protocols: options.supported_proxy_protocols + %w[socks5])
  23. end
  24. end
  25. 8 module ConnectionMethods
  26. 8 def call
  27. 794 super
  28. 794 return unless @options.proxy && @options.proxy.uri.scheme == "socks5"
  29. 237 case @state
  30. when :connecting,
  31. :negotiating,
  32. :authenticating
  33. 81 consume
  34. end
  35. end
  36. 8 def connecting?
  37. 3448 super || @state == :authenticating || @state == :negotiating
  38. end
  39. 8 def interests
  40. 4603 if @state == :connecting || @state == :authenticating || @state == :negotiating
  41. 1681 return @write_buffer.empty? ? :r : :w
  42. end
  43. 2922 super
  44. end
  45. 8 private
  46. 8 def handle_transition(nextstate)
  47. 2076 return super unless @options.proxy && @options.proxy.uri.scheme == "socks5"
  48. 780 case nextstate
  49. when :connecting
  50. 216 return unless @state == :idle
  51. 216 @io.connect
  52. 216 return unless @io.connected?
  53. 108 @write_buffer << Packet.negotiate(@options.proxy)
  54. 108 __socks5_proxy_connect
  55. when :authenticating
  56. 36 return unless @state == :connecting
  57. 36 @write_buffer << Packet.authenticate(@options.proxy)
  58. when :negotiating
  59. 144 return unless @state == :connecting || @state == :authenticating
  60. 36 req = @pending.first
  61. 36 request_uri = req.uri
  62. 36 @write_buffer << Packet.connect(request_uri)
  63. when :connected
  64. 24 return unless @state == :negotiating
  65. 24 @parser = nil
  66. end
  67. 564 log(level: 1) { "SOCKS5: #{nextstate}: #{@write_buffer.to_s.inspect}" } unless nextstate == :open
  68. 564 super
  69. end
  70. 8 def __socks5_proxy_connect
  71. 108 @parser = SocksParser.new(@write_buffer, @options)
  72. 108 @parser.on(:packet, &method(:__socks5_on_packet))
  73. 108 transition(:negotiating)
  74. end
  75. 8 def __socks5_on_packet(packet)
  76. 180 case @state
  77. when :connecting
  78. 108 version, method = packet.unpack("CC")
  79. 108 __socks5_check_version(version)
  80. 108 case method
  81. when PASSWD
  82. 36 transition(:authenticating)
  83. 6 nil
  84. when NONE
  85. 60 __on_socks5_error("no supported authorization methods")
  86. else
  87. 12 transition(:negotiating)
  88. end
  89. when :authenticating
  90. 36 _, status = packet.unpack("CC")
  91. 36 return transition(:negotiating) if status == SUCCESS
  92. 12 __on_socks5_error("socks authentication error: #{status}")
  93. when :negotiating
  94. 36 version, reply, = packet.unpack("CC")
  95. 36 __socks5_check_version(version)
  96. 36 __on_socks5_error("socks5 negotiation error: #{reply}") unless reply == SUCCESS
  97. 24 req = @pending.first
  98. 24 request_uri = req.uri
  99. 24 @io = ProxySSL.new(@io, request_uri, @options) if request_uri.scheme == "https"
  100. 24 transition(:connected)
  101. 24 throw(:called)
  102. end
  103. end
  104. 8 def __socks5_check_version(version)
  105. 144 __on_socks5_error("invalid SOCKS version (#{version})") if version != 5
  106. end
  107. 8 def __on_socks5_error(message)
  108. 84 ex = Error.new(message)
  109. 84 ex.set_backtrace(caller)
  110. 84 on_error(ex)
  111. 84 throw(:called)
  112. end
  113. end
  114. 8 class SocksParser
  115. 8 include HTTPX::Callbacks
  116. 8 def initialize(buffer, options)
  117. 108 @buffer = buffer
  118. 108 @options = options
  119. end
  120. 8 def close; end
  121. 8 def consume(*); end
  122. 8 def empty?
  123. true
  124. end
  125. 8 def <<(packet)
  126. 180 emit(:packet, packet)
  127. end
  128. end
  129. 8 module Packet
  130. 8 module_function
  131. 8 def negotiate(parameters)
  132. 108 methods = [NOAUTH]
  133. 108 methods << PASSWD if parameters.can_authenticate?
  134. 108 methods.unshift(methods.size)
  135. 108 methods.unshift(VERSION)
  136. 108 methods.pack("C*")
  137. end
  138. 8 def authenticate(parameters)
  139. 36 parameters.authenticate
  140. end
  141. 8 def connect(uri)
  142. 36 packet = [VERSION, CONNECT, 0].pack("C*")
  143. 5 begin
  144. 36 ip = IPAddr.new(uri.host)
  145. 12 ipcode = ip.ipv6? ? IPV6 : IPV4
  146. 12 packet << [ipcode].pack("C") << ip.hton
  147. rescue IPAddr::InvalidAddressError
  148. 24 packet << [DOMAIN, uri.host.bytesize, uri.host].pack("CCA*")
  149. end
  150. 36 packet << [uri.port].pack("n")
  151. 36 packet
  152. end
  153. end
  154. end
  155. end
  156. 8 register_plugin :"proxy/socks5", Proxy::Socks5
  157. end
  158. end

lib/httpx/plugins/proxy/ssh.rb

92.31% lines covered

52 relevant lines. 48 lines covered and 4 lines missed.
    
  1. # frozen_string_literal: true
  2. 5 require "httpx/plugins/proxy"
  3. 5 module HTTPX
  4. 5 module Plugins
  5. 5 module Proxy
  6. 5 module SSH
  7. 5 class << self
  8. 5 def load_dependencies(*)
  9. 10 require "net/ssh/gateway"
  10. end
  11. end
  12. 5 module OptionsMethods
  13. 5 def option_proxy(value)
  14. 20 Hash[value]
  15. end
  16. end
  17. 5 module InstanceMethods
  18. 5 def request(*args, **options)
  19. 10 raise ArgumentError, "must perform at least one request" if args.empty?
  20. 10 requests = args.first.is_a?(Request) ? args : build_requests(*args, options)
  21. 10 request = requests.first or return super
  22. 10 request_options = request.options
  23. 10 return super unless request_options.proxy
  24. 10 ssh_options = request_options.proxy
  25. 10 ssh_uris = ssh_options.delete(:uri)
  26. 10 ssh_uri = URI.parse(ssh_uris.shift)
  27. 10 return super unless ssh_uri.scheme == "ssh"
  28. 10 ssh_username = ssh_options.delete(:username)
  29. 10 ssh_options[:port] ||= ssh_uri.port || 22
  30. 10 if request_options.debug
  31. ssh_options[:verbose] = request_options.debug_level == 2 ? :debug : :info
  32. end
  33. 10 request_uri = URI(requests.first.uri)
  34. 10 @_gateway = Net::SSH::Gateway.new(ssh_uri.host, ssh_username, ssh_options)
  35. begin
  36. 10 @_gateway.open(request_uri.host, request_uri.port) do |local_port|
  37. 10 io = build_gateway_socket(local_port, request_uri, request_options)
  38. 10 super(*args, **options.merge(io: io))
  39. end
  40. ensure
  41. 10 @_gateway.shutdown!
  42. end
  43. end
  44. 5 private
  45. 5 def build_gateway_socket(port, request_uri, options)
  46. 10 case request_uri.scheme
  47. when "https"
  48. 5 ctx = OpenSSL::SSL::SSLContext.new
  49. 5 ctx_options = SSL::TLS_OPTIONS.merge(options.ssl)
  50. 5 ctx.set_params(ctx_options) unless ctx_options.empty?
  51. 5 sock = TCPSocket.open("localhost", port)
  52. 5 io = OpenSSL::SSL::SSLSocket.new(sock, ctx)
  53. 5 io.hostname = request_uri.host
  54. 5 io.sync_close = true
  55. 5 io.connect
  56. 5 io.post_connection_check(request_uri.host) if ctx.verify_mode != OpenSSL::SSL::VERIFY_NONE
  57. 5 io
  58. when "http"
  59. 5 TCPSocket.open("localhost", port)
  60. else
  61. raise TypeError, "unexpected scheme: #{request_uri.scheme}"
  62. end
  63. end
  64. end
  65. 5 module ConnectionMethods
  66. # should not coalesce connections here, as the IP is the IP of the proxy
  67. 5 def coalescable?(*)
  68. return super unless @options.proxy
  69. false
  70. end
  71. end
  72. end
  73. end
  74. 5 register_plugin :"proxy/ssh", Proxy::SSH
  75. end
  76. end

lib/httpx/plugins/push_promise.rb

100.0% lines covered

41 relevant lines. 41 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 6 module HTTPX
  3. 6 module Plugins
  4. #
  5. # This plugin adds support for HTTP/2 Push responses.
  6. #
  7. # In order to benefit from this, requests are sent one at a time, so that
  8. # no push responses are received after corresponding request has been sent.
  9. #
  10. # https://gitlab.com/os85/httpx/wikis/Server-Push
  11. #
  12. 6 module PushPromise
  13. 6 def self.extra_options(options)
  14. 12 options.merge(http2_settings: { settings_enable_push: 1 },
  15. max_concurrent_requests: 1)
  16. end
  17. 6 module ResponseMethods
  18. 6 def pushed?
  19. 12 @__pushed
  20. end
  21. 6 def mark_as_pushed!
  22. 6 @__pushed = true
  23. end
  24. end
  25. 6 module InstanceMethods
  26. 6 private
  27. 6 def promise_headers
  28. 12 @promise_headers ||= {}
  29. end
  30. 6 def on_promise(parser, stream)
  31. 12 stream.on(:promise_headers) do |h|
  32. 12 __on_promise_request(parser, stream, h)
  33. end
  34. 12 stream.on(:headers) do |h|
  35. 6 __on_promise_response(parser, stream, h)
  36. end
  37. end
  38. 6 def __on_promise_request(parser, stream, h)
  39. 12 log(level: 1, color: :yellow) do
  40. skipped # :nocov:
  41. skipped h.map { |k, v| "#{stream.id}: -> PROMISE HEADER: #{k}: #{v}" }.join("\n")
  42. skipped # :nocov:
  43. end
  44. 12 headers = @options.headers_class.new(h)
  45. 12 path = headers[":path"]
  46. 12 authority = headers[":authority"]
  47. 18 request = parser.pending.find { |r| r.authority == authority && r.path == path }
  48. 12 if request
  49. 6 request.merge_headers(headers)
  50. 6 promise_headers[stream] = request
  51. 6 parser.pending.delete(request)
  52. 6 parser.streams[request] = stream
  53. 6 request.transition(:done)
  54. else
  55. 6 stream.refuse
  56. end
  57. end
  58. 6 def __on_promise_response(parser, stream, h)
  59. 6 request = promise_headers.delete(stream)
  60. 6 return unless request
  61. 6 parser.__send__(:on_stream_headers, stream, request, h)
  62. 6 response = request.response
  63. 6 response.mark_as_pushed!
  64. 6 stream.on(:data, &parser.method(:on_stream_data).curry(3)[stream, request])
  65. 6 stream.on(:close, &parser.method(:on_stream_close).curry(3)[stream, request])
  66. end
  67. end
  68. end
  69. 6 register_plugin(:push_promise, PushPromise)
  70. end
  71. end

lib/httpx/plugins/rate_limiter.rb

100.0% lines covered

18 relevant lines. 18 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 6 module HTTPX
  3. 6 module Plugins
  4. #
  5. # This plugin adds support for retrying requests when the request:
  6. #
  7. # * is rate limited;
  8. # * when the server is unavailable (503);
  9. # * when a 3xx request comes with a "retry-after" value
  10. #
  11. # https://gitlab.com/os85/httpx/wikis/Rate-Limiter
  12. #
  13. 6 module RateLimiter
  14. 6 class << self
  15. 6 RATE_LIMIT_CODES = [429, 503].freeze
  16. 6 def configure(klass)
  17. 48 klass.plugin(:retries,
  18. retry_change_requests: true,
  19. 7 retry_on: method(:retry_on_rate_limited_response),
  20. retry_after: method(:retry_after_rate_limit))
  21. end
  22. 6 def retry_on_rate_limited_response(response)
  23. 96 return false unless response.is_a?(Response)
  24. 96 status = response.status
  25. 96 RATE_LIMIT_CODES.include?(status)
  26. end
  27. # Servers send the "Retry-After" header field to indicate how long the
  28. # user agent ought to wait before making a follow-up request. When
  29. # sent with a 503 (Service Unavailable) response, Retry-After indicates
  30. # how long the service is expected to be unavailable to the client.
  31. # When sent with any 3xx (Redirection) response, Retry-After indicates
  32. # the minimum time that the user agent is asked to wait before issuing
  33. # the redirected request.
  34. #
  35. 6 def retry_after_rate_limit(_, response)
  36. 48 return unless response.is_a?(Response)
  37. 48 retry_after = response.headers["retry-after"]
  38. 48 return unless retry_after
  39. 24 Utils.parse_retry_after(retry_after)
  40. end
  41. end
  42. end
  43. 6 register_plugin :rate_limiter, RateLimiter
  44. end
  45. end

lib/httpx/plugins/response_cache.rb

100.0% lines covered

80 relevant lines. 80 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 6 module HTTPX
  3. 6 module Plugins
  4. #
  5. # This plugin adds support for retrying requests when certain errors happen.
  6. #
  7. # https://gitlab.com/os85/httpx/wikis/Response-Cache
  8. #
  9. 6 module ResponseCache
  10. 6 CACHEABLE_VERBS = %w[GET HEAD].freeze
  11. 6 CACHEABLE_STATUS_CODES = [200, 203, 206, 300, 301, 410].freeze
  12. 6 private_constant :CACHEABLE_VERBS
  13. 6 private_constant :CACHEABLE_STATUS_CODES
  14. 6 class << self
  15. 6 def load_dependencies(*)
  16. 132 require_relative "response_cache/store"
  17. end
  18. 6 def cacheable_request?(request)
  19. 186 CACHEABLE_VERBS.include?(request.verb) &&
  20. (
  21. 186 !request.headers.key?("cache-control") || !request.headers.get("cache-control").include?("no-store")
  22. )
  23. end
  24. 6 def cacheable_response?(response)
  25. 126 response.is_a?(Response) &&
  26. (
  27. 126 response.cache_control.nil? ||
  28. # TODO: !response.cache_control.include?("private") && is shared cache
  29. !response.cache_control.include?("no-store")
  30. ) &&
  31. CACHEABLE_STATUS_CODES.include?(response.status) &&
  32. # RFC 2616 13.4 - A response received with a status code of 200, 203, 206, 300, 301 or
  33. # 410 MAY be stored by a cache and used in reply to a subsequent
  34. # request, subject to the expiration mechanism, unless a cache-control
  35. # directive prohibits caching. However, a cache that does not support
  36. # the Range and Content-Range headers MUST NOT cache 206 (Partial
  37. # Content) responses.
  38. response.status != 206 && (
  39. 95 response.headers.key?("etag") || response.headers.key?("last-modified") || response.fresh?
  40. )
  41. end
  42. 6 def cached_response?(response)
  43. 60 response.is_a?(Response) && response.status == 304
  44. end
  45. 6 def extra_options(options)
  46. 132 options.merge(response_cache_store: Store.new)
  47. end
  48. end
  49. 6 module OptionsMethods
  50. 6 def option_response_cache_store(value)
  51. 132 raise TypeError, "must be an instance of #{Store}" unless value.is_a?(Store)
  52. 132 value
  53. end
  54. end
  55. 6 module InstanceMethods
  56. 6 def clear_response_cache
  57. 12 @options.response_cache_store.clear
  58. end
  59. 6 def build_request(*)
  60. 60 request = super
  61. 60 return request unless ResponseCache.cacheable_request?(request) && @options.response_cache_store.cached?(request)
  62. 24 @options.response_cache_store.prepare(request)
  63. 24 request
  64. end
  65. 6 def fetch_response(request, *)
  66. 208 response = super
  67. 208 return unless response
  68. 60 if ResponseCache.cached_response?(response)
  69. 24 log { "returning cached response for #{request.uri}" }
  70. 24 cached_response = @options.response_cache_store.lookup(request)
  71. 24 response.copy_from_cached(cached_response)
  72. else
  73. 36 @options.response_cache_store.cache(request, response)
  74. end
  75. 60 response
  76. end
  77. end
  78. 6 module RequestMethods
  79. 6 def response_cache_key
  80. 384 @response_cache_key ||= Digest::SHA1.hexdigest("httpx-response-cache-#{@verb}-#{@uri}")
  81. end
  82. end
  83. 6 module ResponseMethods
  84. 6 def copy_from_cached(other)
  85. # 304 responses do not have content-type, which are needed for decoding.
  86. 24 @headers = @headers.class.new(other.headers.merge(@headers))
  87. 24 @body = other.body.dup
  88. 24 @body.rewind
  89. end
  90. # A response is fresh if its age has not yet exceeded its freshness lifetime.
  91. 6 def fresh?
  92. 192 if cache_control
  93. 30 return false if cache_control.include?("no-cache")
  94. # check age: max-age
  95. 36 max_age = cache_control.find { |directive| directive.start_with?("s-maxage") }
  96. 36 max_age ||= cache_control.find { |directive| directive.start_with?("max-age") }
  97. 18 max_age = max_age[/age=(\d+)/, 1] if max_age
  98. 18 max_age = max_age.to_i if max_age
  99. 18 return max_age > age if max_age
  100. end
  101. # check age: expires
  102. 162 if @headers.key?("expires")
  103. 2 begin
  104. 18 expires = Time.httpdate(@headers["expires"])
  105. rescue ArgumentError
  106. 6 return true
  107. end
  108. 12 return (expires - Time.now).to_i.positive?
  109. end
  110. 144 true
  111. end
  112. 6 def cache_control
  113. 414 return @cache_control if defined?(@cache_control)
  114. @cache_control = begin
  115. 288 return unless @headers.key?("cache-control")
  116. 30 @headers["cache-control"].split(/ *, */)
  117. end
  118. end
  119. 6 def vary
  120. 210 return @vary if defined?(@vary)
  121. @vary = begin
  122. 168 return unless @headers.key?("vary")
  123. 12 @headers["vary"].split(/ *, */)
  124. end
  125. end
  126. 6 private
  127. 6 def age
  128. 18 return @headers["age"].to_i if @headers.key?("age")
  129. 18 (Time.now - date).to_i
  130. end
  131. 6 def date
  132. 18 @date ||= Time.httpdate(@headers["date"])
  133. rescue NoMethodError, ArgumentError
  134. 6 Time.now
  135. end
  136. end
  137. end
  138. 6 register_plugin :response_cache, ResponseCache
  139. end
  140. end

lib/httpx/plugins/response_cache/store.rb

100.0% lines covered

47 relevant lines. 47 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 6 module HTTPX::Plugins
  3. 6 module ResponseCache
  4. 6 class Store
  5. 6 def initialize
  6. 186 @store = {}
  7. 186 @store_mutex = Thread::Mutex.new
  8. end
  9. 6 def clear
  10. 24 @store_mutex.synchronize { @store.clear }
  11. end
  12. 6 def lookup(request)
  13. 234 responses = _get(request)
  14. 234 return unless responses
  15. 180 responses.find(&method(:match_by_vary?).curry(2)[request])
  16. end
  17. 6 def cached?(request)
  18. 84 lookup(request)
  19. end
  20. 6 def cache(request, response)
  21. 126 return unless ResponseCache.cacheable_request?(request) && ResponseCache.cacheable_response?(response)
  22. 114 _set(request, response)
  23. end
  24. 6 def prepare(request)
  25. 60 cached_response = lookup(request)
  26. 60 return unless cached_response
  27. 42 return unless match_by_vary?(request, cached_response)
  28. 42 if !request.headers.key?("if-modified-since") && (last_modified = cached_response.headers["last-modified"])
  29. 24 request.headers.add("if-modified-since", last_modified)
  30. end
  31. 42 if !request.headers.key?("if-none-match") && (etag = cached_response.headers["etag"]) # rubocop:disable Style/GuardClause
  32. 42 request.headers.add("if-none-match", etag)
  33. end
  34. end
  35. 6 private
  36. 6 def match_by_vary?(request, response)
  37. 210 vary = response.vary
  38. 210 return true unless vary
  39. 54 original_request = response.instance_variable_get(:@request)
  40. 54 return request.headers.same_headers?(original_request.headers) if vary == %w[*]
  41. 30 vary.all? do |cache_field|
  42. 30 cache_field.downcase!
  43. 30 !original_request.headers.key?(cache_field) || request.headers[cache_field] == original_request.headers[cache_field]
  44. end
  45. end
  46. 6 def _get(request)
  47. 234 @store_mutex.synchronize do
  48. 234 responses = @store[request.response_cache_key]
  49. 234 return unless responses
  50. 180 responses.select! do |res|
  51. 180 !res.body.closed? && res.fresh?
  52. end
  53. 180 responses
  54. end
  55. end
  56. 6 def _set(request, response)
  57. 114 @store_mutex.synchronize do
  58. 114 responses = (@store[request.response_cache_key] ||= [])
  59. 114 responses.reject! do |res|
  60. 12 res.body.closed? || !res.fresh? || match_by_vary?(request, res)
  61. end
  62. 114 responses << response
  63. end
  64. end
  65. end
  66. end
  67. end

lib/httpx/plugins/retries.rb

95.74% lines covered

94 relevant lines. 90 lines covered and 4 lines missed.
    
  1. # frozen_string_literal: true
  2. 13 module HTTPX
  3. 13 module Plugins
  4. #
  5. # This plugin adds support for retrying requests when errors happen.
  6. #
  7. # It has a default max number of retries (see *MAX_RETRIES* and the *max_retries* option),
  8. # after which it will return the last response, error or not. It will **not** raise an exception.
  9. #
  10. # It does not retry which are not considered idempotent (see *retry_change_requests* to override).
  11. #
  12. # https://gitlab.com/os85/httpx/wikis/Retries
  13. #
  14. 13 module Retries
  15. 13 MAX_RETRIES = 3
  16. # TODO: pass max_retries in a configure/load block
  17. 13 IDEMPOTENT_METHODS = %w[GET OPTIONS HEAD PUT DELETE].freeze
  18. 1 RETRYABLE_ERRORS = [
  19. 12 IOError,
  20. EOFError,
  21. Errno::ECONNRESET,
  22. Errno::ECONNABORTED,
  23. Errno::EPIPE,
  24. Errno::EINVAL,
  25. Errno::ETIMEDOUT,
  26. Parser::Error,
  27. TLSError,
  28. TimeoutError,
  29. ConnectionError,
  30. Connection::HTTP2::GoawayError,
  31. ].freeze
  32. 13 DEFAULT_JITTER = ->(interval) { interval * ((rand + 1) * 0.5) }
  33. 13 if ENV.key?("HTTPX_NO_JITTER")
  34. 13 def self.extra_options(options)
  35. 516 options.merge(max_retries: MAX_RETRIES)
  36. end
  37. else
  38. def self.extra_options(options)
  39. options.merge(max_retries: MAX_RETRIES, retry_jitter: DEFAULT_JITTER)
  40. end
  41. end
  42. # adds support for the following options:
  43. #
  44. # :max_retries :: max number of times a request will be retried (defaults to <tt>3</tt>).
  45. # :retry_change_requests :: whether idempotent requests are retried (defaults to <tt>false</tt>).
  46. # :retry_after:: seconds after which a request is retried; can also be a callable object (i.e. <tt>->(req, res) { ... } </tt>)
  47. # :retry_jitter :: number of seconds applied to *:retry_after* (must be a callable, i.e. <tt>->(retry_after) { ... } </tt>).
  48. # :retry_on :: callable which alternatively defines a different rule for when a response is to be retried
  49. # (i.e. <tt>->(res) { ... }</tt>).
  50. 13 module OptionsMethods
  51. 13 def option_retry_after(value)
  52. # return early if callable
  53. 156 unless value.respond_to?(:call)
  54. 72 value = Float(value)
  55. 72 raise TypeError, ":retry_after must be positive" unless value.positive?
  56. end
  57. 156 value
  58. end
  59. 13 def option_retry_jitter(value)
  60. # return early if callable
  61. 36 raise TypeError, ":retry_jitter must be callable" unless value.respond_to?(:call)
  62. 36 value
  63. end
  64. 13 def option_max_retries(value)
  65. 1628 num = Integer(value)
  66. 1628 raise TypeError, ":max_retries must be positive" unless num >= 0
  67. 1628 num
  68. end
  69. 13 def option_retry_change_requests(v)
  70. 891 v
  71. end
  72. 13 def option_retry_on(value)
  73. 173 raise TypeError, ":retry_on must be called with the response" unless value.respond_to?(:call)
  74. 173 value
  75. end
  76. end
  77. 13 module InstanceMethods
  78. 13 def max_retries(n)
  79. 72 with(max_retries: n)
  80. end
  81. 13 private
  82. 13 def fetch_response(request, selector, options)
  83. 1995058 response = super
  84. 1995058 if response &&
  85. request.retries.positive? &&
  86. __repeatable_request?(request, options) &&
  87. (
  88. (
  89. 124 response.is_a?(ErrorResponse) && __retryable_error?(response.error)
  90. ) ||
  91. (
  92. 92 options.retry_on && options.retry_on.call(response)
  93. )
  94. )
  95. 343 __try_partial_retry(request, response)
  96. 343 log { "failed to get response, #{request.retries} tries to go..." }
  97. 343 request.retries -= 1
  98. 343 request.transition(:idle)
  99. 343 retry_after = options.retry_after
  100. 343 retry_after = retry_after.call(request, response) if retry_after.respond_to?(:call)
  101. 343 if retry_after
  102. # apply jitter
  103. 72 if (jitter = request.options.retry_jitter)
  104. 12 retry_after = jitter.call(retry_after)
  105. end
  106. 72 retry_start = Utils.now
  107. 72 log { "retrying after #{retry_after} secs..." }
  108. 72 selector.after(retry_after) do
  109. 72 if request.response
  110. # request has terminated abruptly meanwhile
  111. request.emit(:response, request.response)
  112. else
  113. 72 log { "retrying (elapsed time: #{Utils.elapsed_time(retry_start)})!!" }
  114. 72 send_request(request, selector, options)
  115. end
  116. end
  117. else
  118. 271 send_request(request, selector, options)
  119. end
  120. 343 return
  121. end
  122. 1994715 response
  123. end
  124. 13 def __repeatable_request?(request, options)
  125. 713 IDEMPOTENT_METHODS.include?(request.verb) || options.retry_change_requests
  126. end
  127. 13 def __retryable_error?(ex)
  128. 2489 RETRYABLE_ERRORS.any? { |klass| ex.is_a?(klass) }
  129. end
  130. 13 def proxy_error?(request, response, _)
  131. 48 super && !request.retries.positive?
  132. end
  133. #
  134. # Atttempt to set the request to perform a partial range request.
  135. # This happens if the peer server accepts byte-range requests, and
  136. # the last response contains some body payload.
  137. #
  138. 13 def __try_partial_retry(request, response)
  139. 343 response = response.response if response.is_a?(ErrorResponse)
  140. 343 return unless response
  141. 154 unless response.headers.key?("accept-ranges") &&
  142. response.headers["accept-ranges"] == "bytes" && # there's nothing else supported though...
  143. 12 (original_body = response.body)
  144. 142 response.close if response.respond_to?(:close)
  145. 142 return
  146. end
  147. 12 request.partial_response = response
  148. 12 size = original_body.bytesize
  149. 12 request.headers["range"] = "bytes=#{size}-"
  150. end
  151. end
  152. 13 module RequestMethods
  153. 13 attr_accessor :retries
  154. 13 attr_writer :partial_response
  155. 13 def initialize(*args)
  156. 530 super
  157. 530 @retries = @options.max_retries
  158. end
  159. 13 def response=(response)
  160. 885 if @partial_response
  161. 12 if response.is_a?(Response) && response.status == 206
  162. 12 response.from_partial_response(@partial_response)
  163. else
  164. @partial_response.close
  165. end
  166. 12 @partial_response = nil
  167. end
  168. 885 super
  169. end
  170. end
  171. 13 module ResponseMethods
  172. 13 def from_partial_response(response)
  173. 12 @status = response.status
  174. 12 @headers = response.headers
  175. 12 @body = response.body
  176. end
  177. end
  178. end
  179. 13 register_plugin :retries, Retries
  180. end
  181. end

lib/httpx/plugins/ssrf_filter.rb

100.0% lines covered

61 relevant lines. 61 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 6 module HTTPX
  3. 6 class ServerSideRequestForgeryError < Error; end
  4. 6 module Plugins
  5. #
  6. # This plugin adds support for preventing Server-Side Request Forgery attacks.
  7. #
  8. # https://gitlab.com/os85/httpx/wikis/Server-Side-Request-Forgery-Filter
  9. #
  10. 6 module SsrfFilter
  11. 6 module IPAddrExtensions
  12. 6 refine IPAddr do
  13. 6 def prefixlen
  14. 96 mask_addr = @mask_addr
  15. 96 raise "Invalid mask" if mask_addr.zero?
  16. 96 mask_addr >>= 1 while (mask_addr & 0x1).zero?
  17. 96 length = 0
  18. 96 while mask_addr & 0x1 == 0x1
  19. 1518 length += 1
  20. 1518 mask_addr >>= 1
  21. end
  22. 96 length
  23. end
  24. end
  25. end
  26. 6 using IPAddrExtensions
  27. # https://en.wikipedia.org/wiki/Reserved_IP_addresses
  28. 1 IPV4_BLACKLIST = [
  29. 5 IPAddr.new("0.0.0.0/8"), # Current network (only valid as source address)
  30. IPAddr.new("10.0.0.0/8"), # Private network
  31. IPAddr.new("100.64.0.0/10"), # Shared Address Space
  32. IPAddr.new("127.0.0.0/8"), # Loopback
  33. IPAddr.new("169.254.0.0/16"), # Link-local
  34. IPAddr.new("172.16.0.0/12"), # Private network
  35. IPAddr.new("192.0.0.0/24"), # IETF Protocol Assignments
  36. IPAddr.new("192.0.2.0/24"), # TEST-NET-1, documentation and examples
  37. IPAddr.new("192.88.99.0/24"), # IPv6 to IPv4 relay (includes 2002::/16)
  38. IPAddr.new("192.168.0.0/16"), # Private network
  39. IPAddr.new("198.18.0.0/15"), # Network benchmark tests
  40. IPAddr.new("198.51.100.0/24"), # TEST-NET-2, documentation and examples
  41. IPAddr.new("203.0.113.0/24"), # TEST-NET-3, documentation and examples
  42. IPAddr.new("224.0.0.0/4"), # IP multicast (former Class D network)
  43. IPAddr.new("240.0.0.0/4"), # Reserved (former Class E network)
  44. IPAddr.new("255.255.255.255"), # Broadcast
  45. ].freeze
  46. 2 IPV6_BLACKLIST = ([
  47. 5 IPAddr.new("::1/128"), # Loopback
  48. IPAddr.new("64:ff9b::/96"), # IPv4/IPv6 translation (RFC 6052)
  49. IPAddr.new("100::/64"), # Discard prefix (RFC 6666)
  50. IPAddr.new("2001::/32"), # Teredo tunneling
  51. IPAddr.new("2001:10::/28"), # Deprecated (previously ORCHID)
  52. IPAddr.new("2001:20::/28"), # ORCHIDv2
  53. IPAddr.new("2001:db8::/32"), # Addresses used in documentation and example source code
  54. IPAddr.new("2002::/16"), # 6to4
  55. IPAddr.new("fc00::/7"), # Unique local address
  56. IPAddr.new("fe80::/10"), # Link-local address
  57. IPAddr.new("ff00::/8"), # Multicast
  58. ] + IPV4_BLACKLIST.flat_map do |ipaddr|
  59. 96 prefixlen = ipaddr.prefixlen
  60. 96 ipv4_compatible = ipaddr.ipv4_compat.mask(96 + prefixlen)
  61. 96 ipv4_mapped = ipaddr.ipv4_mapped.mask(80 + prefixlen)
  62. 96 [ipv4_compatible, ipv4_mapped]
  63. end).freeze
  64. 6 class << self
  65. 6 def extra_options(options)
  66. 52 options.merge(allowed_schemes: %w[https http])
  67. end
  68. 6 def unsafe_ip_address?(ipaddr)
  69. 70 range = ipaddr.to_range
  70. 70 return true if range.first != range.last
  71. 82 return IPV6_BLACKLIST.any? { |r| r.include?(ipaddr) } if ipaddr.ipv6?
  72. 700 IPV4_BLACKLIST.any? { |r| r.include?(ipaddr) } # then it's IPv4
  73. end
  74. end
  75. # adds support for the following options:
  76. #
  77. # :allowed_schemes :: list of URI schemes allowed (defaults to <tt>["https", "http"]</tt>)
  78. 6 module OptionsMethods
  79. 6 def option_allowed_schemes(value)
  80. 58 Array(value)
  81. end
  82. end
  83. 6 module InstanceMethods
  84. 6 def send_requests(*requests)
  85. 64 responses = requests.map do |request|
  86. 64 next if @options.allowed_schemes.include?(request.uri.scheme)
  87. 6 error = ServerSideRequestForgeryError.new("#{request.uri} URI scheme not allowed")
  88. 6 error.set_backtrace(caller)
  89. 6 response = ErrorResponse.new(request, error)
  90. 6 request.emit(:response, response)
  91. 6 response
  92. end
  93. 128 allowed_requests = requests.select { |req| responses[requests.index(req)].nil? }
  94. 64 allowed_responses = super(*allowed_requests)
  95. 64 allowed_responses.each_with_index do |res, idx|
  96. 58 req = allowed_requests[idx]
  97. 58 responses[requests.index(req)] = res
  98. end
  99. 64 responses
  100. end
  101. end
  102. 6 module ConnectionMethods
  103. 6 def initialize(*)
  104. begin
  105. 58 super
  106. 8 rescue ServerSideRequestForgeryError => e
  107. # may raise when IPs are passed as options via :addresses
  108. 12 throw(:resolve_error, e)
  109. end
  110. end
  111. 6 def addresses=(addrs)
  112. 128 addrs = addrs.map { |addr| addr.is_a?(IPAddr) ? addr : IPAddr.new(addr) }
  113. 58 addrs.reject!(&SsrfFilter.method(:unsafe_ip_address?))
  114. 58 raise ServerSideRequestForgeryError, "#{@origin.host} has no public IP addresses" if addrs.empty?
  115. 12 super
  116. end
  117. end
  118. end
  119. 6 register_plugin :ssrf_filter, SsrfFilter
  120. end
  121. end

lib/httpx/plugins/stream.rb

100.0% lines covered

74 relevant lines. 74 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 11 module HTTPX
  3. 11 class StreamResponse
  4. 11 def initialize(request, session)
  5. 131 @request = request
  6. 131 @session = session
  7. 131 @response = nil
  8. end
  9. 11 def each(&block)
  10. 167 return enum_for(__method__) unless block
  11. 119 @request.stream = self
  12. 13 begin
  13. 119 @on_chunk = block
  14. 119 if @request.response
  15. # if we've already started collecting the payload, yield it first
  16. # before proceeding.
  17. 12 body = @request.response.body
  18. 12 body.each do |chunk|
  19. 12 on_chunk(chunk)
  20. end
  21. end
  22. 119 response.raise_for_status
  23. ensure
  24. 119 @on_chunk = nil
  25. end
  26. end
  27. 11 def each_line
  28. 82 return enum_for(__method__) unless block_given?
  29. 41 line = "".b
  30. 41 each do |chunk|
  31. 34 line << chunk
  32. 97 while (idx = line.index("\n"))
  33. 41 yield line.byteslice(0..idx - 1)
  34. 41 line = line.byteslice(idx + 1..-1)
  35. end
  36. end
  37. 17 yield line unless line.empty?
  38. end
  39. # This is a ghost method. It's to be used ONLY internally, when processing streams
  40. 11 def on_chunk(chunk)
  41. 176 raise NoMethodError unless @on_chunk
  42. 176 @on_chunk.call(chunk)
  43. end
  44. skipped # :nocov:
  45. skipped def inspect
  46. skipped "#<StreamResponse:#{object_id}>"
  47. skipped end
  48. skipped # :nocov:
  49. 11 def to_s
  50. 12 response.to_s
  51. end
  52. 11 private
  53. 11 def response
  54. 429 return @response if @response
  55. 161 @request.response || begin
  56. 131 @response = @session.request(@request)
  57. end
  58. end
  59. 11 def respond_to_missing?(meth, *args)
  60. 12 response.respond_to?(meth, *args) || super
  61. end
  62. 11 def method_missing(meth, *args, &block)
  63. 143 return super unless response.respond_to?(meth)
  64. 143 response.__send__(meth, *args, &block)
  65. end
  66. end
  67. 11 module Plugins
  68. #
  69. # This plugin adds support for stream response (text/event-stream).
  70. #
  71. # https://gitlab.com/os85/httpx/wikis/Stream
  72. #
  73. 11 module Stream
  74. 11 def self.extra_options(options)
  75. 238 options.merge(timeout: { read_timeout: Float::INFINITY, operation_timeout: 60 })
  76. end
  77. 11 module InstanceMethods
  78. 11 def request(*args, stream: false, **options)
  79. 371 return super(*args, **options) unless stream
  80. 143 requests = args.first.is_a?(Request) ? args : build_requests(*args, options)
  81. 143 raise Error, "only 1 response at a time is supported for streaming requests" unless requests.size == 1
  82. 131 request = requests.first
  83. 131 StreamResponse.new(request, self)
  84. end
  85. end
  86. 11 module RequestMethods
  87. 11 attr_accessor :stream
  88. end
  89. 11 module ResponseMethods
  90. 11 def stream
  91. 223 request = @request.root_request if @request.respond_to?(:root_request)
  92. 223 request ||= @request
  93. 223 request.stream
  94. end
  95. end
  96. 11 module ResponseBodyMethods
  97. 11 def initialize(*)
  98. 223 super
  99. 223 @stream = @response.stream
  100. end
  101. 11 def write(chunk)
  102. 305 return super unless @stream
  103. 186 return 0 if chunk.empty?
  104. 164 chunk = decode_chunk(chunk)
  105. 164 @stream.on_chunk(chunk.dup)
  106. 164 chunk.size
  107. end
  108. 11 private
  109. 11 def transition(*)
  110. 148 return if @stream
  111. 148 super
  112. end
  113. end
  114. end
  115. 11 register_plugin :stream, Stream
  116. end
  117. end

lib/httpx/plugins/upgrade.rb

100.0% lines covered

34 relevant lines. 34 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 6 module HTTPX
  3. 6 module Plugins
  4. #
  5. # This plugin helps negotiating a new protocol from an HTTP/1.1 connection, via the
  6. # Upgrade header.
  7. #
  8. # https://gitlab.com/os85/httpx/wikis/Upgrade
  9. #
  10. 6 module Upgrade
  11. 6 class << self
  12. 6 def configure(klass)
  13. 24 klass.plugin(:"upgrade/h2")
  14. end
  15. 6 def extra_options(options)
  16. 24 options.merge(upgrade_handlers: {})
  17. end
  18. end
  19. 6 module OptionsMethods
  20. 6 def option_upgrade_handlers(value)
  21. 66 raise TypeError, ":upgrade_handlers must be a Hash" unless value.is_a?(Hash)
  22. 66 value
  23. end
  24. end
  25. 6 module InstanceMethods
  26. 6 def fetch_response(request, selector, options)
  27. 201 response = super
  28. 201 if response
  29. 66 return response unless response.is_a?(Response)
  30. 66 return response unless response.headers.key?("upgrade")
  31. 24 upgrade_protocol = response.headers["upgrade"].split(/ *, */).first
  32. 24 return response unless upgrade_protocol && options.upgrade_handlers.key?(upgrade_protocol)
  33. 24 protocol_handler = options.upgrade_handlers[upgrade_protocol]
  34. 24 return response unless protocol_handler
  35. 24 log { "upgrading to #{upgrade_protocol}..." }
  36. 24 connection = find_connection(request.uri, selector, options)
  37. # do not upgrade already upgraded connections
  38. 24 return if connection.upgrade_protocol == upgrade_protocol
  39. 24 protocol_handler.call(connection, request, response)
  40. # keep in the loop if the server is switching, unless
  41. # the connection has been hijacked, in which case you want
  42. # to terminante immediately
  43. 24 return if response.status == 101 && !connection.hijacked
  44. end
  45. 147 response
  46. end
  47. end
  48. 6 module ConnectionMethods
  49. 6 attr_reader :upgrade_protocol, :hijacked
  50. 6 def hijack_io
  51. 6 @hijacked = true
  52. # connection is taken away from selector and not given back to the pool.
  53. 6 @current_session.deselect_connection(self, @current_selector, true)
  54. end
  55. end
  56. end
  57. 6 register_plugin(:upgrade, Upgrade)
  58. end
  59. end

lib/httpx/plugins/upgrade/h2.rb

91.67% lines covered

24 relevant lines. 22 lines covered and 2 lines missed.
    
  1. # frozen_string_literal: true
  2. 6 module HTTPX
  3. 6 module Plugins
  4. #
  5. # This plugin adds support for upgrading an HTTP/1.1 connection to HTTP/2
  6. # via an Upgrade: h2 response declaration
  7. #
  8. # https://gitlab.com/os85/httpx/wikis/Connection-Upgrade#h2
  9. #
  10. 6 module H2
  11. 6 class << self
  12. 6 def extra_options(options)
  13. 24 options.merge(upgrade_handlers: options.upgrade_handlers.merge("h2" => self))
  14. end
  15. 6 def call(connection, _request, _response)
  16. 6 connection.upgrade_to_h2
  17. end
  18. end
  19. 6 module ConnectionMethods
  20. 6 using URIExtensions
  21. 6 def upgrade_to_h2
  22. 6 prev_parser = @parser
  23. 6 if prev_parser
  24. 6 prev_parser.reset
  25. 6 @inflight -= prev_parser.requests.size
  26. end
  27. 6 @parser = Connection::HTTP2.new(@write_buffer, @options)
  28. 6 set_parser_callbacks(@parser)
  29. 6 @upgrade_protocol = "h2"
  30. # what's happening here:
  31. # a deviation from the state machine is done to perform the actions when a
  32. # connection is closed, without transitioning, so the connection is kept in the pool.
  33. # the state is reset to initial, so that the socket reconnect works out of the box,
  34. # while the parser is already here.
  35. 6 purge_after_closed
  36. 6 transition(:idle)
  37. 6 prev_parser.requests.each do |req|
  38. req.transition(:idle)
  39. send(req)
  40. end
  41. end
  42. end
  43. end
  44. 6 register_plugin(:"upgrade/h2", H2)
  45. end
  46. end

lib/httpx/plugins/webdav.rb

100.0% lines covered

39 relevant lines. 39 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 6 module HTTPX
  3. 6 module Plugins
  4. #
  5. # This plugin implements convenience methods for performing WEBDAV requests.
  6. #
  7. # https://gitlab.com/os85/httpx/wikis/WebDav
  8. #
  9. 6 module WebDav
  10. 6 def self.configure(klass)
  11. 72 klass.plugin(:xml)
  12. end
  13. 6 module InstanceMethods
  14. 6 def copy(src, dest)
  15. 12 request("COPY", src, headers: { "destination" => @options.origin.merge(dest) })
  16. end
  17. 6 def move(src, dest)
  18. 12 request("MOVE", src, headers: { "destination" => @options.origin.merge(dest) })
  19. end
  20. 6 def lock(path, timeout: nil, &blk)
  21. 36 headers = {}
  22. 36 headers["timeout"] = if timeout && timeout.positive?
  23. 12 "Second-#{timeout}"
  24. else
  25. 24 "Infinite, Second-4100000000"
  26. end
  27. 36 xml = "<?xml version=\"1.0\" encoding=\"utf-8\" ?>" \
  28. "<D:lockinfo xmlns:D=\"DAV:\">" \
  29. "<D:lockscope><D:exclusive/></D:lockscope>" \
  30. "<D:locktype><D:write/></D:locktype>" \
  31. "<D:owner>null</D:owner>" \
  32. "</D:lockinfo>"
  33. 36 response = request("LOCK", path, headers: headers, xml: xml)
  34. 36 return response unless response.is_a?(Response)
  35. 36 return response unless blk && response.status == 200
  36. 12 lock_token = response.headers["lock-token"]
  37. 1 begin
  38. 12 blk.call(response)
  39. ensure
  40. 12 unlock(path, lock_token)
  41. end
  42. 12 response
  43. end
  44. 6 def unlock(path, lock_token)
  45. 24 request("UNLOCK", path, headers: { "lock-token" => lock_token })
  46. end
  47. 6 def mkcol(dir)
  48. 12 request("MKCOL", dir)
  49. end
  50. 6 def propfind(path, xml = nil)
  51. 48 body = case xml
  52. when :acl
  53. 12 '<?xml version="1.0" encoding="utf-8" ?><D:propfind xmlns:D="DAV:"><D:prop><D:owner/>' \
  54. "<D:supported-privilege-set/><D:current-user-privilege-set/><D:acl/></D:prop></D:propfind>"
  55. when nil
  56. 24 '<?xml version="1.0" encoding="utf-8"?><DAV:propfind xmlns:DAV="DAV:"><DAV:allprop/></DAV:propfind>'
  57. else
  58. 12 xml
  59. end
  60. 48 request("PROPFIND", path, headers: { "depth" => "1" }, xml: body)
  61. end
  62. 6 def proppatch(path, xml)
  63. 2 body = "<?xml version=\"1.0\"?>" \
  64. 10 "<D:propertyupdate xmlns:D=\"DAV:\" xmlns:Z=\"http://ns.example.com/standards/z39.50/\">#{xml}</D:propertyupdate>"
  65. 12 request("PROPPATCH", path, xml: body)
  66. end
  67. # %i[ orderpatch acl report search]
  68. end
  69. end
  70. 6 register_plugin(:webdav, WebDav)
  71. end
  72. end

lib/httpx/plugins/xml.rb

100.0% lines covered

34 relevant lines. 34 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 6 module HTTPX
  3. 6 module Plugins
  4. #
  5. # This plugin supports request XML encoding/response decoding using the nokogiri gem.
  6. #
  7. # https://gitlab.com/os85/httpx/wikis/XML
  8. #
  9. 6 module XML
  10. 6 MIME_TYPES = %r{\b(application|text)/(.+\+)?xml\b}.freeze
  11. 6 module Transcoder
  12. 6 module_function
  13. 6 class Encoder
  14. 6 def initialize(xml)
  15. 120 @raw = xml
  16. end
  17. 6 def content_type
  18. 120 charset = @raw.respond_to?(:encoding) && @raw.encoding ? @raw.encoding.to_s.downcase : "utf-8"
  19. 120 "application/xml; charset=#{charset}"
  20. end
  21. 6 def bytesize
  22. 384 @raw.to_s.bytesize
  23. end
  24. 6 def to_s
  25. 120 @raw.to_s
  26. end
  27. end
  28. 6 def encode(xml)
  29. 120 Encoder.new(xml)
  30. end
  31. 6 def decode(response)
  32. 18 content_type = response.content_type.mime_type
  33. 18 raise HTTPX::Error, "invalid form mime type (#{content_type})" unless MIME_TYPES.match?(content_type)
  34. 18 Nokogiri::XML.method(:parse)
  35. end
  36. end
  37. 6 class << self
  38. 6 def load_dependencies(*)
  39. 108 require "nokogiri"
  40. end
  41. end
  42. 6 module ResponseMethods
  43. # decodes the response payload into a Nokogiri::XML::Node object **if** the payload is valid
  44. # "application/xml" (requires the "nokogiri" gem).
  45. 6 def xml
  46. 12 decode(Transcoder)
  47. end
  48. end
  49. 6 module RequestBodyClassMethods
  50. # ..., xml: Nokogiri::XML::Node #=> xml encoder
  51. 6 def initialize_body(params)
  52. 444 if (xml = params.delete(:xml))
  53. # @type var xml: Nokogiri::XML::Node | String
  54. 120 return Transcoder.encode(xml)
  55. end
  56. 324 super
  57. end
  58. end
  59. end
  60. 6 register_plugin(:xml, XML)
  61. end
  62. end

lib/httpx/pmatch_extensions.rb

100.0% lines covered

17 relevant lines. 17 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 23 module HTTPX
  3. 23 module ResponsePatternMatchExtensions
  4. 23 def deconstruct
  5. 25 [@status, @headers, @body]
  6. end
  7. 23 def deconstruct_keys(_keys)
  8. 50 { status: @status, headers: @headers, body: @body }
  9. end
  10. end
  11. 23 module ErrorResponsePatternMatchExtensions
  12. 23 def deconstruct
  13. 5 [@error]
  14. end
  15. 23 def deconstruct_keys(_keys)
  16. 25 { error: @error }
  17. end
  18. end
  19. 23 module HeadersPatternMatchExtensions
  20. 23 def deconstruct
  21. 5 to_a
  22. end
  23. end
  24. 23 Headers.include HeadersPatternMatchExtensions
  25. 23 Response.include ResponsePatternMatchExtensions
  26. 23 ErrorResponse.include ErrorResponsePatternMatchExtensions
  27. end

lib/httpx/pool.rb

100.0% lines covered

68 relevant lines. 68 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 23 require "httpx/selector"
  3. 23 require "httpx/connection"
  4. 23 require "httpx/resolver"
  5. 23 module HTTPX
  6. 23 class Pool
  7. 23 using ArrayExtensions::FilterMap
  8. 23 using URIExtensions
  9. 23 POOL_TIMEOUT = 5
  10. # Sets up the connection pool with the given +options+, which can be the following:
  11. #
  12. # :max_connections_per_origin :: the maximum number of connections held in the pool pointing to a given origin.
  13. # :pool_timeout :: the number of seconds to wait for a connection to a given origin (before raising HTTPX::PoolTimeoutError)
  14. #
  15. 23 def initialize(options)
  16. 7934 @max_connections_per_origin = options.fetch(:max_connections_per_origin, Float::INFINITY)
  17. 7934 @pool_timeout = options.fetch(:pool_timeout, POOL_TIMEOUT)
  18. 12838 @resolvers = Hash.new { |hs, resolver_type| hs[resolver_type] = [] }
  19. 7934 @resolver_mtx = Thread::Mutex.new
  20. 7934 @connections = []
  21. 7934 @connection_mtx = Thread::Mutex.new
  22. 7934 @origin_counters = Hash.new(0)
  23. 12353 @origin_conds = Hash.new { |hs, orig| hs[orig] = ConditionVariable.new }
  24. end
  25. 23 def pop_connection
  26. 8407 @connection_mtx.synchronize do
  27. 8407 conn = @connections.shift
  28. 8407 @origin_conds.delete(conn.origin) if conn && (@origin_counters[conn.origin.to_s] -= 1).zero?
  29. 8407 conn
  30. end
  31. end
  32. # opens a connection to the IP reachable through +uri+.
  33. # Many hostnames are reachable through the same IP, so we try to
  34. # maximize pipelining by opening as few connections as possible.
  35. #
  36. 23 def checkout_connection(uri, options)
  37. 5746 return checkout_new_connection(uri, options) if options.io
  38. 5696 @connection_mtx.synchronize do
  39. 5696 acquire_connection(uri, options) || begin
  40. 5243 if @origin_counters[uri.origin] == @max_connections_per_origin
  41. 12 @origin_conds[uri.origin].wait(@connection_mtx, @pool_timeout)
  42. 12 return acquire_connection(uri, options) || raise(PoolTimeoutError.new(uri.origin, @pool_timeout))
  43. end
  44. 5231 @origin_counters[uri.origin] += 1
  45. 5231 checkout_new_connection(uri, options)
  46. end
  47. end
  48. end
  49. 23 def checkin_connection(connection)
  50. 5789 return if connection.options.io
  51. 5739 @connection_mtx.synchronize do
  52. 5739 @connections << connection
  53. 5739 @origin_conds[connection.origin.to_s].signal
  54. end
  55. end
  56. 23 def checkout_mergeable_connection(connection)
  57. 5203 return if connection.options.io
  58. 5203 @connection_mtx.synchronize do
  59. 5203 idx = @connections.find_index do |ch|
  60. 177 ch != connection && ch.mergeable?(connection)
  61. end
  62. 5203 @connections.delete_at(idx) if idx
  63. end
  64. end
  65. 23 def reset_resolvers
  66. 10234 @resolver_mtx.synchronize { @resolvers.clear }
  67. end
  68. 23 def checkout_resolver(options)
  69. 5080 resolver_type = options.resolver_class
  70. 5080 resolver_type = Resolver.resolver_for(resolver_type)
  71. 5080 @resolver_mtx.synchronize do
  72. 5080 resolvers = @resolvers[resolver_type]
  73. 5080 idx = resolvers.find_index do |res|
  74. 30 res.options == options
  75. end
  76. 5080 resolvers.delete_at(idx) if idx
  77. end || checkout_new_resolver(resolver_type, options)
  78. end
  79. 23 def checkin_resolver(resolver)
  80. 271 @resolver_mtx.synchronize do
  81. 271 resolvers = @resolvers[resolver.class]
  82. 271 resolver = resolver.multi
  83. 271 resolvers << resolver unless resolvers.include?(resolver)
  84. end
  85. end
  86. 23 private
  87. 23 def acquire_connection(uri, options)
  88. 5708 idx = @connections.find_index do |connection|
  89. 625 connection.match?(uri, options)
  90. end
  91. 5708 @connections.delete_at(idx) if idx
  92. end
  93. 23 def checkout_new_connection(uri, options)
  94. 5281 options.connection_class.new(uri, options)
  95. end
  96. 23 def checkout_new_resolver(resolver_type, options)
  97. 5056 if resolver_type.multi?
  98. 5035 Resolver::Multi.new(resolver_type, options)
  99. else
  100. 21 resolver_type.new(options)
  101. end
  102. end
  103. end
  104. end

lib/httpx/punycode.rb

100.0% lines covered

9 relevant lines. 9 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 23 module HTTPX
  3. 23 module Punycode
  4. 23 module_function
  5. begin
  6. 23 require "idnx"
  7. 22 def encode_hostname(hostname)
  8. 24 Idnx.to_punycode(hostname)
  9. end
  10. rescue LoadError
  11. 1 def encode_hostname(hostname)
  12. 1 warn "#{hostname} cannot be converted to punycode. Install the " \
  13. "\"idnx\" gem: https://github.com/HoneyryderChuck/idnx"
  14. 1 hostname
  15. end
  16. end
  17. end
  18. end

lib/httpx/request.rb

100.0% lines covered

114 relevant lines. 114 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 23 require "delegate"
  3. 23 require "forwardable"
  4. 23 module HTTPX
  5. # Defines how an HTTP request is handled internally, both in terms of making attributes accessible,
  6. # as well as maintaining the state machine which manages streaming the request onto the wire.
  7. 23 class Request
  8. 23 extend Forwardable
  9. 23 include Callbacks
  10. 23 using URIExtensions
  11. # default value used for "user-agent" header, when not overridden.
  12. 23 USER_AGENT = "httpx.rb/#{VERSION}".freeze # rubocop:disable Style/RedundantFreeze
  13. # the upcased string HTTP verb for this request.
  14. 23 attr_reader :verb
  15. # the absolute URI object for this request.
  16. 23 attr_reader :uri
  17. # an HTTPX::Headers object containing the request HTTP headers.
  18. 23 attr_reader :headers
  19. # an HTTPX::Request::Body object containing the request body payload (or +nil+, whenn there is none).
  20. 23 attr_reader :body
  21. # a symbol describing which frame is currently being flushed.
  22. 23 attr_reader :state
  23. # an HTTPX::Options object containing request options.
  24. 23 attr_reader :options
  25. # the corresponding HTTPX::Response object, when there is one.
  26. 23 attr_reader :response
  27. # Exception raised during enumerable body writes.
  28. 23 attr_reader :drain_error
  29. # The IP address from the peer server.
  30. 23 attr_accessor :peer_address
  31. 23 attr_writer :persistent
  32. # will be +true+ when request body has been completely flushed.
  33. 23 def_delegator :@body, :empty?
  34. # initializes the instance with the given +verb+ (an upppercase String, ex. 'GEt'),
  35. # an absolute or relative +uri+ (either as String or URI::HTTP object), the
  36. # request +options+ (instance of HTTPX::Options) and an optional Hash of +params+.
  37. #
  38. # Besides any of the options documented in HTTPX::Options (which would override or merge with what
  39. # +options+ sets), it accepts also the following:
  40. #
  41. # :params :: hash or array of key-values which will be encoded and set in the query string of request uris.
  42. # :body :: to be encoded in the request body payload. can be a String, an IO object (i.e. a File), or an Enumerable.
  43. # :form :: hash of array of key-values which will be form-urlencoded- or multipart-encoded in requests body payload.
  44. # :json :: hash of array of key-values which will be JSON-encoded in requests body payload.
  45. # :xml :: Nokogiri XML nodes which will be encoded in requests body payload.
  46. #
  47. # :body, :form, :json and :xml are all mutually exclusive, i.e. only one of them gets picked up.
  48. 23 def initialize(verb, uri, options, params = EMPTY_HASH)
  49. 7306 @verb = verb.to_s.upcase
  50. 7306 @uri = Utils.to_uri(uri)
  51. 7305 @headers = options.headers.dup
  52. 7305 merge_headers(params.delete(:headers)) if params.key?(:headers)
  53. 7305 @headers["user-agent"] ||= USER_AGENT
  54. 7305 @headers["accept"] ||= "*/*"
  55. # forego compression in the Range request case
  56. 7305 if @headers.key?("range")
  57. 6 @headers.delete("accept-encoding")
  58. else
  59. 7299 @headers["accept-encoding"] ||= options.supported_compression_formats
  60. end
  61. 7305 @query_params = params.delete(:params) if params.key?(:params)
  62. 7305 @body = options.request_body_class.new(@headers, options, **params)
  63. 7299 @options = @body.options
  64. 7299 if @uri.relative? || @uri.host.nil?
  65. 452 origin = @options.origin
  66. 452 raise(Error, "invalid URI: #{@uri}") unless origin
  67. 432 base_path = @options.base_path
  68. 432 @uri = origin.merge("#{base_path}#{@uri}")
  69. end
  70. 7279 @state = :idle
  71. 7279 @response = nil
  72. 7279 @peer_address = nil
  73. 7279 @persistent = @options.persistent
  74. end
  75. # the read timeout defined for this requet.
  76. 23 def read_timeout
  77. 16786 @options.timeout[:read_timeout]
  78. end
  79. # the write timeout defined for this requet.
  80. 23 def write_timeout
  81. 16786 @options.timeout[:write_timeout]
  82. end
  83. # the request timeout defined for this requet.
  84. 23 def request_timeout
  85. 16572 @options.timeout[:request_timeout]
  86. end
  87. 23 def persistent?
  88. 3671 @persistent
  89. end
  90. # if the request contains trailer headers
  91. 23 def trailers?
  92. 2170 defined?(@trailers)
  93. end
  94. # returns an instance of HTTPX::Headers containing the trailer headers
  95. 23 def trailers
  96. 66 @trailers ||= @options.headers_class.new
  97. end
  98. # returns +:r+ or +:w+, depending on whether the request is waiting for a response or flushing.
  99. 23 def interests
  100. 19723 return :r if @state == :done || @state == :expect
  101. 2550 :w
  102. end
  103. # merges +h+ into the instance of HTTPX::Headers of the request.
  104. 23 def merge_headers(h)
  105. 615 @headers = @headers.merge(h)
  106. end
  107. # the URI scheme of the request +uri+.
  108. 23 def scheme
  109. 2587 @uri.scheme
  110. end
  111. # sets the +response+ on this request.
  112. 23 def response=(response)
  113. 6855 return unless response
  114. 6855 if response.is_a?(Response) && response.status < 200
  115. # deal with informational responses
  116. 120 if response.status == 100 && @headers.key?("expect")
  117. 102 @informational_status = response.status
  118. 102 return
  119. end
  120. # 103 Early Hints advertises resources in document to browsers.
  121. # not very relevant for an HTTP client, discard.
  122. 18 return if response.status >= 103
  123. end
  124. 6753 @response = response
  125. 6753 emit(:response_started, response)
  126. end
  127. # returnns the URI path of the request +uri+.
  128. 23 def path
  129. 6416 path = uri.path.dup
  130. 6416 path = +"" if path.nil?
  131. 6416 path << "/" if path.empty?
  132. 6416 path << "?#{query}" unless query.empty?
  133. 6416 path
  134. end
  135. # returs the URI authority of the request.
  136. #
  137. # session.build_request("GET", "https://google.com/query").authority #=> "google.com"
  138. # session.build_request("GET", "http://internal:3182/a").authority #=> "internal:3182"
  139. 23 def authority
  140. 6438 @uri.authority
  141. end
  142. # returs the URI origin of the request.
  143. #
  144. # session.build_request("GET", "https://google.com/query").authority #=> "https://google.com"
  145. # session.build_request("GET", "http://internal:3182/a").authority #=> "http://internal:3182"
  146. 23 def origin
  147. 2802 @uri.origin
  148. end
  149. # returs the URI query string of the request (when available).
  150. #
  151. # session.build_request("GET", "https://search.com").query #=> ""
  152. # session.build_request("GET", "https://search.com?q=a").query #=> "q=a"
  153. # session.build_request("GET", "https://search.com", params: { q: "a"}).query #=> "q=a"
  154. # session.build_request("GET", "https://search.com?q=a", params: { foo: "bar"}).query #=> "q=a&foo&bar"
  155. 23 def query
  156. 7124 return @query if defined?(@query)
  157. 5975 query = []
  158. 5975 if (q = @query_params)
  159. 116 query << Transcoder::Form.encode(q)
  160. end
  161. 5975 query << @uri.query if @uri.query
  162. 5975 @query = query.join("&")
  163. end
  164. # consumes and returns the next available chunk of request body that can be sent
  165. 23 def drain_body
  166. 6800 return nil if @body.nil?
  167. 6800 @drainer ||= @body.each
  168. 6800 chunk = @drainer.next.dup
  169. 4605 emit(:body_chunk, chunk)
  170. 4605 chunk
  171. rescue StopIteration
  172. 2185 nil
  173. rescue StandardError => e
  174. 10 @drain_error = e
  175. 10 nil
  176. end
  177. skipped # :nocov:
  178. skipped def inspect
  179. skipped "#<HTTPX::Request:#{object_id} " \
  180. skipped "#{@verb} " \
  181. skipped "#{uri} " \
  182. skipped "@headers=#{@headers} " \
  183. skipped "@body=#{@body}>"
  184. skipped end
  185. skipped # :nocov:
  186. # moves on to the +nextstate+ of the request state machine (when all preconditions are met)
  187. 23 def transition(nextstate)
  188. 29477 case nextstate
  189. when :idle
  190. 523 @body.rewind
  191. 523 @response = nil
  192. 523 @drainer = nil
  193. when :headers
  194. 7974 return unless @state == :idle
  195. when :body
  196. 7962 return unless @state == :headers ||
  197. @state == :expect
  198. 6517 if @headers.key?("expect")
  199. 372 if @informational_status && @informational_status == 100
  200. # check for 100 Continue response, and deallocate the var
  201. # if @informational_status == 100
  202. # @response = nil
  203. # end
  204. else
  205. 277 return if @state == :expect # do not re-set it
  206. 108 nextstate = :expect
  207. end
  208. end
  209. when :trailers
  210. 6506 return unless @state == :body
  211. when :done
  212. 6512 return if @state == :expect
  213. end
  214. 25580 @state = nextstate
  215. 25580 emit(@state, self)
  216. 3501 nil
  217. end
  218. # whether the request supports the 100-continue handshake and already processed the 100 response.
  219. 23 def expects?
  220. 6022 @headers["expect"] == "100-continue" && @informational_status == 100 && !@response
  221. end
  222. end
  223. end
  224. 23 require_relative "request/body"

lib/httpx/request/body.rb

100.0% lines covered

69 relevant lines. 69 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 23 module HTTPX
  3. # Implementation of the HTTP Request body as a delegator which iterates (responds to +each+) payload chunks.
  4. 23 class Request::Body < SimpleDelegator
  5. 23 class << self
  6. 23 def new(_, options, body: nil, **params)
  7. 7311 if body.is_a?(self)
  8. # request derives its options from body
  9. 12 body.options = options.merge(params)
  10. 12 return body
  11. end
  12. 7299 super
  13. end
  14. end
  15. 23 attr_accessor :options
  16. # inits the instance with the request +headers+, +options+ and +params+, which contain the payload definition.
  17. # it wraps the given body with the appropriate encoder on initialization.
  18. #
  19. # ..., json: { foo: "bar" }) #=> json encoder
  20. # ..., form: { foo: "bar" }) #=> form urlencoded encoder
  21. # ..., form: { foo: Pathname.open("path/to/file") }) #=> multipart urlencoded encoder
  22. # ..., form: { foo: File.open("path/to/file") }) #=> multipart urlencoded encoder
  23. # ..., form: { body: "bla") }) #=> raw data encoder
  24. 23 def initialize(h, options, **params)
  25. 7299 @headers = h
  26. 7299 @body = self.class.initialize_body(params)
  27. 7299 @options = options.merge(params)
  28. 7299 if @body
  29. 2284 if @options.compress_request_body && @headers.key?("content-encoding")
  30. 74 @headers.get("content-encoding").each do |encoding|
  31. 74 @body = self.class.initialize_deflater_body(@body, encoding)
  32. end
  33. end
  34. 2284 @headers["content-type"] ||= @body.content_type
  35. 2284 @headers["content-length"] = @body.bytesize unless unbounded_body?
  36. end
  37. 7293 super(@body)
  38. end
  39. # consumes and yields the request payload in chunks.
  40. 23 def each(&block)
  41. 4588 return enum_for(__method__) unless block
  42. 2297 return if @body.nil?
  43. 2243 body = stream(@body)
  44. 2243 if body.respond_to?(:read)
  45. 939 ::IO.copy_stream(body, ProcIO.new(block))
  46. 1303 elsif body.respond_to?(:each)
  47. 309 body.each(&block)
  48. else
  49. 995 block[body.to_s]
  50. end
  51. end
  52. # if the +@body+ is rewindable, it rewinnds it.
  53. 23 def rewind
  54. 571 return if empty?
  55. 120 @body.rewind if @body.respond_to?(:rewind)
  56. end
  57. # return +true+ if the +body+ has been fully drained (or does nnot exist).
  58. 23 def empty?
  59. 13974 return true if @body.nil?
  60. 6123 return false if chunked?
  61. 6051 @body.bytesize.zero?
  62. end
  63. # returns the +@body+ payload size in bytes.
  64. 23 def bytesize
  65. 2626 return 0 if @body.nil?
  66. 96 @body.bytesize
  67. end
  68. # sets the body to yield using chunked trannsfer encoding format.
  69. 23 def stream(body)
  70. 2243 return body unless chunked?
  71. 72 Transcoder::Chunker.encode(body.enum_for(:each))
  72. end
  73. # returns whether the body yields infinitely.
  74. 23 def unbounded_body?
  75. 2818 return @unbounded_body if defined?(@unbounded_body)
  76. 2338 @unbounded_body = !@body.nil? && (chunked? || @body.bytesize == Float::INFINITY)
  77. end
  78. # returns whether the chunked transfer encoding header is set.
  79. 23 def chunked?
  80. 14473 @headers["transfer-encoding"] == "chunked"
  81. end
  82. # sets the chunked transfer encoding header.
  83. 23 def chunk!
  84. 24 @headers.add("transfer-encoding", "chunked")
  85. end
  86. skipped # :nocov:
  87. skipped def inspect
  88. skipped "#<HTTPX::Request::Body:#{object_id} " \
  89. skipped "#{unbounded_body? ? "stream" : "@bytesize=#{bytesize}"}>"
  90. skipped end
  91. skipped # :nocov:
  92. 23 class << self
  93. 23 def initialize_body(params)
  94. 7179 if (body = params.delete(:body))
  95. # @type var body: bodyIO
  96. 968 Transcoder::Body.encode(body)
  97. 6211 elsif (form = params.delete(:form))
  98. # @type var form: Transcoder::urlencoded_input
  99. 1133 Transcoder::Form.encode(form)
  100. 5078 elsif (json = params.delete(:json))
  101. # @type var body: _ToJson
  102. 63 Transcoder::JSON.encode(json)
  103. end
  104. end
  105. # returns the +body+ wrapped with the correct deflater accordinng to the given +encodisng+.
  106. 23 def initialize_deflater_body(body, encoding)
  107. 74 case encoding
  108. when "gzip"
  109. 38 Transcoder::GZIP.encode(body)
  110. when "deflate"
  111. 18 Transcoder::Deflate.encode(body)
  112. when "identity"
  113. 12 body
  114. else
  115. 6 body
  116. end
  117. end
  118. end
  119. end
  120. # Wrapper yielder which can be used with functions which expect an IO writer.
  121. 23 class ProcIO
  122. 23 def initialize(block)
  123. 939 @block = block
  124. end
  125. # Implementation the IO write protocol, which yield the given chunk to +@block+.
  126. 23 def write(data)
  127. 2623 @block.call(data.dup)
  128. 2617 data.bytesize
  129. end
  130. end
  131. end

lib/httpx/resolver.rb

100.0% lines covered

82 relevant lines. 82 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 23 require "resolv"
  3. 23 require "ipaddr"
  4. 23 module HTTPX
  5. 23 module Resolver
  6. 23 RESOLVE_TIMEOUT = [2, 3].freeze
  7. 23 require "httpx/resolver/resolver"
  8. 23 require "httpx/resolver/system"
  9. 23 require "httpx/resolver/native"
  10. 23 require "httpx/resolver/https"
  11. 23 require "httpx/resolver/multi"
  12. 23 @lookup_mutex = Thread::Mutex.new
  13. 172 @lookups = Hash.new { |h, k| h[k] = [] }
  14. 23 @identifier_mutex = Thread::Mutex.new
  15. 23 @identifier = 1
  16. 23 @system_resolver = Resolv::Hosts.new
  17. 23 module_function
  18. 23 def resolver_for(resolver_type)
  19. 5115 case resolver_type
  20. 4965 when :native then Native
  21. 27 when :system then System
  22. 61 when :https then HTTPS
  23. else
  24. 62 return resolver_type if resolver_type.is_a?(Class) && resolver_type < Resolver
  25. 6 raise Error, "unsupported resolver type (#{resolver_type})"
  26. end
  27. end
  28. 23 def nolookup_resolve(hostname)
  29. 4957 ip_resolve(hostname) || cached_lookup(hostname) || system_resolve(hostname)
  30. end
  31. 23 def ip_resolve(hostname)
  32. 4957 [IPAddr.new(hostname)]
  33. rescue ArgumentError
  34. end
  35. 23 def system_resolve(hostname)
  36. 426 ips = @system_resolver.getaddresses(hostname)
  37. 426 return if ips.empty?
  38. 534 ips.map { |ip| IPAddr.new(ip) }
  39. rescue IOError
  40. end
  41. 23 def cached_lookup(hostname)
  42. 4558 now = Utils.now
  43. 4558 lookup_synchronize do |lookups|
  44. 4558 lookup(hostname, lookups, now)
  45. end
  46. end
  47. 23 def cached_lookup_set(hostname, family, entries)
  48. 180 now = Utils.now
  49. 180 entries.each do |entry|
  50. 229 entry["TTL"] += now
  51. end
  52. 180 lookup_synchronize do |lookups|
  53. 180 case family
  54. when Socket::AF_INET6
  55. 30 lookups[hostname].concat(entries)
  56. when Socket::AF_INET
  57. 150 lookups[hostname].unshift(*entries)
  58. end
  59. 180 entries.each do |entry|
  60. 229 next unless entry["name"] != hostname
  61. 24 case family
  62. when Socket::AF_INET6
  63. 6 lookups[entry["name"]] << entry
  64. when Socket::AF_INET
  65. 18 lookups[entry["name"]].unshift(entry)
  66. end
  67. end
  68. end
  69. end
  70. # do not use directly!
  71. 23 def lookup(hostname, lookups, ttl)
  72. 4564 return unless lookups.key?(hostname)
  73. 4137 entries = lookups[hostname] = lookups[hostname].select do |address|
  74. 10270 address["TTL"] > ttl
  75. end
  76. 4137 ips = entries.flat_map do |address|
  77. 10255 if address.key?("alias")
  78. 6 lookup(address["alias"], lookups, ttl)
  79. else
  80. 10249 IPAddr.new(address["data"])
  81. end
  82. end.compact
  83. 4137 ips unless ips.empty?
  84. end
  85. 23 def generate_id
  86. 1226 id_synchronize { @identifier = (@identifier + 1) & 0xFFFF }
  87. end
  88. 23 def encode_dns_query(hostname, type: Resolv::DNS::Resource::IN::A, message_id: generate_id)
  89. 559 Resolv::DNS::Message.new(message_id).tap do |query|
  90. 613 query.rd = 1
  91. 613 query.add_question(hostname, type)
  92. 107 end.encode
  93. end
  94. 23 def decode_dns_answer(payload)
  95. 53 begin
  96. 555 message = Resolv::DNS::Message.decode(payload)
  97. rescue Resolv::DNS::DecodeError => e
  98. 5 return :decode_error, e
  99. end
  100. # no domain was found
  101. 550 return :no_domain_found if message.rcode == Resolv::DNS::RCode::NXDomain
  102. 224 return :message_truncated if message.tc == 1
  103. 214 return :dns_error, message.rcode if message.rcode != Resolv::DNS::RCode::NoError
  104. 204 addresses = []
  105. 204 message.each_answer do |question, _, value|
  106. 874 case value
  107. when Resolv::DNS::Resource::IN::CNAME
  108. 18 addresses << {
  109. "name" => question.to_s,
  110. "TTL" => value.ttl,
  111. "alias" => value.name.to_s,
  112. }
  113. when Resolv::DNS::Resource::IN::A,
  114. Resolv::DNS::Resource::IN::AAAA
  115. 856 addresses << {
  116. 23 "name" => question.to_s,
  117. "TTL" => value.ttl,
  118. "data" => value.address.to_s,
  119. }
  120. end
  121. end
  122. 204 [:ok, addresses]
  123. end
  124. 23 def lookup_synchronize
  125. 9476 @lookup_mutex.synchronize { yield(@lookups) }
  126. end
  127. 23 def id_synchronize(&block)
  128. 613 @identifier_mutex.synchronize(&block)
  129. end
  130. end
  131. end

lib/httpx/resolver/https.rb

86.71% lines covered

143 relevant lines. 124 lines covered and 19 lines missed.
    
  1. # frozen_string_literal: true
  2. 23 require "resolv"
  3. 23 require "uri"
  4. 23 require "cgi"
  5. 23 require "forwardable"
  6. 23 require "httpx/base64"
  7. 23 module HTTPX
  8. 23 class Resolver::HTTPS < Resolver::Resolver
  9. 23 extend Forwardable
  10. 23 using URIExtensions
  11. 23 module DNSExtensions
  12. 23 refine Resolv::DNS do
  13. 23 def generate_candidates(name)
  14. 35 @config.generate_candidates(name)
  15. end
  16. end
  17. end
  18. 23 using DNSExtensions
  19. 23 NAMESERVER = "https://1.1.1.1/dns-query"
  20. 1 DEFAULTS = {
  21. 22 uri: NAMESERVER,
  22. use_get: false,
  23. }.freeze
  24. 23 def_delegators :@resolver_connection, :state, :connecting?, :to_io, :call, :close, :terminate, :inflight?
  25. 23 def initialize(_, options)
  26. 75 super
  27. 75 @resolver_options = DEFAULTS.merge(@options.resolver_options)
  28. 75 @queries = {}
  29. 75 @requests = {}
  30. 75 @connections = []
  31. 75 @uri = URI(@resolver_options[:uri])
  32. 75 @uri_addresses = nil
  33. 75 @resolver = Resolv::DNS.new
  34. 75 @resolver.timeouts = @resolver_options.fetch(:timeouts, Resolver::RESOLVE_TIMEOUT)
  35. 75 @resolver.lazy_initialize
  36. end
  37. 23 def <<(connection)
  38. 75 return if @uri.origin == connection.peer.to_s
  39. 40 @uri_addresses ||= HTTPX::Resolver.nolookup_resolve(@uri.host) || @resolver.getaddresses(@uri.host)
  40. 40 if @uri_addresses.empty?
  41. 5 ex = ResolveError.new("Can't resolve DNS server #{@uri.host}")
  42. 5 ex.set_backtrace(caller)
  43. 5 connection.force_reset
  44. 5 throw(:resolve_error, ex)
  45. end
  46. 35 resolve(connection)
  47. end
  48. 23 def closed?
  49. true
  50. end
  51. 23 def empty?
  52. 70 true
  53. end
  54. 23 def resolver_connection
  55. # TODO: leaks connection object into the pool
  56. 55 @resolver_connection ||= @current_session.find_connection(@uri, @current_selector,
  57. @options.merge(ssl: { alpn_protocols: %w[h2] })).tap do |conn|
  58. 35 emit_addresses(conn, @family, @uri_addresses) unless conn.addresses
  59. end
  60. end
  61. 23 private
  62. 23 def resolve(connection = @connections.first, hostname = nil)
  63. 55 return unless connection
  64. 55 hostname ||= @queries.key(connection)
  65. 55 if hostname.nil?
  66. 35 hostname = connection.peer.host
  67. 35 log { "resolver: resolve IDN #{connection.peer.non_ascii_hostname} as #{hostname}" } if connection.peer.non_ascii_hostname
  68. 35 hostname = @resolver.generate_candidates(hostname).each do |name|
  69. 105 @queries[name.to_s] = connection
  70. end.first.to_s
  71. else
  72. 20 @queries[hostname] = connection
  73. end
  74. 55 log { "resolver: query #{FAMILY_TYPES[RECORD_TYPES[@family]]} for #{hostname}" }
  75. begin
  76. 55 request = build_request(hostname)
  77. 55 request.on(:response, &method(:on_response).curry(2)[request])
  78. 55 request.on(:promise, &method(:on_promise))
  79. 55 @requests[request] = hostname
  80. 55 resolver_connection.send(request)
  81. 55 @connections << connection
  82. rescue ResolveError, Resolv::DNS::EncodeError => e
  83. reset_hostname(hostname)
  84. emit_resolve_error(connection, connection.peer.host, e)
  85. end
  86. end
  87. 23 def on_response(request, response)
  88. 55 response.raise_for_status
  89. rescue StandardError => e
  90. 5 hostname = @requests.delete(request)
  91. 5 connection = reset_hostname(hostname)
  92. 5 emit_resolve_error(connection, connection.peer.host, e)
  93. else
  94. # @type var response: HTTPX::Response
  95. 50 parse(request, response)
  96. ensure
  97. 55 @requests.delete(request)
  98. end
  99. 23 def on_promise(_, stream)
  100. log(level: 2) { "#{stream.id}: refusing stream!" }
  101. stream.refuse
  102. end
  103. 23 def parse(request, response)
  104. 50 code, result = decode_response_body(response)
  105. 50 case code
  106. when :ok
  107. 15 parse_addresses(result, request)
  108. when :no_domain_found
  109. # Indicates no such domain was found.
  110. 30 host = @requests.delete(request)
  111. 30 connection = reset_hostname(host, reset_candidates: false)
  112. 30 unless @queries.value?(connection)
  113. 10 emit_resolve_error(connection)
  114. 10 return
  115. end
  116. 20 resolve
  117. when :dns_error
  118. host = @requests.delete(request)
  119. connection = reset_hostname(host)
  120. emit_resolve_error(connection)
  121. when :decode_error
  122. 5 host = @requests.delete(request)
  123. 5 connection = reset_hostname(host)
  124. 5 emit_resolve_error(connection, connection.peer.host, result)
  125. end
  126. end
  127. 23 def parse_addresses(answers, request)
  128. 15 if answers.empty?
  129. # no address found, eliminate candidates
  130. host = @requests.delete(request)
  131. connection = reset_hostname(host)
  132. emit_resolve_error(connection)
  133. return
  134. else
  135. 35 answers = answers.group_by { |answer| answer["name"] }
  136. 15 answers.each do |hostname, addresses|
  137. 20 addresses = addresses.flat_map do |address|
  138. 20 if address.key?("alias")
  139. 5 alias_address = answers[address["alias"]]
  140. 5 if alias_address.nil?
  141. reset_hostname(address["name"])
  142. if early_resolve(connection, hostname: address["alias"])
  143. @connections.delete(connection)
  144. else
  145. resolve(connection, address["alias"])
  146. return # rubocop:disable Lint/NonLocalExitFromIterator
  147. end
  148. else
  149. 5 alias_address
  150. end
  151. else
  152. 15 address
  153. end
  154. end.compact
  155. 20 next if addresses.empty?
  156. 20 hostname.delete_suffix!(".") if hostname.end_with?(".")
  157. 20 connection = reset_hostname(hostname, reset_candidates: false)
  158. 20 next unless connection # probably a retried query for which there's an answer
  159. 15 @connections.delete(connection)
  160. # eliminate other candidates
  161. 45 @queries.delete_if { |_, conn| connection == conn }
  162. 15 Resolver.cached_lookup_set(hostname, @family, addresses) if @resolver_options[:cache]
  163. 45 catch(:coalesced) { emit_addresses(connection, @family, addresses.map { |addr| addr["data"] }) }
  164. end
  165. end
  166. 15 return if @connections.empty?
  167. resolve
  168. end
  169. 23 def build_request(hostname)
  170. 50 uri = @uri.dup
  171. 50 rklass = @options.request_class
  172. 50 payload = Resolver.encode_dns_query(hostname, type: @record_type)
  173. 50 if @resolver_options[:use_get]
  174. 5 params = URI.decode_www_form(uri.query.to_s)
  175. 5 params << ["type", FAMILY_TYPES[@record_type]]
  176. 5 params << ["dns", Base64.urlsafe_encode64(payload, padding: false)]
  177. 5 uri.query = URI.encode_www_form(params)
  178. 5 request = rklass.new("GET", uri, @options)
  179. else
  180. 45 request = rklass.new("POST", uri, @options, body: [payload])
  181. 45 request.headers["content-type"] = "application/dns-message"
  182. end
  183. 50 request.headers["accept"] = "application/dns-message"
  184. 50 request
  185. end
  186. 23 def decode_response_body(response)
  187. 45 case response.headers["content-type"]
  188. when "application/dns-udpwireformat",
  189. "application/dns-message"
  190. 45 Resolver.decode_dns_answer(response.to_s)
  191. else
  192. raise Error, "unsupported DNS mime-type (#{response.headers["content-type"]})"
  193. end
  194. end
  195. 23 def reset_hostname(hostname, reset_candidates: true)
  196. 60 connection = @queries.delete(hostname)
  197. 60 return connection unless connection && reset_candidates
  198. # eliminate other candidates
  199. 30 candidates = @queries.select { |_, conn| connection == conn }.keys
  200. 30 @queries.delete_if { |h, _| candidates.include?(h) }
  201. 10 connection
  202. end
  203. end
  204. end

lib/httpx/resolver/multi.rb

88.24% lines covered

51 relevant lines. 45 lines covered and 6 lines missed.
    
  1. # frozen_string_literal: true
  2. 23 require "forwardable"
  3. 23 require "resolv"
  4. 23 module HTTPX
  5. 23 class Resolver::Multi
  6. 23 include Callbacks
  7. 23 using ArrayExtensions::FilterMap
  8. 23 attr_reader :resolvers, :options
  9. 23 def initialize(resolver_type, options)
  10. 5035 @current_selector = nil
  11. 5035 @current_session = nil
  12. 5035 @options = options
  13. 5035 @resolver_options = @options.resolver_options
  14. 5035 @resolvers = options.ip_families.map do |ip_family|
  15. 5035 resolver = resolver_type.new(ip_family, options)
  16. 5035 resolver.multi = self
  17. 5035 resolver
  18. end
  19. 5035 @errors = Hash.new { |hs, k| hs[k] = [] }
  20. end
  21. 23 def current_selector=(s)
  22. 5059 @current_selector = s
  23. 10118 @resolvers.each { |r| r.__send__(__method__, s) }
  24. end
  25. 23 def current_session=(s)
  26. 5059 @current_session = s
  27. 10118 @resolvers.each { |r| r.__send__(__method__, s) }
  28. end
  29. 23 def closed?
  30. @resolvers.all?(&:closed?)
  31. end
  32. 23 def empty?
  33. @resolvers.all?(&:empty?)
  34. end
  35. 23 def inflight?
  36. @resolvers.any(&:inflight?)
  37. end
  38. 23 def timeout
  39. @resolvers.filter_map(&:timeout).min
  40. end
  41. 23 def close
  42. @resolvers.each(&:close)
  43. end
  44. 23 def connections
  45. @resolvers.filter_map { |r| r.resolver_connection if r.respond_to?(:resolver_connection) }
  46. end
  47. 23 def early_resolve(connection)
  48. 5061 hostname = connection.peer.host
  49. 5061 addresses = @resolver_options[:cache] && (connection.addresses || HTTPX::Resolver.nolookup_resolve(hostname))
  50. 5061 return false unless addresses
  51. 4708 resolved = false
  52. 4886 addresses.group_by(&:family).sort { |(f1, _), (f2, _)| f2 <=> f1 }.each do |family, addrs|
  53. # try to match the resolver by family. However, there are cases where that's not possible, as when
  54. # the system does not have IPv6 connectivity, but it does support IPv6 via loopback/link-local.
  55. 9748 resolver = @resolvers.find { |r| r.family == family } || @resolvers.first
  56. 4874 next unless resolver # this should ever happen
  57. # it does not matter which resolver it is, as early-resolve code is shared.
  58. 4874 resolver.emit_addresses(connection, family, addrs, true)
  59. 4845 resolved = true
  60. end
  61. 4679 resolved
  62. end
  63. 23 def lazy_resolve(connection)
  64. 353 @resolvers.each do |resolver|
  65. 353 resolver << @current_session.try_clone_connection(connection, @current_selector, resolver.family)
  66. 343 next if resolver.empty?
  67. 273 @current_session.select_resolver(resolver, @current_selector)
  68. end
  69. end
  70. end
  71. end

lib/httpx/resolver/native.rb

93.33% lines covered

285 relevant lines. 266 lines covered and 19 lines missed.
    
  1. # frozen_string_literal: true
  2. 23 require "forwardable"
  3. 23 require "resolv"
  4. 23 module HTTPX
  5. 23 class Resolver::Native < Resolver::Resolver
  6. 23 extend Forwardable
  7. 23 using URIExtensions
  8. 1 DEFAULTS = {
  9. 22 nameserver: nil,
  10. **Resolv::DNS::Config.default_config_hash,
  11. packet_size: 512,
  12. timeouts: Resolver::RESOLVE_TIMEOUT,
  13. }.freeze
  14. 23 DNS_PORT = 53
  15. 23 def_delegator :@connections, :empty?
  16. 23 attr_reader :state
  17. 23 def initialize(family, options)
  18. 4960 super
  19. 4960 @ns_index = 0
  20. 4960 @resolver_options = DEFAULTS.merge(@options.resolver_options)
  21. 4960 @socket_type = @resolver_options.fetch(:socket_type, :udp)
  22. 4960 @nameserver = if (nameserver = @resolver_options[:nameserver])
  23. 4955 nameserver = nameserver[family] if nameserver.is_a?(Hash)
  24. 4955 Array(nameserver)
  25. end
  26. 4960 @ndots = @resolver_options.fetch(:ndots, 1)
  27. 14880 @search = Array(@resolver_options[:search]).map { |srch| srch.scan(/[^.]+/) }
  28. 4960 @_timeouts = Array(@resolver_options[:timeouts])
  29. 6256 @timeouts = Hash.new { |timeouts, host| timeouts[host] = @_timeouts.dup }
  30. 4960 @connections = []
  31. 4960 @queries = {}
  32. 4960 @read_buffer = "".b
  33. 4960 @write_buffer = Buffer.new(@resolver_options[:packet_size])
  34. 4960 @state = :idle
  35. end
  36. 23 def close
  37. 271 transition(:closed)
  38. end
  39. 23 def closed?
  40. 542 @state == :closed
  41. end
  42. 23 def to_io
  43. 970 @io.to_io
  44. end
  45. 23 def call
  46. 849 case @state
  47. when :open
  48. 844 consume
  49. end
  50. 72 nil
  51. rescue Errno::EHOSTUNREACH => e
  52. 15 @ns_index += 1
  53. 15 nameserver = @nameserver
  54. 15 if nameserver && @ns_index < nameserver.size
  55. 10 log { "resolver: failed resolving on nameserver #{@nameserver[@ns_index - 1]} (#{e.message})" }
  56. 10 transition(:idle)
  57. 10 @timeouts.clear
  58. else
  59. 5 handle_error(e)
  60. end
  61. rescue NativeResolveError => e
  62. 20 handle_error(e)
  63. end
  64. 23 def interests
  65. 3816 case @state
  66. when :idle
  67. 3168 transition(:open)
  68. when :closed
  69. 13 transition(:idle)
  70. 13 transition(:open)
  71. end
  72. 3816 calculate_interests
  73. end
  74. 23 def <<(connection)
  75. 278 if @nameserver.nil?
  76. 5 ex = ResolveError.new("No available nameserver")
  77. 5 ex.set_backtrace(caller)
  78. 5 connection.force_reset
  79. 5 throw(:resolve_error, ex)
  80. else
  81. 273 @connections << connection
  82. 273 resolve
  83. end
  84. end
  85. 23 def timeout
  86. 3816 return if @connections.empty?
  87. 3816 @start_timeout = Utils.now
  88. 3816 hosts = @queries.keys
  89. 3816 @timeouts.values_at(*hosts).reject(&:empty?).map(&:first).min
  90. end
  91. 23 def handle_socket_timeout(interval)
  92. 83 do_retry(interval)
  93. end
  94. 23 private
  95. 23 def calculate_interests
  96. 5380 return :w unless @write_buffer.empty?
  97. 4164 return :r unless @queries.empty?
  98. 18 nil
  99. end
  100. 23 def consume
  101. 829 dread if calculate_interests == :r
  102. 735 do_retry
  103. 735 dwrite if calculate_interests == :w
  104. end
  105. 23 def do_retry(loop_time = nil)
  106. 818 return if @queries.empty? || !@start_timeout
  107. 641 loop_time ||= Utils.elapsed_time(@start_timeout)
  108. 641 query = @queries.first
  109. 641 return unless query
  110. 641 h, connection = query
  111. 641 host = connection.peer.host
  112. 641 timeout = (@timeouts[host][0] -= loop_time)
  113. 641 return unless timeout <= 0
  114. 63 @timeouts[host].shift
  115. 63 if !@timeouts[host].empty?
  116. 38 log { "resolver: timeout after #{timeout}s, retry(#{@timeouts[host].first}) #{host}..." }
  117. # must downgrade to tcp AND retry on same host as last
  118. 38 downgrade_socket
  119. 38 resolve(connection, h)
  120. 25 elsif @ns_index + 1 < @nameserver.size
  121. # try on the next nameserver
  122. 5 @ns_index += 1
  123. 5 log { "resolver: failed resolving #{host} on nameserver #{@nameserver[@ns_index - 1]} (timeout error)" }
  124. 5 transition(:idle)
  125. 5 @timeouts.clear
  126. 5 resolve(connection, h)
  127. else
  128. 20 @timeouts.delete(host)
  129. 20 reset_hostname(h, reset_candidates: false)
  130. 20 return unless @queries.empty?
  131. 5 @connections.delete(connection)
  132. # This loop_time passed to the exception is bogus. Ideally we would pass the total
  133. # resolve timeout, including from the previous retries.
  134. 5 ex = ResolveTimeoutError.new(loop_time, "Timed out while resolving #{connection.peer.host}")
  135. 5 ex.set_backtrace(ex ? ex.backtrace : caller)
  136. 5 emit_resolve_error(connection, host, ex)
  137. 5 emit(:close, self)
  138. end
  139. end
  140. 23 def dread(wsize = @resolver_options[:packet_size])
  141. 510 loop do
  142. 754 wsize = @large_packet.capacity if @large_packet
  143. 754 siz = @io.read(wsize, @read_buffer)
  144. 754 unless siz
  145. ex = EOFError.new("descriptor closed")
  146. ex.set_backtrace(caller)
  147. raise ex
  148. end
  149. 754 return unless siz.positive?
  150. 520 if @socket_type == :tcp
  151. # packet may be incomplete, need to keep draining from the socket
  152. 25 if @large_packet
  153. # large packet buffer already exists, continue pumping
  154. 10 @large_packet << @read_buffer
  155. 10 next unless @large_packet.full?
  156. 10 parse(@large_packet.to_s)
  157. 10 @large_packet = nil
  158. # downgrade to udp again
  159. 10 downgrade_socket
  160. 10 return
  161. else
  162. 15 size = @read_buffer[0, 2].unpack1("n")
  163. 15 buffer = @read_buffer.byteslice(2..-1)
  164. 15 if size > @read_buffer.bytesize
  165. # only do buffer logic if it's worth it, and the whole packet isn't here already
  166. 10 @large_packet = Buffer.new(size)
  167. 10 @large_packet << buffer
  168. 10 next
  169. else
  170. 5 parse(buffer)
  171. end
  172. end
  173. else # udp
  174. 495 parse(@read_buffer)
  175. end
  176. 406 return if @state == :closed
  177. end
  178. end
  179. 23 def dwrite
  180. 558 loop do
  181. 1116 return if @write_buffer.empty?
  182. 558 siz = @io.write(@write_buffer)
  183. 558 unless siz
  184. ex = EOFError.new("descriptor closed")
  185. ex.set_backtrace(caller)
  186. raise ex
  187. end
  188. 558 return unless siz.positive?
  189. 558 return if @state == :closed
  190. end
  191. end
  192. 23 def parse(buffer)
  193. 510 code, result = Resolver.decode_dns_answer(buffer)
  194. 510 case code
  195. when :ok
  196. 189 parse_addresses(result)
  197. when :no_domain_found
  198. # Indicates no such domain was found.
  199. 296 hostname, connection = @queries.first
  200. 296 reset_hostname(hostname, reset_candidates: false)
  201. 296 unless @queries.value?(connection)
  202. 74 @connections.delete(connection)
  203. 74 ex = NativeResolveError.new(connection, connection.peer.host, "name or service not known")
  204. 74 ex.set_backtrace(ex ? ex.backtrace : caller)
  205. 74 emit_resolve_error(connection, connection.peer.host, ex)
  206. 74 emit(:close, self)
  207. end
  208. 296 resolve
  209. when :message_truncated
  210. # TODO: what to do if it's already tcp??
  211. 10 return if @socket_type == :tcp
  212. 10 @socket_type = :tcp
  213. 10 hostname, _ = @queries.first
  214. 10 reset_hostname(hostname)
  215. 10 transition(:closed)
  216. when :dns_error
  217. 10 hostname, connection = @queries.first
  218. 10 reset_hostname(hostname)
  219. 10 @connections.delete(connection)
  220. 10 ex = NativeResolveError.new(connection, connection.peer.host, "unknown DNS error (error code #{result})")
  221. 10 raise ex
  222. when :decode_error
  223. 5 hostname, connection = @queries.first
  224. 5 reset_hostname(hostname)
  225. 5 @connections.delete(connection)
  226. 5 ex = NativeResolveError.new(connection, connection.peer.host, result.message)
  227. 5 ex.set_backtrace(result.backtrace)
  228. 5 raise ex
  229. end
  230. end
  231. 23 def parse_addresses(addresses)
  232. 189 if addresses.empty?
  233. # no address found, eliminate candidates
  234. 5 hostname, connection = @queries.first
  235. 5 reset_hostname(hostname)
  236. 5 @connections.delete(connection)
  237. 5 raise NativeResolveError.new(connection, connection.peer.host)
  238. else
  239. 184 address = addresses.first
  240. 184 name = address["name"]
  241. 184 connection = @queries.delete(name)
  242. 184 unless connection
  243. orig_name = name
  244. # absolute name
  245. name_labels = Resolv::DNS::Name.create(name).to_a
  246. name = @queries.each_key.first { |hname| name_labels == Resolv::DNS::Name.create(hname).to_a }
  247. # probably a retried query for which there's an answer
  248. unless name
  249. @timeouts.delete(orig_name)
  250. return
  251. end
  252. address["name"] = name
  253. connection = @queries.delete(name)
  254. end
  255. 184 if address.key?("alias") # CNAME
  256. 16 hostname_alias = address["alias"]
  257. # clean up intermediate queries
  258. 16 @timeouts.delete(name) unless connection.peer.host == name
  259. 16 if early_resolve(connection, hostname: hostname_alias)
  260. 1 @connections.delete(connection)
  261. else
  262. 15 if @socket_type == :tcp
  263. # must downgrade to udp if tcp
  264. 5 @socket_type = @resolver_options.fetch(:socket_type, :udp)
  265. 5 transition(:idle)
  266. 5 transition(:open)
  267. end
  268. 15 log { "resolver: ALIAS #{hostname_alias} for #{name}" }
  269. 15 resolve(connection, hostname_alias)
  270. 15 return
  271. end
  272. else
  273. 168 reset_hostname(name, connection: connection)
  274. 168 @timeouts.delete(connection.peer.host)
  275. 168 @connections.delete(connection)
  276. 168 Resolver.cached_lookup_set(connection.peer.host, @family, addresses) if @resolver_options[:cache]
  277. 848 catch(:coalesced) { emit_addresses(connection, @family, addresses.map { |addr| addr["data"] }) }
  278. end
  279. end
  280. 169 return emit(:close, self) if @connections.empty?
  281. 2 resolve
  282. end
  283. 23 def resolve(connection = @connections.first, hostname = nil)
  284. 639 raise Error, "no URI to resolve" unless connection
  285. 565 return unless @write_buffer.empty?
  286. 563 hostname ||= @queries.key(connection)
  287. 563 if hostname.nil?
  288. 283 hostname = connection.peer.host
  289. 283 log { "resolver: resolve IDN #{connection.peer.non_ascii_hostname} as #{hostname}" } if connection.peer.non_ascii_hostname
  290. 283 hostname = generate_candidates(hostname).each do |name|
  291. 1132 @queries[name] = connection
  292. end.first
  293. else
  294. 280 @queries[hostname] = connection
  295. end
  296. 563 log { "resolver: query #{@record_type.name.split("::").last} for #{hostname}" }
  297. 53 begin
  298. 563 @write_buffer << encode_dns_query(hostname)
  299. rescue Resolv::DNS::EncodeError => e
  300. reset_hostname(hostname, connection: connection)
  301. @connections.delete(connection)
  302. emit_resolve_error(connection, hostname, e)
  303. emit(:close, self) if @connections.empty?
  304. end
  305. end
  306. 23 def encode_dns_query(hostname)
  307. 563 message_id = Resolver.generate_id
  308. 563 msg = Resolver.encode_dns_query(hostname, type: @record_type, message_id: message_id)
  309. 563 msg[0, 2] = [msg.size, message_id].pack("nn") if @socket_type == :tcp
  310. 563 msg
  311. end
  312. 23 def generate_candidates(name)
  313. 283 return [name] if name.end_with?(".")
  314. 283 candidates = []
  315. 283 name_parts = name.scan(/[^.]+/)
  316. 283 candidates = [name] if @ndots <= name_parts.size - 1
  317. 849 candidates.concat(@search.map { |domain| [*name_parts, *domain].join(".") })
  318. 283 fname = "#{name}."
  319. 283 candidates << fname unless candidates.include?(fname)
  320. 283 candidates
  321. end
  322. 23 def build_socket
  323. 306 ip, port = @nameserver[@ns_index]
  324. 306 port ||= DNS_PORT
  325. 306 case @socket_type
  326. when :udp
  327. 291 log { "resolver: server: udp://#{ip}:#{port}..." }
  328. 291 UDP.new(ip, port, @options)
  329. when :tcp
  330. 15 log { "resolver: server: tcp://#{ip}:#{port}..." }
  331. 15 origin = URI("tcp://#{ip}:#{port}")
  332. 15 TCP.new(origin, [ip], @options)
  333. end
  334. end
  335. 23 def downgrade_socket
  336. 48 return unless @socket_type == :tcp
  337. 5 @socket_type = @resolver_options.fetch(:socket_type, :udp)
  338. 5 transition(:idle)
  339. 5 transition(:open)
  340. end
  341. 23 def transition(nextstate)
  342. 3510 case nextstate
  343. when :idle
  344. 38 if @io
  345. 38 @io.close
  346. 38 @io = nil
  347. end
  348. when :open
  349. 3191 return unless @state == :idle
  350. 3191 @io ||= build_socket
  351. 3191 @io.connect
  352. 3191 return unless @io.connected?
  353. 306 resolve if @queries.empty? && !@connections.empty?
  354. when :closed
  355. 281 return unless @state == :open
  356. 281 @io.close if @io
  357. 281 @start_timeout = nil
  358. 281 @write_buffer.clear
  359. 281 @read_buffer.clear
  360. end
  361. 625 @state = nextstate
  362. rescue Errno::ECONNREFUSED,
  363. Errno::EADDRNOTAVAIL,
  364. Errno::EHOSTUNREACH,
  365. SocketError,
  366. IOError,
  367. ConnectTimeoutError => e
  368. # these errors may happen during TCP handshake
  369. # treat them as resolve errors.
  370. handle_error(e)
  371. end
  372. 23 def handle_error(error)
  373. 25 if error.respond_to?(:connection) &&
  374. error.respond_to?(:host)
  375. 20 reset_hostname(error.host, connection: error.connection)
  376. 20 @connections.delete(error.connection)
  377. 20 emit_resolve_error(error.connection, error.host, error)
  378. else
  379. 5 @queries.each do |host, connection|
  380. 5 reset_hostname(host, connection: connection)
  381. 5 @connections.delete(connection)
  382. 5 emit_resolve_error(connection, host, error)
  383. end
  384. end
  385. 25 emit(:close, self) if @connections.empty?
  386. end
  387. 23 def reset_hostname(hostname, connection: @queries.delete(hostname), reset_candidates: true)
  388. 539 @timeouts.delete(hostname)
  389. 539 @timeouts.delete(hostname)
  390. 539 return unless connection && reset_candidates
  391. # eliminate other candidates
  392. 837 candidates = @queries.select { |_, conn| connection == conn }.keys
  393. 837 @queries.delete_if { |h, _| candidates.include?(h) }
  394. # reset timeouts
  395. 847 @timeouts.delete_if { |h, _| candidates.include?(h) }
  396. end
  397. end
  398. end

lib/httpx/resolver/resolver.rb

91.25% lines covered

80 relevant lines. 73 lines covered and 7 lines missed.
    
  1. # frozen_string_literal: true
  2. 23 require "resolv"
  3. 23 require "ipaddr"
  4. 23 module HTTPX
  5. 23 class Resolver::Resolver
  6. 23 include Callbacks
  7. 23 include Loggable
  8. 23 using ArrayExtensions::Intersect
  9. 1 RECORD_TYPES = {
  10. 22 Socket::AF_INET6 => Resolv::DNS::Resource::IN::AAAA,
  11. Socket::AF_INET => Resolv::DNS::Resource::IN::A,
  12. }.freeze
  13. 1 FAMILY_TYPES = {
  14. 22 Resolv::DNS::Resource::IN::AAAA => "AAAA",
  15. Resolv::DNS::Resource::IN::A => "A",
  16. }.freeze
  17. 23 class << self
  18. 23 def multi?
  19. 5035 true
  20. end
  21. end
  22. 23 attr_reader :family, :options
  23. 23 attr_writer :current_selector, :current_session
  24. 23 attr_accessor :multi
  25. 23 def initialize(family, options)
  26. 5056 @family = family
  27. 5056 @record_type = RECORD_TYPES[family]
  28. 5056 @options = options
  29. 5056 set_resolver_callbacks
  30. end
  31. 23 def each_connection(&block)
  32. 170 enum_for(__method__) unless block
  33. 170 return unless @connections
  34. 170 @connections.each(&block)
  35. end
  36. 23 def close; end
  37. 23 alias_method :terminate, :close
  38. 23 def closed?
  39. true
  40. end
  41. 23 def empty?
  42. true
  43. end
  44. 23 def inflight?
  45. false
  46. end
  47. 23 def emit_addresses(connection, family, addresses, early_resolve = false)
  48. 5103 addresses.map! do |address|
  49. 11546 address.is_a?(IPAddr) ? address : IPAddr.new(address.to_s)
  50. end
  51. # double emission check, but allow early resolution to work
  52. 5103 return if !early_resolve && connection.addresses && !addresses.intersect?(connection.addresses)
  53. 5153 log { "resolver: answer #{FAMILY_TYPES[RECORD_TYPES[family]]} #{connection.peer.host}: #{addresses.inspect}" }
  54. 5103 if @current_selector && # if triggered by early resolve, session may not be here yet
  55. !connection.io &&
  56. connection.options.ip_families.size > 1 &&
  57. family == Socket::AF_INET &&
  58. addresses.first.to_s != connection.peer.host.to_s
  59. log { "resolver: A response, applying resolution delay..." }
  60. @current_selector.after(0.05) do
  61. unless connection.state == :closed ||
  62. # double emission check
  63. (connection.addresses && addresses.intersect?(connection.addresses))
  64. emit_resolved_connection(connection, addresses, early_resolve)
  65. end
  66. end
  67. else
  68. 5103 emit_resolved_connection(connection, addresses, early_resolve)
  69. end
  70. end
  71. 23 private
  72. 23 def emit_resolved_connection(connection, addresses, early_resolve)
  73. begin
  74. 5103 connection.addresses = addresses
  75. 5069 emit(:resolve, connection)
  76. 24 rescue StandardError => e
  77. 34 if early_resolve
  78. 29 connection.force_reset
  79. 29 throw(:resolve_error, e)
  80. else
  81. 5 emit(:error, connection, e)
  82. end
  83. end
  84. end
  85. 23 def early_resolve(connection, hostname: connection.peer.host)
  86. 16 addresses = @resolver_options[:cache] && (connection.addresses || HTTPX::Resolver.nolookup_resolve(hostname))
  87. 16 return false unless addresses
  88. 5 addresses = addresses.select { |addr| addr.family == @family }
  89. 1 return false if addresses.empty?
  90. 1 emit_addresses(connection, @family, addresses, true)
  91. 1 true
  92. end
  93. 23 def emit_resolve_error(connection, hostname = connection.peer.host, ex = nil)
  94. 135 emit_connection_error(connection, resolve_error(hostname, ex))
  95. end
  96. 23 def resolve_error(hostname, ex = nil)
  97. 135 return ex if ex.is_a?(ResolveError) || ex.is_a?(ResolveTimeoutError)
  98. 35 message = ex ? ex.message : "Can't resolve #{hostname}"
  99. 35 error = ResolveError.new(message)
  100. 35 error.set_backtrace(ex ? ex.backtrace : caller)
  101. 35 error
  102. end
  103. 23 def set_resolver_callbacks
  104. 5056 on(:resolve, &method(:resolve_connection))
  105. 5056 on(:error, &method(:emit_connection_error))
  106. 5056 on(:close, &method(:close_resolver))
  107. end
  108. 23 def resolve_connection(connection)
  109. 5069 @current_session.__send__(:on_resolver_connection, connection, @current_selector)
  110. end
  111. 23 def emit_connection_error(connection, error)
  112. 129 return connection.emit(:connect_error, error) if connection.connecting? && connection.callbacks_for?(:connect_error)
  113. 129 connection.emit(:error, error)
  114. end
  115. 23 def close_resolver(resolver)
  116. 271 @current_session.__send__(:on_resolver_close, resolver, @current_selector)
  117. end
  118. end
  119. end

lib/httpx/resolver/system.rb

82.09% lines covered

134 relevant lines. 110 lines covered and 24 lines missed.
    
  1. # frozen_string_literal: true
  2. 23 require "forwardable"
  3. 23 require "resolv"
  4. 23 module HTTPX
  5. 23 class Resolver::System < Resolver::Resolver
  6. 23 using URIExtensions
  7. 23 extend Forwardable
  8. 23 RESOLV_ERRORS = [Resolv::ResolvError,
  9. Resolv::DNS::Requester::RequestError,
  10. Resolv::DNS::EncodeError,
  11. Resolv::DNS::DecodeError].freeze
  12. 23 DONE = 1
  13. 23 ERROR = 2
  14. 23 class << self
  15. 23 def multi?
  16. 21 false
  17. end
  18. end
  19. 23 attr_reader :state
  20. 23 def_delegator :@connections, :empty?
  21. 23 def initialize(options)
  22. 21 super(nil, options)
  23. 21 @resolver_options = @options.resolver_options
  24. 21 resolv_options = @resolver_options.dup
  25. 21 timeouts = resolv_options.delete(:timeouts) || Resolver::RESOLVE_TIMEOUT
  26. 21 @_timeouts = Array(timeouts)
  27. 42 @timeouts = Hash.new { |tims, host| tims[host] = @_timeouts.dup }
  28. 21 resolv_options.delete(:cache)
  29. 21 @connections = []
  30. 21 @queries = []
  31. 21 @ips = []
  32. 21 @pipe_mutex = Thread::Mutex.new
  33. 21 @state = :idle
  34. end
  35. 23 def resolvers
  36. return enum_for(__method__) unless block_given?
  37. yield self
  38. end
  39. 23 def multi
  40. self
  41. end
  42. 23 def empty?
  43. true
  44. end
  45. 23 def close
  46. transition(:closed)
  47. end
  48. 23 def closed?
  49. @state == :closed
  50. end
  51. 23 def to_io
  52. @pipe_read.to_io
  53. end
  54. 23 def call
  55. case @state
  56. when :open
  57. consume
  58. end
  59. nil
  60. end
  61. 23 def interests
  62. return if @queries.empty?
  63. :r
  64. end
  65. 23 def timeout
  66. return unless @queries.empty?
  67. _, connection = @queries.first
  68. return unless connection
  69. @timeouts[connection.peer.host].first
  70. end
  71. 23 def <<(connection)
  72. 21 @connections << connection
  73. 21 resolve
  74. end
  75. 23 def early_resolve(connection, **)
  76. 21 self << connection
  77. 10 true
  78. end
  79. 23 def handle_socket_timeout(interval)
  80. error = HTTPX::ResolveTimeoutError.new(interval, "timed out while waiting on select")
  81. error.set_backtrace(caller)
  82. on_error(error)
  83. end
  84. 23 private
  85. 23 def transition(nextstate)
  86. 21 case nextstate
  87. when :idle
  88. @timeouts.clear
  89. when :open
  90. 21 return unless @state == :idle
  91. 21 @pipe_read, @pipe_write = ::IO.pipe
  92. when :closed
  93. return unless @state == :open
  94. @pipe_write.close
  95. @pipe_read.close
  96. end
  97. 21 @state = nextstate
  98. end
  99. 23 def consume
  100. 21 return if @connections.empty?
  101. 21 if @pipe_read.wait_readable
  102. 21 event = @pipe_read.getbyte
  103. 21 case event
  104. when DONE
  105. 20 *pair, addrs = @pipe_mutex.synchronize { @ips.pop }
  106. 10 @queries.delete(pair)
  107. 10 _, connection = pair
  108. 10 @connections.delete(connection)
  109. 10 family, connection = pair
  110. 20 catch(:coalesced) { emit_addresses(connection, family, addrs) }
  111. when ERROR
  112. 22 *pair, error = @pipe_mutex.synchronize { @ips.pop }
  113. 11 @queries.delete(pair)
  114. 11 @connections.delete(connection)
  115. 11 _, connection = pair
  116. 11 emit_resolve_error(connection, connection.peer.host, error)
  117. end
  118. end
  119. 10 return emit(:close, self) if @connections.empty?
  120. resolve
  121. end
  122. 23 def resolve(connection = @connections.first)
  123. 21 raise Error, "no URI to resolve" unless connection
  124. 21 return unless @queries.empty?
  125. 21 hostname = connection.peer.host
  126. 21 scheme = connection.origin.scheme
  127. 21 log { "resolver: resolve IDN #{connection.peer.non_ascii_hostname} as #{hostname}" } if connection.peer.non_ascii_hostname
  128. 21 transition(:open)
  129. 21 connection.options.ip_families.each do |family|
  130. 21 @queries << [family, connection]
  131. end
  132. 21 async_resolve(connection, hostname, scheme)
  133. 21 consume
  134. end
  135. 23 def async_resolve(connection, hostname, scheme)
  136. 21 families = connection.options.ip_families
  137. 21 log { "resolver: query for #{hostname}" }
  138. 21 timeouts = @timeouts[connection.peer.host]
  139. 21 resolve_timeout = timeouts.first
  140. 21 Thread.start do
  141. 21 Thread.current.report_on_exception = false
  142. begin
  143. 21 addrs = if resolve_timeout
  144. 21 Timeout.timeout(resolve_timeout) do
  145. 21 __addrinfo_resolve(hostname, scheme)
  146. end
  147. else
  148. __addrinfo_resolve(hostname, scheme)
  149. end
  150. 10 addrs = addrs.sort_by(&:afamily).group_by(&:afamily)
  151. 10 families.each do |family|
  152. 10 addresses = addrs[family]
  153. 10 next unless addresses
  154. 10 addresses.map!(&:ip_address)
  155. 10 addresses.uniq!
  156. 10 @pipe_mutex.synchronize do
  157. 10 @ips.unshift([family, connection, addresses])
  158. 10 @pipe_write.putc(DONE) unless @pipe_write.closed?
  159. end
  160. end
  161. rescue StandardError => e
  162. 11 if e.is_a?(Timeout::Error)
  163. 1 timeouts.shift
  164. 1 retry unless timeouts.empty?
  165. 1 e = ResolveTimeoutError.new(resolve_timeout, e.message)
  166. 1 e.set_backtrace(e.backtrace)
  167. end
  168. 11 @pipe_mutex.synchronize do
  169. 11 families.each do |family|
  170. 11 @ips.unshift([family, connection, e])
  171. 11 @pipe_write.putc(ERROR) unless @pipe_write.closed?
  172. end
  173. end
  174. end
  175. end
  176. end
  177. 23 def __addrinfo_resolve(host, scheme)
  178. 21 Addrinfo.getaddrinfo(host, scheme, Socket::AF_UNSPEC, Socket::SOCK_STREAM)
  179. end
  180. 23 def emit_connection_error(_, error)
  181. 11 throw(:resolve_error, error)
  182. end
  183. 23 def close_resolver(resolver); end
  184. end
  185. end

lib/httpx/response.rb

100.0% lines covered

107 relevant lines. 107 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 23 require "objspace"
  3. 23 require "stringio"
  4. 23 require "tempfile"
  5. 23 require "fileutils"
  6. 23 require "forwardable"
  7. 23 module HTTPX
  8. # Defines a HTTP response is handled internally, with a few properties exposed as attributes.
  9. #
  10. # It delegates the following methods to the corresponding HTTPX::Request:
  11. #
  12. # * HTTPX::Request#uri
  13. # * HTTPX::Request#peer_address
  14. #
  15. # It implements (indirectly, via the +body+) the IO write protocol to internally buffer payloads.
  16. #
  17. # It implements the IO reader protocol in order for users to buffer/stream it, acts as an enumerable
  18. # (of payload chunks).
  19. #
  20. 23 class Response
  21. 23 extend Forwardable
  22. 23 include Callbacks
  23. # the HTTP response status code
  24. 23 attr_reader :status
  25. # an HTTPX::Headers object containing the response HTTP headers.
  26. 23 attr_reader :headers
  27. # a HTTPX::Response::Body object wrapping the response body. The following methods are delegated to it:
  28. #
  29. # * HTTPX::Response::Body#to_s
  30. # * HTTPX::Response::Body#to_str
  31. # * HTTPX::Response::Body#read
  32. # * HTTPX::Response::Body#copy_to
  33. # * HTTPX::Response::Body#close
  34. 23 attr_reader :body
  35. # The HTTP protocol version used to fetch the response.
  36. 23 attr_reader :version
  37. # returns the response body buffered in a string.
  38. 23 def_delegator :@body, :to_s
  39. 23 def_delegator :@body, :to_str
  40. # implements the IO reader +#read+ interface.
  41. 23 def_delegator :@body, :read
  42. # copies the response body to a different location.
  43. 23 def_delegator :@body, :copy_to
  44. # closes the body.
  45. 23 def_delegator :@body, :close
  46. # the corresponding request uri.
  47. 23 def_delegator :@request, :uri
  48. # the IP address of the peer server.
  49. 23 def_delegator :@request, :peer_address
  50. # inits the instance with the corresponding +request+ to this response, an the
  51. # response HTTP +status+, +version+ and HTTPX::Headers instance of +headers+.
  52. 23 def initialize(request, status, version, headers)
  53. 6666 @request = request
  54. 6666 @options = request.options
  55. 6666 @version = version
  56. 6666 @status = Integer(status)
  57. 6666 @headers = @options.headers_class.new(headers)
  58. 6666 @body = @options.response_body_class.new(self, @options)
  59. 6666 @finished = complete?
  60. 6666 @content_type = nil
  61. end
  62. # merges headers defined in +h+ into the response headers.
  63. 23 def merge_headers(h)
  64. 155 @headers = @headers.merge(h)
  65. end
  66. # writes +data+ chunk into the response body.
  67. 23 def <<(data)
  68. 8867 @body.write(data)
  69. end
  70. # returns the HTTPX::ContentType for the response, as per what's declared in the content-type header.
  71. #
  72. # response.content_type #=> #<HTTPX::ContentType:xxx @header_value="text/plain">
  73. # response.content_type.mime_type #=> "text/plain"
  74. 23 def content_type
  75. 6945 @content_type ||= ContentType.new(@headers["content-type"])
  76. end
  77. # returns whether the response has been fully fetched.
  78. 23 def finished?
  79. 3581 @finished
  80. end
  81. # marks the response as finished, freezes the headers.
  82. 23 def finish!
  83. 3499 @finished = true
  84. 3499 @headers.freeze
  85. end
  86. # returns whether the response contains body payload.
  87. 23 def bodyless?
  88. 6666 @request.verb == "HEAD" ||
  89. @status < 200 || # informational response
  90. @status == 204 ||
  91. @status == 205 ||
  92. @status == 304 || begin
  93. 6327 content_length = @headers["content-length"]
  94. 6327 return false if content_length.nil?
  95. 5474 content_length == "0"
  96. end
  97. end
  98. 23 def complete?
  99. 6666 bodyless? || (@request.verb == "CONNECT" && @status == 200)
  100. end
  101. skipped # :nocov:
  102. skipped def inspect
  103. skipped "#<Response:#{object_id} " \
  104. skipped "HTTP/#{version} " \
  105. skipped "@status=#{@status} " \
  106. skipped "@headers=#{@headers} " \
  107. skipped "@body=#{@body.bytesize}>"
  108. skipped end
  109. skipped # :nocov:
  110. # returns an instance of HTTPX::HTTPError if the response has a 4xx or 5xx
  111. # status code, or nothing.
  112. #
  113. # ok_response.error #=> nil
  114. # not_found_response.error #=> HTTPX::HTTPError instance, status 404
  115. 23 def error
  116. 448 return if @status < 400
  117. 41 HTTPError.new(self)
  118. end
  119. # it raises the exception returned by +error+, or itself otherwise.
  120. #
  121. # ok_response.raise_for_status #=> ok_response
  122. # not_found_response.raise_for_status #=> raises HTTPX::HTTPError exception
  123. 23 def raise_for_status
  124. 418 return self unless (err = error)
  125. 29 raise err
  126. end
  127. # decodes the response payload into a ruby object **if** the payload is valid json.
  128. #
  129. # response.json #≈> { "foo" => "bar" } for "{\"foo\":\"bar\"}" payload
  130. # response.json(symbolize_names: true) #≈> { foo: "bar" } for "{\"foo\":\"bar\"}" payload
  131. 23 def json(*args)
  132. 99 decode(Transcoder::JSON, *args)
  133. end
  134. # decodes the response payload into a ruby object **if** the payload is valid
  135. # "application/x-www-urlencoded" or "multipart/form-data".
  136. 23 def form
  137. 48 decode(Transcoder::Form)
  138. end
  139. 23 def xml
  140. # TODO: remove at next major version.
  141. 6 warn "DEPRECATION WARNING: calling `.#{__method__}` on plain HTTPX responses is deprecated. " \
  142. "Use HTTPX.plugin(:xml) sessions and call `.#{__method__}` in its responses instead."
  143. 6 require "httpx/plugins/xml"
  144. 6 decode(Plugins::XML::Transcoder)
  145. end
  146. 23 private
  147. # decodes the response payload using the given +transcoder+, which implements the decoding logic.
  148. #
  149. # +transcoder+ must implement the internal transcoder API, i.e. respond to <tt>decode(HTTPX::Response response)</tt>,
  150. # which returns a decoder which responds to <tt>call(HTTPX::Response response, **kwargs)</tt>
  151. 23 def decode(transcoder, *args)
  152. # TODO: check if content-type is a valid format, i.e. "application/json" for json parsing
  153. 165 decoder = transcoder.decode(self)
  154. 147 raise Error, "no decoder available for \"#{transcoder}\"" unless decoder
  155. 147 @body.rewind
  156. 147 decoder.call(self, *args)
  157. end
  158. end
  159. # Helper class which decodes the HTTP "content-type" header.
  160. 23 class ContentType
  161. 23 MIME_TYPE_RE = %r{^([^/]+/[^;]+)(?:$|;)}.freeze
  162. 23 CHARSET_RE = /;\s*charset=([^;]+)/i.freeze
  163. 23 def initialize(header_value)
  164. 6915 @header_value = header_value
  165. end
  166. # returns the mime type declared in the header.
  167. #
  168. # ContentType.new("application/json; charset=utf-8").mime_type #=> "application/json"
  169. 23 def mime_type
  170. 165 return @mime_type if defined?(@mime_type)
  171. 135 m = @header_value.to_s[MIME_TYPE_RE, 1]
  172. 135 m && @mime_type = m.strip.downcase
  173. end
  174. # returns the charset declared in the header.
  175. #
  176. # ContentType.new("application/json; charset=utf-8").charset #=> "utf-8"
  177. # ContentType.new("text/plain").charset #=> nil
  178. 23 def charset
  179. 6780 return @charset if defined?(@charset)
  180. 6780 m = @header_value.to_s[CHARSET_RE, 1]
  181. 6780 m && @charset = m.strip.delete('"')
  182. end
  183. end
  184. # Wraps an error which has happened while processing an HTTP Request. It has partial
  185. # public API parity with HTTPX::Response, so users should rely on it to infer whether
  186. # the returned response is one or the other.
  187. #
  188. # response = HTTPX.get("https://some-domain/path") #=> response is HTTPX::Response or HTTPX::ErrorResponse
  189. # response.raise_for_status #=> raises if it wraps an error
  190. 23 class ErrorResponse
  191. 23 include Loggable
  192. 23 extend Forwardable
  193. # the corresponding HTTPX::Request instance.
  194. 23 attr_reader :request
  195. # the HTTPX::Response instance, when there is one (i.e. error happens fetching the response).
  196. 23 attr_reader :response
  197. # the wrapped exception.
  198. 23 attr_reader :error
  199. # the request uri
  200. 23 def_delegator :@request, :uri
  201. # the IP address of the peer server.
  202. 23 def_delegator :@request, :peer_address
  203. 23 def initialize(request, error)
  204. 885 @request = request
  205. 885 @response = request.response if request.response.is_a?(Response)
  206. 885 @error = error
  207. 885 @options = request.options
  208. 885 log_exception(@error)
  209. end
  210. # returns the exception full message.
  211. 23 def to_s
  212. 7 @error.full_message(highlight: false)
  213. end
  214. # closes the error resources.
  215. 23 def close
  216. 30 @response.close if @response && @response.respond_to?(:close)
  217. end
  218. # always true for error responses.
  219. 23 def finished?
  220. 6 true
  221. end
  222. # raises the wrapped exception.
  223. 23 def raise_for_status
  224. 58 raise @error
  225. end
  226. # buffers lost chunks to error response
  227. 23 def <<(data)
  228. 6 @response << data
  229. end
  230. end
  231. end
  232. 23 require_relative "response/body"
  233. 23 require_relative "response/buffer"
  234. 23 require_relative "pmatch_extensions" if RUBY_VERSION >= "2.7.0"

lib/httpx/response/body.rb

100.0% lines covered

114 relevant lines. 114 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 23 module HTTPX
  3. # Implementation of the HTTP Response body as a buffer which implements the IO writer protocol
  4. # (for buffering the response payload), the IO reader protocol (for consuming the response payload),
  5. # and can be iterated over (via #each, which yields the payload in chunks).
  6. 23 class Response::Body
  7. # the payload encoding (i.e. "utf-8", "ASCII-8BIT")
  8. 23 attr_reader :encoding
  9. # Array of encodings contained in the response "content-encoding" header.
  10. 23 attr_reader :encodings
  11. # initialized with the corresponding HTTPX::Response +response+ and HTTPX::Options +options+.
  12. 23 def initialize(response, options)
  13. 6780 @response = response
  14. 6780 @headers = response.headers
  15. 6780 @options = options
  16. 6780 @window_size = options.window_size
  17. 6780 @encodings = []
  18. 6780 @length = 0
  19. 6780 @buffer = nil
  20. 6780 @reader = nil
  21. 6780 @state = :idle
  22. # initialize response encoding
  23. 6780 @encoding = if (enc = response.content_type.charset)
  24. 167 begin
  25. 1144 Encoding.find(enc)
  26. rescue ArgumentError
  27. 24 Encoding::BINARY
  28. end
  29. else
  30. 5636 Encoding::BINARY
  31. end
  32. 6780 initialize_inflaters
  33. end
  34. 23 def initialize_dup(other)
  35. 24 super
  36. 24 @buffer = other.instance_variable_get(:@buffer).dup
  37. end
  38. 23 def closed?
  39. 222 @state == :closed
  40. end
  41. # write the response payload +chunk+ into the buffer. Inflates the chunk when required
  42. # and supported.
  43. 23 def write(chunk)
  44. 8724 return if @state == :closed
  45. 8724 return 0 if chunk.empty?
  46. 8410 chunk = decode_chunk(chunk)
  47. 8410 size = chunk.bytesize
  48. 8410 @length += size
  49. 8410 transition(:open)
  50. 8410 @buffer.write(chunk)
  51. 8410 @response.emit(:chunk_received, chunk)
  52. 8398 size
  53. end
  54. # reads a chunk from the payload (implementation of the IO reader protocol).
  55. 23 def read(*args)
  56. 233 return unless @buffer
  57. 233 unless @reader
  58. 93 rewind
  59. 93 @reader = @buffer
  60. end
  61. 233 @reader.read(*args)
  62. end
  63. # size of the decoded response payload. May differ from "content-length" header if
  64. # response was encoded over-the-wire.
  65. 23 def bytesize
  66. 169 @length
  67. end
  68. # yields the payload in chunks.
  69. 23 def each
  70. 48 return enum_for(__method__) unless block_given?
  71. 5 begin
  72. 36 if @buffer
  73. 36 rewind
  74. 96 while (chunk = @buffer.read(@window_size))
  75. 36 yield(chunk.force_encoding(@encoding))
  76. end
  77. end
  78. ensure
  79. 36 close
  80. end
  81. end
  82. # returns the declared filename in the "contennt-disposition" header, when present.
  83. 23 def filename
  84. 36 return unless @headers.key?("content-disposition")
  85. 30 Utils.get_filename(@headers["content-disposition"])
  86. end
  87. # returns the full response payload as a string.
  88. 23 def to_s
  89. 3338 return "".b unless @buffer
  90. 3099 @buffer.to_s
  91. end
  92. 23 alias_method :to_str, :to_s
  93. # whether the payload is empty.
  94. 23 def empty?
  95. 24 @length.zero?
  96. end
  97. # copies the payload to +dest+.
  98. #
  99. # body.copy_to("path/to/file")
  100. # body.copy_to(Pathname.new("path/to/file"))
  101. # body.copy_to(File.new("path/to/file"))
  102. 23 def copy_to(dest)
  103. 36 return unless @buffer
  104. 36 rewind
  105. 36 if dest.respond_to?(:path) && @buffer.respond_to?(:path)
  106. 6 FileUtils.mv(@buffer.path, dest.path)
  107. else
  108. 30 ::IO.copy_stream(@buffer, dest)
  109. end
  110. end
  111. # closes/cleans the buffer, resets everything
  112. 23 def close
  113. 506 if @buffer
  114. 375 @buffer.close
  115. 375 @buffer = nil
  116. end
  117. 506 @length = 0
  118. 506 transition(:closed)
  119. end
  120. 23 def ==(other)
  121. 88 object_id == other.object_id || begin
  122. 88 if other.respond_to?(:read)
  123. 60 _with_same_buffer_pos { FileUtils.compare_stream(@buffer, other) }
  124. else
  125. 58 to_s == other.to_s
  126. end
  127. end
  128. end
  129. skipped # :nocov:
  130. skipped def inspect
  131. skipped "#<HTTPX::Response::Body:#{object_id} " \
  132. skipped "@state=#{@state} " \
  133. skipped "@length=#{@length}>"
  134. skipped end
  135. skipped # :nocov:
  136. # rewinds the response payload buffer.
  137. 23 def rewind
  138. 342 return unless @buffer
  139. # in case there's some reading going on
  140. 342 @reader = nil
  141. 342 @buffer.rewind
  142. end
  143. 23 private
  144. # prepares inflaters for the advertised encodings in "content-encoding" header.
  145. 23 def initialize_inflaters
  146. 6780 @inflaters = nil
  147. 6780 return unless @headers.key?("content-encoding")
  148. 144 return unless @options.decompress_response_body
  149. 132 @inflaters = @headers.get("content-encoding").filter_map do |encoding|
  150. 132 next if encoding == "identity"
  151. 132 inflater = self.class.initialize_inflater_by_encoding(encoding, @response)
  152. # do not uncompress if there is no decoder available. In fact, we can't reliably
  153. # continue decompressing beyond that, so ignore.
  154. 132 break unless inflater
  155. 132 @encodings << encoding
  156. 132 inflater
  157. end
  158. end
  159. # passes the +chunk+ through all inflaters to decode it.
  160. 23 def decode_chunk(chunk)
  161. @inflaters.reverse_each do |inflater|
  162. 387 chunk = inflater.call(chunk)
  163. 8528 end if @inflaters
  164. 8529 chunk
  165. end
  166. # tries transitioning the body STM to the +nextstate+.
  167. 23 def transition(nextstate)
  168. 8916 case nextstate
  169. when :open
  170. 8410 return unless @state == :idle
  171. 5241 @buffer = Response::Buffer.new(
  172. threshold_size: @options.body_threshold_size,
  173. bytesize: @length,
  174. encoding: @encoding
  175. )
  176. when :closed
  177. 506 return if @state == :closed
  178. end
  179. 5747 @state = nextstate
  180. end
  181. 23 def _with_same_buffer_pos # :nodoc:
  182. 30 return yield unless @buffer && @buffer.respond_to?(:pos)
  183. # @type ivar @buffer: StringIO | Tempfile
  184. 30 current_pos = @buffer.pos
  185. 30 @buffer.rewind
  186. 4 begin
  187. 30 yield
  188. ensure
  189. 30 @buffer.pos = current_pos
  190. end
  191. end
  192. 23 class << self
  193. 23 def initialize_inflater_by_encoding(encoding, response, **kwargs) # :nodoc:
  194. 132 case encoding
  195. when "gzip"
  196. 120 Transcoder::GZIP.decode(response, **kwargs)
  197. when "deflate"
  198. 12 Transcoder::Deflate.decode(response, **kwargs)
  199. end
  200. end
  201. end
  202. end
  203. end

lib/httpx/response/buffer.rb

96.0% lines covered

50 relevant lines. 48 lines covered and 2 lines missed.
    
  1. # frozen_string_literal: true
  2. 23 require "delegate"
  3. 23 require "stringio"
  4. 23 require "tempfile"
  5. 23 module HTTPX
  6. # wraps and delegates to an internal buffer, which can be a StringIO or a Tempfile.
  7. 23 class Response::Buffer < SimpleDelegator
  8. # initializes buffer with the +threshold_size+ over which the payload gets buffer to a tempfile,
  9. # the initial +bytesize+, and the +encoding+.
  10. 23 def initialize(threshold_size:, bytesize: 0, encoding: Encoding::BINARY)
  11. 5391 @threshold_size = threshold_size
  12. 5391 @bytesize = bytesize
  13. 5391 @encoding = encoding
  14. 5391 @buffer = StringIO.new("".b)
  15. 5391 super(@buffer)
  16. end
  17. 23 def initialize_dup(other)
  18. 24 super
  19. 24 @buffer = other.instance_variable_get(:@buffer).dup
  20. end
  21. # size in bytes of the buffered content.
  22. 23 def size
  23. 254 @bytesize
  24. end
  25. # writes the +chunk+ into the buffer.
  26. 23 def write(chunk)
  27. 8697 @bytesize += chunk.bytesize
  28. 8697 try_upgrade_buffer
  29. 8697 @buffer.write(chunk)
  30. end
  31. # returns the buffered content as a string.
  32. 23 def to_s
  33. 3171 case @buffer
  34. when StringIO
  35. 475 begin
  36. 3111 @buffer.string.force_encoding(@encoding)
  37. rescue ArgumentError
  38. @buffer.string
  39. end
  40. when Tempfile
  41. 60 rewind
  42. 120 content = _with_same_buffer_pos { @buffer.read }
  43. 9 begin
  44. 60 content.force_encoding(@encoding)
  45. rescue ArgumentError # ex: unknown encoding name - utf
  46. content
  47. end
  48. end
  49. end
  50. # closes the buffer.
  51. 23 def close
  52. 459 @buffer.close
  53. 459 @buffer.unlink if @buffer.respond_to?(:unlink)
  54. end
  55. 23 private
  56. # initializes the buffer into a StringIO, or turns it into a Tempfile when the threshold
  57. # has been reached.
  58. 23 def try_upgrade_buffer
  59. 8697 return unless @bytesize > @threshold_size
  60. 381 return if @buffer.is_a?(Tempfile)
  61. 123 aux = @buffer
  62. 123 @buffer = Tempfile.new("httpx", encoding: Encoding::BINARY, mode: File::RDWR)
  63. 123 if aux
  64. 123 aux.rewind
  65. 123 ::IO.copy_stream(aux, @buffer)
  66. 123 aux.close
  67. end
  68. 123 __setobj__(@buffer)
  69. end
  70. 23 def _with_same_buffer_pos # :nodoc:
  71. 60 current_pos = @buffer.pos
  72. 60 @buffer.rewind
  73. 9 begin
  74. 60 yield
  75. ensure
  76. 60 @buffer.pos = current_pos
  77. end
  78. end
  79. end
  80. end

lib/httpx/selector.rb

91.92% lines covered

99 relevant lines. 91 lines covered and 8 lines missed.
    
  1. # frozen_string_literal: true
  2. 23 require "io/wait"
  3. 23 module HTTPX
  4. 23 class Selector
  5. 23 extend Forwardable
  6. 23 READABLE = %i[rw r].freeze
  7. 23 WRITABLE = %i[rw w].freeze
  8. 23 private_constant :READABLE
  9. 23 private_constant :WRITABLE
  10. 23 def_delegator :@timers, :after
  11. 23 def_delegator :@selectables, :empty?
  12. 23 def initialize
  13. 5409 @timers = Timers.new
  14. 5409 @selectables = []
  15. end
  16. 23 def each(&blk)
  17. @selectables.each(&blk)
  18. end
  19. 23 def next_tick
  20. 3345336 catch(:jump_tick) do
  21. 3345336 timeout = next_timeout
  22. 3345336 if timeout && timeout.negative?
  23. @timers.fire
  24. throw(:jump_tick)
  25. end
  26. 1313616 begin
  27. 3345336 select(timeout, &:call)
  28. 3345167 @timers.fire
  29. rescue TimeoutError => e
  30. @timers.fire(e)
  31. end
  32. end
  33. rescue StandardError => e
  34. 91 emit_error(e)
  35. rescue Exception # rubocop:disable Lint/RescueException
  36. 78 each_connection(&:force_reset)
  37. 78 raise
  38. end
  39. 23 def terminate
  40. # array may change during iteration
  41. 5117 selectables = @selectables.reject(&:inflight?)
  42. 5117 selectables.each(&:terminate)
  43. 5111 until selectables.empty?
  44. 2011 next_tick
  45. 2011 selectables &= @selectables
  46. end
  47. end
  48. 23 def find_resolver(options)
  49. 5082 res = @selectables.find do |c|
  50. 44 c.is_a?(Resolver::Resolver) && options == c.options
  51. end
  52. 5082 res.multi if res
  53. end
  54. 23 def each_connection(&block)
  55. 24341 return enum_for(__method__) unless block
  56. 12358 @selectables.each do |c|
  57. 1769 if c.is_a?(Resolver::Resolver)
  58. 170 c.each_connection(&block)
  59. else
  60. 1599 yield c
  61. end
  62. end
  63. end
  64. 23 def find_connection(request_uri, options)
  65. 6757 each_connection.find do |connection|
  66. 1056 connection.match?(request_uri, options)
  67. end
  68. end
  69. 23 def find_mergeable_connection(connection)
  70. 5226 each_connection.find do |ch|
  71. 232 ch != connection && ch.mergeable?(connection)
  72. end
  73. end
  74. 23 def empty?
  75. 762 @selectables.empty?
  76. end
  77. # deregisters +io+ from selectables.
  78. 23 def deregister(io)
  79. 6103 @selectables.delete(io)
  80. end
  81. # register +io+.
  82. 23 def register(io)
  83. 6231 return if @selectables.include?(io)
  84. 5949 @selectables << io
  85. end
  86. 23 private
  87. 23 def select(interval, &block)
  88. # do not cause an infinite loop here.
  89. #
  90. # this may happen if timeout calculation actually triggered an error which causes
  91. # the connections to be reaped (such as the total timeout error) before #select
  92. # gets called.
  93. 3345336 return if interval.nil? && @selectables.empty?
  94. 3343325 return select_one(interval, &block) if @selectables.size == 1
  95. 261 select_many(interval, &block)
  96. end
  97. 23 def select_many(interval, &block)
  98. 261 r, w = nil
  99. # first, we group IOs based on interest type. On call to #interests however,
  100. # things might already happen, and new IOs might be registered, so we might
  101. # have to start all over again. We do this until we group all selectables
  102. begin
  103. 261 @selectables.delete_if do |io|
  104. 333 interests = io.interests
  105. 333 (r ||= []) << io if READABLE.include?(interests)
  106. 333 (w ||= []) << io if WRITABLE.include?(interests)
  107. 333 io.state == :closed
  108. end
  109. # TODO: what to do if there are no selectables?
  110. 261 readers, writers = IO.select(r, w, nil, interval)
  111. 261 if readers.nil? && writers.nil? && interval
  112. 103 [*r, *w].each { |io| io.handle_socket_timeout(interval) }
  113. 103 return
  114. end
  115. end
  116. 158 if writers
  117. readers.each do |io|
  118. 91 yield io
  119. # so that we don't yield 2 times
  120. 91 writers.delete(io)
  121. 157 end if readers
  122. 158 writers.each(&block)
  123. else
  124. readers.each(&block) if readers
  125. end
  126. end
  127. 23 def select_one(interval)
  128. 3343064 io = @selectables.first
  129. 3343064 return unless io
  130. 3343064 interests = io.interests
  131. 3343064 result = case interests
  132. 9708 when :r then io.to_io.wait_readable(interval)
  133. 7121 when :w then io.to_io.wait_writable(interval)
  134. when :rw then io.to_io.wait(interval, :read_write)
  135. 3326235 when nil then return
  136. end
  137. 16829 unless result || interval.nil?
  138. 417 io.handle_socket_timeout(interval)
  139. 417 return
  140. end
  141. # raise TimeoutError.new(interval, "timed out while waiting on select")
  142. 16412 yield io
  143. # rescue IOError, SystemCallError
  144. # @selectables.reject!(&:closed?)
  145. # raise unless @selectables.empty?
  146. end
  147. 23 def next_timeout
  148. [
  149. 2031719 @timers.wait_interval,
  150. @selectables.filter_map(&:timeout).min,
  151. 1313616 ].compact.min
  152. end
  153. 23 def emit_error(e)
  154. 91 @selectables.each do |c|
  155. next if c.is_a?(Resolver::Resolver)
  156. c.emit(:error, e)
  157. end
  158. end
  159. end
  160. end

lib/httpx/session.rb

87.79% lines covered

262 relevant lines. 230 lines covered and 32 lines missed.
    
  1. # frozen_string_literal: true
  2. 23 module HTTPX
  3. # Class implementing the APIs being used publicly.
  4. #
  5. # HTTPX.get(..) #=> delegating to an internal HTTPX::Session object.
  6. # HTTPX.plugin(..).get(..) #=> creating an intermediate HTTPX::Session with plugin, then sending the GET request
  7. 23 class Session
  8. 23 include Loggable
  9. 23 include Chainable
  10. # initializes the session with a set of +options+, which will be shared by all
  11. # requests sent from it.
  12. #
  13. # When pass a block, it'll yield itself to it, then closes after the block is evaluated.
  14. 23 def initialize(options = EMPTY_HASH, &blk)
  15. 7922 @options = self.class.default_options.merge(options)
  16. 7922 @responses = {}
  17. 7922 @persistent = @options.persistent
  18. 7922 @pool = @options.pool_class.new(@options.pool_options)
  19. 7922 @wrapped = false
  20. 7922 @closing = false
  21. 7922 wrap(&blk) if blk
  22. end
  23. # Yields itself the block, then closes it after the block is evaluated.
  24. #
  25. # session.wrap do |http|
  26. # http.get("https://wikipedia.com")
  27. # end # wikipedia connection closes here
  28. 23 def wrap
  29. 395 prev_wrapped = @wrapped
  30. 395 @wrapped = true
  31. 395 was_initialized = false
  32. 395 current_selector = get_current_selector do
  33. 395 selector = Selector.new
  34. 395 set_current_selector(selector)
  35. 395 was_initialized = true
  36. 395 selector
  37. end
  38. 50 begin
  39. 395 yield self
  40. ensure
  41. 395 unless prev_wrapped
  42. 395 if @persistent
  43. 1 deactivate(current_selector)
  44. else
  45. 394 close(current_selector)
  46. end
  47. end
  48. 395 @wrapped = prev_wrapped
  49. 395 set_current_selector(nil) if was_initialized
  50. end
  51. end
  52. # closes all the active connections from the session.
  53. #
  54. # when called directly without specifying +selector+, all available connections
  55. # will be picked up from the connection pool and closed. Connections in use
  56. # by other sessions, or same session in a different thread, will not be reaped.
  57. 23 def close(selector = Selector.new)
  58. # throw resolvers away from the pool
  59. 5117 @pool.reset_resolvers
  60. # preparing to throw away connections
  61. 12262 while (connection = @pool.pop_connection)
  62. 3290 next if connection.state == :closed
  63. 120 connection.current_session = self
  64. 120 connection.current_selector = selector
  65. 120 select_connection(connection, selector)
  66. end
  67. 763 begin
  68. 5117 @closing = true
  69. 5117 selector.terminate
  70. ensure
  71. 5117 @closing = false
  72. end
  73. end
  74. # performs one, or multple requests; it accepts:
  75. #
  76. # 1. one or multiple HTTPX::Request objects;
  77. # 2. an HTTP verb, then a sequence of URIs or URI/options tuples;
  78. # 3. one or multiple HTTP verb / uri / (optional) options tuples;
  79. #
  80. # when present, the set of +options+ kwargs is applied to all of the
  81. # sent requests.
  82. #
  83. # respectively returns a single HTTPX::Response response, or all of them in an Array, in the same order.
  84. #
  85. # resp1 = session.request(req1)
  86. # resp1, resp2 = session.request(req1, req2)
  87. # resp1 = session.request("GET", "https://server.org/a")
  88. # resp1, resp2 = session.request("GET", ["https://server.org/a", "https://server.org/b"])
  89. # resp1, resp2 = session.request(["GET", "https://server.org/a"], ["GET", "https://server.org/b"])
  90. # resp1 = session.request("POST", "https://server.org/a", form: { "foo" => "bar" })
  91. # resp1, resp2 = session.request(["POST", "https://server.org/a", form: { "foo" => "bar" }], ["GET", "https://server.org/b"])
  92. # resp1, resp2 = session.request("GET", ["https://server.org/a", "https://server.org/b"], headers: { "x-api-token" => "TOKEN" })
  93. #
  94. 23 def request(*args, **params)
  95. 5303 raise ArgumentError, "must perform at least one request" if args.empty?
  96. 5303 requests = args.first.is_a?(Request) ? args : build_requests(*args, params)
  97. 5282 responses = send_requests(*requests)
  98. 5193 return responses.first if responses.size == 1
  99. 146 responses
  100. end
  101. # returns a HTTP::Request instance built from the HTTP +verb+, the request +uri+, and
  102. # the optional set of request-specific +options+. This request **must** be sent through
  103. # the same session it was built from.
  104. #
  105. # req = session.build_request("GET", "https://server.com")
  106. # resp = session.request(req)
  107. 23 def build_request(verb, uri, params = EMPTY_HASH, options = @options)
  108. 6492 rklass = options.request_class
  109. 6492 request = rklass.new(verb, uri, options, params)
  110. 6471 request.persistent = @persistent
  111. 6471 set_request_callbacks(request)
  112. 6471 request
  113. end
  114. 23 def select_connection(connection, selector)
  115. 6231 selector.register(connection)
  116. end
  117. 23 alias_method :select_resolver, :select_connection
  118. 23 def deselect_connection(connection, selector, cloned = false)
  119. 5832 selector.deregister(connection)
  120. # when connections coalesce
  121. 5832 return if connection.state == :idle
  122. 5801 return if cloned
  123. 5795 return if @closing && connection.state == :closed
  124. 5789 @pool.checkin_connection(connection)
  125. end
  126. 23 def deselect_resolver(resolver, selector)
  127. 271 selector.deregister(resolver)
  128. 271 return if @closing && resolver.closed?
  129. 271 @pool.checkin_resolver(resolver)
  130. end
  131. 23 def try_clone_connection(connection, selector, family)
  132. 353 connection.family ||= family
  133. 353 return connection if connection.family == family
  134. new_connection = connection.class.new(connection.origin, connection.options)
  135. new_connection.family = family
  136. new_connection.current_session = self
  137. new_connection.current_selector = selector
  138. connection.once(:tcp_open) { new_connection.force_reset(true) }
  139. connection.once(:connect_error) do |err|
  140. if new_connection.connecting?
  141. new_connection.merge(connection)
  142. connection.emit(:cloned, new_connection)
  143. connection.force_reset(true)
  144. else
  145. connection.__send__(:handle_error, err)
  146. end
  147. end
  148. new_connection.once(:tcp_open) do |new_conn|
  149. if new_conn != connection
  150. new_conn.merge(connection)
  151. connection.force_reset(true)
  152. end
  153. end
  154. new_connection.once(:connect_error) do |err|
  155. if connection.connecting?
  156. # main connection has the requests
  157. connection.merge(new_connection)
  158. new_connection.emit(:cloned, connection)
  159. new_connection.force_reset(true)
  160. else
  161. new_connection.__send__(:handle_error, err)
  162. end
  163. end
  164. do_init_connection(new_connection, selector)
  165. new_connection
  166. end
  167. # returns the HTTPX::Connection through which the +request+ should be sent through.
  168. 23 def find_connection(request_uri, selector, options)
  169. 6757 if (connection = selector.find_connection(request_uri, options))
  170. 1011 return connection
  171. end
  172. 5746 connection = @pool.checkout_connection(request_uri, options)
  173. 5728 connection.current_session = self
  174. 5728 connection.current_selector = selector
  175. 5728 case connection.state
  176. when :idle
  177. 5219 do_init_connection(connection, selector)
  178. when :open
  179. 50 select_connection(connection, selector) if options.io
  180. when :closed
  181. 454 connection.idling
  182. 454 select_connection(connection, selector)
  183. when :closing
  184. connection.once(:close) do
  185. connection.idling
  186. select_connection(connection, selector)
  187. end
  188. end
  189. 5678 connection
  190. end
  191. 23 private
  192. 23 def deactivate(selector)
  193. 297 selector.each_connection do |connection|
  194. 255 connection.deactivate
  195. 255 deselect_connection(connection, selector) if connection.state == :inactive
  196. end
  197. end
  198. # callback executed when a response for a given request has been received.
  199. 23 def on_response(request, response)
  200. 6683 @responses[request] = response
  201. end
  202. # callback executed when an HTTP/2 promise frame has been received.
  203. 23 def on_promise(_, stream)
  204. 6 log(level: 2) { "#{stream.id}: refusing stream!" }
  205. 6 stream.refuse
  206. end
  207. # returns the corresponding HTTP::Response to the given +request+ if it has been received.
  208. 23 def fetch_response(request, _selector, _options)
  209. 3349354 @responses.delete(request)
  210. end
  211. # sends the +request+ to the corresponding HTTPX::Connection
  212. 23 def send_request(request, selector, options = request.options)
  213. 895 error = begin
  214. 6704 catch(:resolve_error) do
  215. 6704 connection = find_connection(request.uri, selector, options)
  216. 6618 connection.send(request)
  217. end
  218. rescue StandardError => e
  219. 24 e
  220. end
  221. 6699 return unless error && error.is_a?(Exception)
  222. 86 if error.is_a?(Error)
  223. 86 request.emit(:response, ErrorResponse.new(request, error))
  224. else
  225. raise error if selector.empty?
  226. end
  227. end
  228. # returns a set of HTTPX::Request objects built from the given +args+ and +options+.
  229. 23 def build_requests(*args, params)
  230. 4906 requests = if args.size == 1
  231. 59 reqs = args.first
  232. 59 reqs.map do |verb, uri, ps = EMPTY_HASH|
  233. 118 request_params = params
  234. 118 request_params = request_params.merge(ps) unless ps.empty?
  235. 118 build_request(verb, uri, request_params)
  236. end
  237. else
  238. 4847 verb, uris = args
  239. 4847 if uris.respond_to?(:each)
  240. 4667 uris.enum_for(:each).map do |uri, ps = EMPTY_HASH|
  241. 5375 request_params = params
  242. 5375 request_params = request_params.merge(ps) unless ps.empty?
  243. 5375 build_request(verb, uri, request_params)
  244. end
  245. else
  246. 180 [build_request(verb, uris, params)]
  247. end
  248. end
  249. 4885 raise ArgumentError, "wrong number of URIs (given 0, expect 1..+1)" if requests.empty?
  250. 4885 requests
  251. end
  252. 23 def set_request_callbacks(request)
  253. 6471 request.on(:response, &method(:on_response).curry(2)[request])
  254. 6471 request.on(:promise, &method(:on_promise))
  255. end
  256. 23 def do_init_connection(connection, selector)
  257. 5219 resolve_connection(connection, selector) unless connection.family
  258. end
  259. # sends an array of HTTPX::Request +requests+, returns the respective array of HTTPX::Response objects.
  260. 23 def send_requests(*requests)
  261. 10215 selector = get_current_selector { Selector.new }
  262. 779 begin
  263. 5356 _send_requests(requests, selector)
  264. 5351 receive_requests(requests, selector)
  265. ensure
  266. 5356 unless @wrapped
  267. 4864 if @persistent
  268. 296 deactivate(selector)
  269. else
  270. 4568 close(selector)
  271. end
  272. end
  273. end
  274. end
  275. # sends an array of HTTPX::Request objects
  276. 23 def _send_requests(requests, selector)
  277. 5356 requests.each do |request|
  278. 6112 send_request(request, selector)
  279. end
  280. end
  281. # returns the array of HTTPX::Response objects corresponding to the array of HTTPX::Request +requests+.
  282. 23 def receive_requests(requests, selector)
  283. # @type var responses: Array[response]
  284. 5351 responses = []
  285. # guarantee ordered responses
  286. 5351 loop do
  287. 6113 request = requests.first
  288. 6113 return responses unless request
  289. 3348494 catch(:coalesced) { selector.next_tick } until (response = fetch_response(request, selector, request.options))
  290. 6029 request.emit(:complete, response)
  291. 6029 responses << response
  292. 6029 requests.shift
  293. 6029 break if requests.empty?
  294. 762 next unless selector.empty?
  295. # in some cases, the pool of connections might have been drained because there was some
  296. # handshake error, and the error responses have already been emitted, but there was no
  297. # opportunity to traverse the requests, hence we're returning only a fraction of the errors
  298. # we were supposed to. This effectively fetches the existing responses and return them.
  299. while (request = requests.shift)
  300. response = fetch_response(request, selector, request.options)
  301. request.emit(:complete, response) if response
  302. responses << response
  303. end
  304. break
  305. end
  306. 5267 responses
  307. end
  308. 23 def resolve_connection(connection, selector)
  309. 5239 if connection.addresses || connection.open?
  310. #
  311. # there are two cases in which we want to activate initialization of
  312. # connection immediately:
  313. #
  314. # 1. when the connection already has addresses, i.e. it doesn't need to
  315. # resolve a name (not the same as name being an IP, yet)
  316. # 2. when the connection is initialized with an external already open IO.
  317. #
  318. 157 connection.once(:connect_error, &connection.method(:handle_error))
  319. 157 on_resolver_connection(connection, selector)
  320. 157 return
  321. end
  322. 5082 resolver = find_resolver_for(connection, selector)
  323. 5082 resolver.early_resolve(connection) || resolver.lazy_resolve(connection)
  324. end
  325. 23 def on_resolver_connection(connection, selector)
  326. 5226 from_pool = false
  327. 5226 found_connection = selector.find_mergeable_connection(connection) || begin
  328. 5203 from_pool = true
  329. 5203 @pool.checkout_mergeable_connection(connection)
  330. end
  331. 5226 return select_connection(connection, selector) unless found_connection
  332. 23 if found_connection.open?
  333. 22 coalesce_connections(found_connection, connection, selector, from_pool)
  334. else
  335. 1 found_connection.once(:open) do
  336. 1 coalesce_connections(found_connection, connection, selector, from_pool)
  337. end
  338. end
  339. end
  340. 23 def on_resolver_close(resolver, selector)
  341. 271 return if resolver.closed?
  342. 271 deselect_resolver(resolver, selector)
  343. 271 resolver.close unless resolver.closed?
  344. end
  345. 23 def find_resolver_for(connection, selector)
  346. 5082 resolver = selector.find_resolver(connection.options)
  347. 5082 unless resolver
  348. 5080 resolver = @pool.checkout_resolver(connection.options)
  349. 5080 resolver.current_session = self
  350. 5080 resolver.current_selector = selector
  351. end
  352. 5082 resolver
  353. end
  354. # coalesces +conn2+ into +conn1+. if +conn1+ was loaded from the connection pool
  355. # (it is known via +from_pool+), then it adds its to the +selector+.
  356. 23 def coalesce_connections(conn1, conn2, selector, from_pool)
  357. 23 unless conn1.coalescable?(conn2)
  358. 12 select_connection(conn2, selector)
  359. 12 @pool.checkin_connection(conn1) if from_pool
  360. 12 return false
  361. end
  362. 11 conn2.emit(:tcp_open, conn1)
  363. 11 conn1.merge(conn2)
  364. 11 conn2.coalesced_connection = conn1
  365. 11 select_connection(conn1, selector) if from_pool
  366. 11 deselect_connection(conn2, selector)
  367. 11 true
  368. end
  369. 23 def get_current_selector
  370. 5751 selector_store[self] || (yield if block_given?)
  371. end
  372. 23 def set_current_selector(selector)
  373. 1082 if selector
  374. 687 selector_store[self] = selector
  375. else
  376. 395 selector_store.delete(self)
  377. end
  378. end
  379. 23 def selector_store
  380. 6833 th_current = Thread.current
  381. 6833 th_current.thread_variable_get(:httpx_persistent_selector_store) || begin
  382. 85 {}.compare_by_identity.tap do |store|
  383. 85 th_current.thread_variable_set(:httpx_persistent_selector_store, store)
  384. end
  385. end
  386. end
  387. 23 @default_options = Options.new
  388. 23 @default_options.freeze
  389. 23 @plugins = []
  390. 23 class << self
  391. 23 attr_reader :default_options
  392. 23 def inherited(klass)
  393. 4021 super
  394. 4021 klass.instance_variable_set(:@default_options, @default_options)
  395. 4021 klass.instance_variable_set(:@plugins, @plugins.dup)
  396. 4021 klass.instance_variable_set(:@callbacks, @callbacks.dup)
  397. end
  398. # returns a new HTTPX::Session instance, with the plugin pointed by +pl+ loaded.
  399. #
  400. # session_with_retries = session.plugin(:retries)
  401. # session_with_custom = session.plugin(CustomPlugin)
  402. #
  403. 23 def plugin(pl, options = nil, &block)
  404. # raise Error, "Cannot add a plugin to a frozen config" if frozen?
  405. 5439 pl = Plugins.load_plugin(pl) if pl.is_a?(Symbol)
  406. 5439 if !@plugins.include?(pl)
  407. 5249 @plugins << pl
  408. 5249 pl.load_dependencies(self, &block) if pl.respond_to?(:load_dependencies)
  409. 5249 @default_options = @default_options.dup
  410. 5249 include(pl::InstanceMethods) if defined?(pl::InstanceMethods)
  411. 5249 extend(pl::ClassMethods) if defined?(pl::ClassMethods)
  412. 5249 opts = @default_options
  413. 5249 opts.extend_with_plugin_classes(pl)
  414. 5249 if defined?(pl::OptionsMethods)
  415. 2161 (pl::OptionsMethods.instance_methods - Object.instance_methods).each do |meth|
  416. 6217 opts.options_class.method_added(meth)
  417. end
  418. 2161 @default_options = opts.options_class.new(opts)
  419. end
  420. 5249 @default_options = pl.extra_options(@default_options) if pl.respond_to?(:extra_options)
  421. 5249 @default_options = @default_options.merge(options) if options
  422. 5249 pl.configure(self, &block) if pl.respond_to?(:configure)
  423. 5249 @default_options.freeze
  424. 189 elsif options
  425. # this can happen when two plugins are loaded, an one of them calls the other under the hood,
  426. # albeit changing some default.
  427. 12 @default_options = pl.extra_options(@default_options) if pl.respond_to?(:extra_options)
  428. 12 @default_options = @default_options.merge(options) if options
  429. 12 @default_options.freeze
  430. end
  431. 5439 self
  432. end
  433. end
  434. end
  435. # session may be overridden by certain adapters.
  436. 23 S = Session
  437. end

lib/httpx/session_extensions.rb

100.0% lines covered

14 relevant lines. 14 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 23 module HTTPX
  3. 23 unless ENV.keys.grep(/\Ahttps?_proxy\z/i).empty?
  4. 1 proxy_session = plugin(:proxy)
  5. 1 remove_const(:Session)
  6. 1 const_set(:Session, proxy_session.class)
  7. # redefine the default options static var, which needs to
  8. # refresh options_class
  9. 1 options = proxy_session.class.default_options.to_hash
  10. 1 original_verbosity = $VERBOSE
  11. 1 $VERBOSE = nil
  12. 1 const_set(:Options, proxy_session.class.default_options.options_class)
  13. 1 options[:options_class] = Class.new(options[:options_class])
  14. 1 options.freeze
  15. 1 Options.send(:const_set, :DEFAULT_OPTIONS, options)
  16. 1 Session.instance_variable_set(:@default_options, Options.new(options))
  17. 1 $VERBOSE = original_verbosity
  18. end
  19. skipped # :nocov:
  20. skipped if Session.default_options.debug_level > 2
  21. skipped proxy_session = plugin(:internal_telemetry)
  22. skipped remove_const(:Session)
  23. skipped const_set(:Session, proxy_session.class)
  24. skipped end
  25. skipped # :nocov:
  26. end

lib/httpx/timers.rb

100.0% lines covered

57 relevant lines. 57 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 23 module HTTPX
  3. 23 class Timers
  4. 23 def initialize
  5. 5409 @intervals = []
  6. end
  7. 23 def after(interval_in_secs, cb = nil, &blk)
  8. 33333 return unless interval_in_secs
  9. 33333 callback = cb || blk
  10. # I'm assuming here that most requests will have the same
  11. # request timeout, as in most cases they share common set of
  12. # options. A user setting different request timeouts for 100s of
  13. # requests will already have a hard time dealing with that.
  14. 61188 unless (interval = @intervals.find { |t| t.interval == interval_in_secs })
  15. 6455 interval = Interval.new(interval_in_secs)
  16. 12658 interval.on_empty { @intervals.delete(interval) }
  17. 6455 @intervals << interval
  18. 6455 @intervals.sort!
  19. end
  20. 33333 interval << callback
  21. 33333 @next_interval_at = nil
  22. 33333 interval
  23. end
  24. 23 def wait_interval
  25. 3345336 return if @intervals.empty?
  26. 3329743 @next_interval_at = Utils.now
  27. 3329743 @intervals.first.interval
  28. end
  29. 23 def fire(error = nil)
  30. 3345167 raise error if error && error.timeout != @intervals.first
  31. 3345167 return if @intervals.empty? || !@next_interval_at
  32. 3324917 elapsed_time = Utils.elapsed_time(@next_interval_at)
  33. 6649847 @intervals = @intervals.drop_while { |interval| interval.elapse(elapsed_time) <= 0 }
  34. 3324917 @next_interval_at = nil if @intervals.empty?
  35. end
  36. 23 class Interval
  37. 23 include Comparable
  38. 23 attr_reader :interval
  39. 23 def initialize(interval)
  40. 6455 @interval = interval
  41. 6455 @callbacks = []
  42. 6455 @on_empty = nil
  43. end
  44. 23 def on_empty(&blk)
  45. 6455 @on_empty = blk
  46. end
  47. 23 def <=>(other)
  48. 555 @interval <=> other.interval
  49. end
  50. 23 def ==(other)
  51. 1844 return @interval == other if other.is_a?(Numeric)
  52. 1844 @interval == other.to_f # rubocop:disable Lint/FloatComparison
  53. end
  54. 23 def to_f
  55. 1844 Float(@interval)
  56. end
  57. 23 def <<(callback)
  58. 33333 @callbacks << callback
  59. end
  60. 23 def delete(callback)
  61. 49288 @callbacks.delete(callback)
  62. 49288 @on_empty.call if @callbacks.empty?
  63. end
  64. 23 def no_callbacks?
  65. 49288 @callbacks.empty?
  66. end
  67. 23 def elapsed?
  68. 930 @interval <= 0
  69. end
  70. 23 def elapse(elapsed)
  71. 3324930 @interval -= elapsed
  72. 3324930 if @interval <= 0
  73. 416 cb = @callbacks.dup
  74. 416 cb.each(&:call)
  75. end
  76. 3324930 @interval
  77. end
  78. end
  79. 23 private_constant :Interval
  80. end
  81. end

lib/httpx/transcoder.rb

100.0% lines covered

52 relevant lines. 52 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 23 module HTTPX
  3. 23 module Transcoder
  4. 23 module_function
  5. 23 def normalize_keys(key, value, cond = nil, &block)
  6. 2570 if cond && cond.call(value)
  7. 803 block.call(key.to_s, value)
  8. 1766 elsif value.respond_to?(:to_ary)
  9. 341 if value.empty?
  10. 96 block.call("#{key}[]")
  11. else
  12. 245 value.to_ary.each do |element|
  13. 394 normalize_keys("#{key}[]", element, cond, &block)
  14. end
  15. end
  16. 1425 elsif value.respond_to?(:to_hash)
  17. 384 value.to_hash.each do |child_key, child_value|
  18. 384 normalize_keys("#{key}[#{child_key}]", child_value, cond, &block)
  19. end
  20. else
  21. 1042 block.call(key.to_s, value)
  22. end
  23. end
  24. # based on https://github.com/rack/rack/blob/d15dd728440710cfc35ed155d66a98dc2c07ae42/lib/rack/query_parser.rb#L82
  25. 23 def normalize_query(params, name, v, depth)
  26. 138 raise Error, "params depth surpasses what's supported" if depth <= 0
  27. 138 name =~ /\A[\[\]]*([^\[\]]+)\]*/
  28. 138 k = Regexp.last_match(1) || ""
  29. 138 after = Regexp.last_match ? Regexp.last_match.post_match : ""
  30. 138 if k.empty?
  31. 12 return Array(v) if !v.empty? && name == "[]"
  32. 6 return
  33. end
  34. 126 case after
  35. when ""
  36. 42 params[k] = v
  37. when "["
  38. 6 params[name] = v
  39. when "[]"
  40. 12 params[k] ||= []
  41. 12 raise Error, "expected Array (got #{params[k].class}) for param '#{k}'" unless params[k].is_a?(Array)
  42. 12 params[k] << v
  43. when /^\[\]\[([^\[\]]+)\]$/, /^\[\](.+)$/
  44. 24 child_key = Regexp.last_match(1)
  45. 24 params[k] ||= []
  46. 24 raise Error, "expected Array (got #{params[k].class}) for param '#{k}'" unless params[k].is_a?(Array)
  47. 24 if params[k].last.is_a?(Hash) && !params_hash_has_key?(params[k].last, child_key)
  48. 6 normalize_query(params[k].last, child_key, v, depth - 1)
  49. else
  50. 18 params[k] << normalize_query({}, child_key, v, depth - 1)
  51. end
  52. else
  53. 42 params[k] ||= {}
  54. 42 raise Error, "expected Hash (got #{params[k].class}) for param '#{k}'" unless params[k].is_a?(Hash)
  55. 42 params[k] = normalize_query(params[k], after, v, depth - 1)
  56. end
  57. 126 params
  58. end
  59. 23 def params_hash_has_key?(hash, key)
  60. 12 return false if key.include?("[]")
  61. 12 key.split(/[\[\]]+/).inject(hash) do |h, part|
  62. 12 next h if part == ""
  63. 12 return false unless h.is_a?(Hash) && h.key?(part)
  64. 6 h[part]
  65. end
  66. 6 true
  67. end
  68. end
  69. end
  70. 23 require "httpx/transcoder/body"
  71. 23 require "httpx/transcoder/form"
  72. 23 require "httpx/transcoder/json"
  73. 23 require "httpx/transcoder/chunker"
  74. 23 require "httpx/transcoder/deflate"
  75. 23 require "httpx/transcoder/gzip"

lib/httpx/transcoder/body.rb

100.0% lines covered

33 relevant lines. 33 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 23 require "forwardable"
  3. 23 module HTTPX::Transcoder
  4. 23 module Body
  5. 23 class Error < HTTPX::Error; end
  6. 23 module_function
  7. 23 class Encoder
  8. 23 extend Forwardable
  9. 23 def_delegator :@raw, :to_s
  10. 23 def_delegator :@raw, :==
  11. 23 def initialize(body)
  12. 968 @raw = body
  13. end
  14. 23 def bytesize
  15. 3602 if @raw.respond_to?(:bytesize)
  16. 1871 @raw.bytesize
  17. 1730 elsif @raw.respond_to?(:to_ary)
  18. 717 @raw.sum(&:bytesize)
  19. 1013 elsif @raw.respond_to?(:size)
  20. 600 @raw.size || Float::INFINITY
  21. 413 elsif @raw.respond_to?(:length)
  22. 192 @raw.length || Float::INFINITY
  23. 221 elsif @raw.respond_to?(:each)
  24. 216 Float::INFINITY
  25. else
  26. 6 raise Error, "cannot determine size of body: #{@raw.inspect}"
  27. end
  28. end
  29. 23 def content_type
  30. 932 "application/octet-stream"
  31. end
  32. 23 private
  33. 23 def respond_to_missing?(meth, *args)
  34. 3553 @raw.respond_to?(meth, *args) || super
  35. end
  36. 23 def method_missing(meth, *args, &block)
  37. 866 return super unless @raw.respond_to?(meth)
  38. 866 @raw.__send__(meth, *args, &block)
  39. end
  40. end
  41. 23 def encode(body)
  42. 968 Encoder.new(body)
  43. end
  44. end
  45. end

lib/httpx/transcoder/chunker.rb

100.0% lines covered

66 relevant lines. 66 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 23 require "forwardable"
  3. 23 module HTTPX::Transcoder
  4. 23 module Chunker
  5. 23 class Error < HTTPX::Error; end
  6. 23 CRLF = "\r\n".b
  7. 23 class Encoder
  8. 23 extend Forwardable
  9. 23 def initialize(body)
  10. 72 @raw = body
  11. end
  12. 23 def each
  13. 72 return enum_for(__method__) unless block_given?
  14. 72 @raw.each do |chunk|
  15. 336 yield "#{chunk.bytesize.to_s(16)}#{CRLF}#{chunk}#{CRLF}"
  16. end
  17. 72 yield "0#{CRLF}"
  18. end
  19. 23 def respond_to_missing?(meth, *args)
  20. 120 @raw.respond_to?(meth, *args) || super
  21. end
  22. end
  23. 23 class Decoder
  24. 23 extend Forwardable
  25. 23 def_delegator :@buffer, :empty?
  26. 23 def_delegator :@buffer, :<<
  27. 23 def_delegator :@buffer, :clear
  28. 23 def initialize(buffer, trailers = false)
  29. 86 @buffer = buffer
  30. 86 @chunk_buffer = "".b
  31. 86 @finished = false
  32. 86 @state = :length
  33. 86 @trailers = trailers
  34. end
  35. 23 def to_s
  36. 80 @buffer
  37. end
  38. 23 def each
  39. 146 loop do
  40. 894 case @state
  41. when :length
  42. 256 index = @buffer.index(CRLF)
  43. 256 return unless index && index.positive?
  44. # Read hex-length
  45. 256 hexlen = @buffer.byteslice(0, index)
  46. 256 @buffer = @buffer.byteslice(index..-1) || "".b
  47. 256 hexlen[/\h/] || raise(Error, "wrong chunk size line: #{hexlen}")
  48. 256 @chunk_length = hexlen.hex
  49. # check if is last chunk
  50. 256 @finished = @chunk_length.zero?
  51. 256 nextstate(:crlf)
  52. when :crlf
  53. 426 crlf_size = @finished && !@trailers ? 4 : 2
  54. # consume CRLF
  55. 426 return if @buffer.bytesize < crlf_size
  56. 426 raise Error, "wrong chunked encoding format" unless @buffer.start_with?(CRLF * (crlf_size / 2))
  57. 426 @buffer = @buffer.byteslice(crlf_size..-1)
  58. 426 if @chunk_length.nil?
  59. 170 nextstate(:length)
  60. else
  61. 256 return if @finished
  62. 176 nextstate(:data)
  63. end
  64. when :data
  65. 212 chunk = @buffer.byteslice(0, @chunk_length)
  66. 212 @buffer = @buffer.byteslice(@chunk_length..-1) || "".b
  67. 212 @chunk_buffer << chunk
  68. 212 @chunk_length -= chunk.bytesize
  69. 212 if @chunk_length.zero?
  70. 176 yield @chunk_buffer unless @chunk_buffer.empty?
  71. 170 @chunk_buffer.clear
  72. 170 @chunk_length = nil
  73. 170 nextstate(:crlf)
  74. end
  75. end
  76. 808 break if @buffer.empty?
  77. end
  78. end
  79. 23 def finished?
  80. 140 @finished
  81. end
  82. 23 private
  83. 23 def nextstate(state)
  84. 772 @state = state
  85. end
  86. end
  87. 23 module_function
  88. 23 def encode(chunks)
  89. 72 Encoder.new(chunks)
  90. end
  91. end
  92. end

lib/httpx/transcoder/deflate.rb

100.0% lines covered

20 relevant lines. 20 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 23 require "zlib"
  3. 23 require_relative "utils/deflater"
  4. 23 module HTTPX
  5. 23 module Transcoder
  6. 23 module Deflate
  7. 23 class Deflater < Transcoder::Deflater
  8. 23 def deflate(chunk)
  9. 54 @deflater ||= Zlib::Deflate.new
  10. 54 if chunk.nil?
  11. 36 unless @deflater.closed?
  12. 18 last = @deflater.finish
  13. 18 @deflater.close
  14. 18 last.empty? ? nil : last
  15. end
  16. else
  17. 18 @deflater.deflate(chunk)
  18. end
  19. end
  20. end
  21. 23 module_function
  22. 23 def encode(body)
  23. 18 Deflater.new(body)
  24. end
  25. 23 def decode(response, bytesize: nil)
  26. 12 bytesize ||= response.headers.key?("content-length") ? response.headers["content-length"].to_i : Float::INFINITY
  27. 12 GZIP::Inflater.new(bytesize)
  28. end
  29. end
  30. end
  31. end

lib/httpx/transcoder/form.rb

100.0% lines covered

42 relevant lines. 42 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 23 require "forwardable"
  3. 23 require "uri"
  4. 23 require_relative "multipart"
  5. 23 module HTTPX
  6. 23 module Transcoder
  7. 23 module Form
  8. 23 module_function
  9. 23 PARAM_DEPTH_LIMIT = 32
  10. 23 class Encoder
  11. 23 extend Forwardable
  12. 23 def_delegator :@raw, :to_s
  13. 23 def_delegator :@raw, :to_str
  14. 23 def_delegator :@raw, :bytesize
  15. 23 def_delegator :@raw, :==
  16. 23 def initialize(form)
  17. 533 @raw = form.each_with_object("".b) do |(key, val), buf|
  18. 893 HTTPX::Transcoder.normalize_keys(key, val) do |k, v|
  19. 1042 buf << "&" unless buf.empty?
  20. 1042 buf << URI.encode_www_form_component(k)
  21. 1042 buf << "=#{URI.encode_www_form_component(v.to_s)}" unless v.nil?
  22. end
  23. end
  24. end
  25. 23 def content_type
  26. 417 "application/x-www-form-urlencoded"
  27. end
  28. end
  29. 23 module Decoder
  30. 23 module_function
  31. 23 def call(response, *)
  32. 30 URI.decode_www_form(response.to_s).each_with_object({}) do |(field, value), params|
  33. 72 HTTPX::Transcoder.normalize_query(params, field, value, PARAM_DEPTH_LIMIT)
  34. end
  35. end
  36. end
  37. 23 def encode(form)
  38. 1249 if multipart?(form)
  39. 716 Multipart::Encoder.new(form)
  40. else
  41. 533 Encoder.new(form)
  42. end
  43. end
  44. 23 def decode(response)
  45. 48 content_type = response.content_type.mime_type
  46. 48 case content_type
  47. when "application/x-www-form-urlencoded"
  48. 30 Decoder
  49. when "multipart/form-data"
  50. 12 Multipart::Decoder.new(response)
  51. else
  52. 6 raise Error, "invalid form mime type (#{content_type})"
  53. end
  54. end
  55. 23 def multipart?(data)
  56. 1249 data.any? do |_, v|
  57. 1657 Multipart::MULTIPART_VALUE_COND.call(v) ||
  58. 1277 (v.respond_to?(:to_ary) && v.to_ary.any?(&Multipart::MULTIPART_VALUE_COND)) ||
  59. 1565 (v.respond_to?(:to_hash) && v.to_hash.any? { |_, e| Multipart::MULTIPART_VALUE_COND.call(e) })
  60. end
  61. end
  62. end
  63. end
  64. end

lib/httpx/transcoder/gzip.rb

100.0% lines covered

40 relevant lines. 40 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 23 require "zlib"
  3. 23 module HTTPX
  4. 23 module Transcoder
  5. 23 module GZIP
  6. 23 class Deflater < Transcoder::Deflater
  7. 23 def initialize(body)
  8. 38 @compressed_chunk = "".b
  9. 38 super
  10. end
  11. 23 def deflate(chunk)
  12. 76 @deflater ||= Zlib::GzipWriter.new(self)
  13. 76 if chunk.nil?
  14. 38 unless @deflater.closed?
  15. 38 @deflater.flush
  16. 38 @deflater.close
  17. 38 compressed_chunk
  18. end
  19. else
  20. 38 @deflater.write(chunk)
  21. 38 compressed_chunk
  22. end
  23. end
  24. 23 private
  25. 23 def write(chunk)
  26. 114 @compressed_chunk << chunk
  27. end
  28. 23 def compressed_chunk
  29. 76 @compressed_chunk.dup
  30. ensure
  31. 76 @compressed_chunk.clear
  32. end
  33. end
  34. 23 class Inflater
  35. 23 def initialize(bytesize)
  36. 132 @inflater = Zlib::Inflate.new(Zlib::MAX_WBITS + 32)
  37. 132 @bytesize = bytesize
  38. end
  39. 23 def call(chunk)
  40. 387 buffer = @inflater.inflate(chunk)
  41. 387 @bytesize -= chunk.bytesize
  42. 387 if @bytesize <= 0
  43. 84 buffer << @inflater.finish
  44. 84 @inflater.close
  45. end
  46. 387 buffer
  47. end
  48. end
  49. 23 module_function
  50. 23 def encode(body)
  51. 38 Deflater.new(body)
  52. end
  53. 23 def decode(response, bytesize: nil)
  54. 120 bytesize ||= response.headers.key?("content-length") ? response.headers["content-length"].to_i : Float::INFINITY
  55. 120 Inflater.new(bytesize)
  56. end
  57. end
  58. end
  59. end

lib/httpx/transcoder/json.rb

100.0% lines covered

33 relevant lines. 33 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 23 require "forwardable"
  3. 23 module HTTPX::Transcoder
  4. 23 module JSON
  5. 23 module_function
  6. 23 JSON_REGEX = %r{
  7. \b
  8. application/
  9. # optional vendor specific type
  10. (?:
  11. # token as per https://datatracker.ietf.org/doc/html/rfc7230#section-3.2.6
  12. [!#$%&'*+\-.^_`|~0-9a-z]+
  13. # literal plus sign
  14. \+
  15. )?
  16. json
  17. \b
  18. }ix.freeze
  19. 23 class Encoder
  20. 23 extend Forwardable
  21. 23 def_delegator :@raw, :to_s
  22. 23 def_delegator :@raw, :bytesize
  23. 23 def_delegator :@raw, :==
  24. 23 def initialize(json)
  25. 63 @raw = JSON.json_dump(json)
  26. 63 @charset = @raw.encoding.name.downcase
  27. end
  28. 23 def content_type
  29. 63 "application/json; charset=#{@charset}"
  30. end
  31. end
  32. 23 def encode(json)
  33. 63 Encoder.new(json)
  34. end
  35. 23 def decode(response)
  36. 99 content_type = response.content_type.mime_type
  37. 99 raise HTTPX::Error, "invalid json mime type (#{content_type})" unless JSON_REGEX.match?(content_type)
  38. 87 method(:json_load)
  39. end
  40. # rubocop:disable Style/SingleLineMethods
  41. 23 if defined?(MultiJson)
  42. 4 def json_load(*args); MultiJson.load(*args); end
  43. 2 def json_dump(*args); MultiJson.dump(*args); end
  44. 21 elsif defined?(Oj)
  45. 4 def json_load(response, *args); Oj.load(response.to_s, *args); end
  46. 2 def json_dump(obj, options = {}); Oj.dump(obj, { mode: :compat }.merge(options)); end
  47. 20 elsif defined?(Yajl)
  48. 4 def json_load(response, *args); Yajl::Parser.new(*args).parse(response.to_s); end
  49. 2 def json_dump(*args); Yajl::Encoder.encode(*args); end
  50. else
  51. 20 require "json"
  52. 98 def json_load(*args); ::JSON.parse(*args); end
  53. 80 def json_dump(*args); ::JSON.dump(*args); end
  54. end
  55. # rubocop:enable Style/SingleLineMethods
  56. end
  57. end

lib/httpx/transcoder/multipart.rb

100.0% lines covered

10 relevant lines. 10 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 23 require_relative "multipart/encoder"
  3. 23 require_relative "multipart/decoder"
  4. 23 require_relative "multipart/part"
  5. 23 require_relative "multipart/mime_type_detector"
  6. 23 module HTTPX::Transcoder
  7. 23 module Multipart
  8. 23 MULTIPART_VALUE_COND = lambda do |value|
  9. 3670 value.respond_to?(:read) ||
  10. 2635 (value.respond_to?(:to_hash) &&
  11. value.key?(:body) &&
  12. 484 (value.key?(:filename) || value.key?(:content_type)))
  13. end
  14. end
  15. end

lib/httpx/transcoder/multipart/decoder.rb

93.83% lines covered

81 relevant lines. 76 lines covered and 5 lines missed.
    
  1. # frozen_string_literal: true
  2. 23 require "tempfile"
  3. 23 require "delegate"
  4. 23 module HTTPX
  5. 23 module Transcoder
  6. 23 module Multipart
  7. 23 class FilePart < SimpleDelegator
  8. 23 attr_reader :original_filename, :content_type
  9. 23 def initialize(filename, content_type)
  10. 24 @original_filename = filename
  11. 24 @content_type = content_type
  12. 24 @file = Tempfile.new("httpx", encoding: Encoding::BINARY, mode: File::RDWR)
  13. 24 super(@file)
  14. end
  15. end
  16. 23 class Decoder
  17. 23 include HTTPX::Utils
  18. 23 CRLF = "\r\n"
  19. 23 BOUNDARY_RE = /;\s*boundary=([^;]+)/i.freeze
  20. 23 MULTIPART_CONTENT_TYPE = /Content-Type: (.*)#{CRLF}/ni.freeze
  21. 23 MULTIPART_CONTENT_DISPOSITION = /Content-Disposition:.*;\s*name=(#{VALUE})/ni.freeze
  22. 23 MULTIPART_CONTENT_ID = /Content-ID:\s*([^#{CRLF}]*)/ni.freeze
  23. 23 WINDOW_SIZE = 2 << 14
  24. 23 def initialize(response)
  25. @boundary = begin
  26. 12 m = response.headers["content-type"].to_s[BOUNDARY_RE, 1]
  27. 12 raise Error, "no boundary declared in content-type header" unless m
  28. 12 m.strip
  29. end
  30. 12 @buffer = "".b
  31. 12 @parts = {}
  32. 12 @intermediate_boundary = "--#{@boundary}"
  33. 12 @state = :idle
  34. end
  35. 23 def call(response, *)
  36. 12 response.body.each do |chunk|
  37. 12 @buffer << chunk
  38. 12 parse
  39. end
  40. 12 raise Error, "invalid or unsupported multipart format" unless @buffer.empty?
  41. 12 @parts
  42. end
  43. 23 private
  44. 23 def parse
  45. 12 case @state
  46. when :idle
  47. 12 raise Error, "payload does not start with boundary" unless @buffer.start_with?("#{@intermediate_boundary}#{CRLF}")
  48. 12 @buffer = @buffer.byteslice(@intermediate_boundary.bytesize + 2..-1)
  49. 12 @state = :part_header
  50. when :part_header
  51. 36 idx = @buffer.index("#{CRLF}#{CRLF}")
  52. # raise Error, "couldn't parse part headers" unless idx
  53. 36 return unless idx
  54. 36 head = @buffer.byteslice(0..idx + 4 - 1)
  55. 36 @buffer = @buffer.byteslice(head.bytesize..-1)
  56. 36 content_type = head[MULTIPART_CONTENT_TYPE, 1]
  57. 66 if (name = head[MULTIPART_CONTENT_DISPOSITION, 1])
  58. 36 name = /\A"(.*)"\Z/ =~ name ? Regexp.last_match(1) : name.dup
  59. 36 name.gsub!(/\\(.)/, "\\1")
  60. 6 name
  61. else
  62. name = head[MULTIPART_CONTENT_ID, 1]
  63. end
  64. 36 filename = HTTPX::Utils.get_filename(head)
  65. 36 name = filename || +"#{content_type || "text/plain"}[]" if name.nil? || name.empty?
  66. 36 @current = name
  67. 36 @parts[name] = if filename
  68. 24 FilePart.new(filename, content_type)
  69. else
  70. 12 "".b
  71. end
  72. 36 @state = :part_body
  73. when :part_body
  74. 36 part = @parts[@current]
  75. 36 body_separator = if part.is_a?(FilePart)
  76. 24 "#{CRLF}#{CRLF}"
  77. else
  78. 12 CRLF
  79. end
  80. 36 idx = @buffer.index(body_separator)
  81. 36 if idx
  82. 36 payload = @buffer.byteslice(0..idx - 1)
  83. 36 @buffer = @buffer.byteslice(idx + body_separator.bytesize..-1)
  84. 36 part << payload
  85. 36 part.rewind if part.respond_to?(:rewind)
  86. 36 @state = :parse_boundary
  87. else
  88. part << @buffer
  89. @buffer.clear
  90. end
  91. when :parse_boundary
  92. 36 raise Error, "payload does not start with boundary" unless @buffer.start_with?(@intermediate_boundary)
  93. 36 @buffer = @buffer.byteslice(@intermediate_boundary.bytesize..-1)
  94. 36 if @buffer == "--"
  95. 12 @buffer.clear
  96. 12 @state = :done
  97. 12 return
  98. 23 elsif @buffer.start_with?(CRLF)
  99. 24 @buffer = @buffer.byteslice(2..-1)
  100. 24 @state = :part_header
  101. else
  102. return
  103. end
  104. when :done
  105. raise Error, "parsing should have been over by now"
  106. end until @buffer.empty?
  107. end
  108. end
  109. end
  110. end
  111. end

lib/httpx/transcoder/multipart/encoder.rb

100.0% lines covered

69 relevant lines. 69 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 23 module HTTPX
  3. 23 module Transcoder::Multipart
  4. 23 class Encoder
  5. 23 attr_reader :bytesize
  6. 23 def initialize(form)
  7. 716 @boundary = ("-" * 21) << SecureRandom.hex(21)
  8. 716 @part_index = 0
  9. 716 @buffer = "".b
  10. 716 @form = form
  11. 716 @parts = to_parts(form)
  12. end
  13. 23 def content_type
  14. 716 "multipart/form-data; boundary=#{@boundary}"
  15. end
  16. 23 def to_s
  17. 15 read
  18. ensure
  19. 15 rewind
  20. end
  21. 23 def read(length = nil, outbuf = nil)
  22. 2923 data = String(outbuf).clear.force_encoding(Encoding::BINARY) if outbuf
  23. 2923 data ||= "".b
  24. 2923 read_chunks(data, length)
  25. 2923 data unless length && data.empty?
  26. end
  27. 23 def rewind
  28. 39 form = @form.each_with_object([]) do |(key, val), aux|
  29. 39 if val.respond_to?(:path) && val.respond_to?(:reopen) && val.respond_to?(:closed?) && val.closed?
  30. 39 val = val.reopen(val.path, File::RDONLY)
  31. end
  32. 39 val.rewind if val.respond_to?(:rewind)
  33. 39 aux << [key, val]
  34. end
  35. 39 @form = form
  36. 39 @parts = to_parts(form)
  37. 39 @part_index = 0
  38. end
  39. 23 private
  40. 23 def to_parts(form)
  41. 755 @bytesize = 0
  42. 755 params = form.each_with_object([]) do |(key, val), aux|
  43. 899 Transcoder.normalize_keys(key, val, MULTIPART_VALUE_COND) do |k, v|
  44. 899 next if v.nil?
  45. 899 value, content_type, filename = Part.call(v)
  46. 899 header = header_part(k, content_type, filename)
  47. 899 @bytesize += header.size
  48. 899 aux << header
  49. 899 @bytesize += value.size
  50. 899 aux << value
  51. 899 delimiter = StringIO.new("\r\n")
  52. 899 @bytesize += delimiter.size
  53. 899 aux << delimiter
  54. end
  55. end
  56. 755 final_delimiter = StringIO.new("--#{@boundary}--\r\n")
  57. 755 @bytesize += final_delimiter.size
  58. 755 params << final_delimiter
  59. 755 params
  60. end
  61. 23 def header_part(key, content_type, filename)
  62. 899 header = "--#{@boundary}\r\n".b
  63. 899 header << "Content-Disposition: form-data; name=#{key.inspect}".b
  64. 899 header << "; filename=#{filename.inspect}" if filename
  65. 899 header << "\r\nContent-Type: #{content_type}\r\n\r\n"
  66. 899 StringIO.new(header)
  67. end
  68. 23 def read_chunks(buffer, length = nil)
  69. 2923 while @part_index < @parts.size
  70. 8243 chunk = read_from_part(length)
  71. 8243 next unless chunk
  72. 4831 buffer << chunk.force_encoding(Encoding::BINARY)
  73. 4831 next unless length
  74. 4776 length -= chunk.bytesize
  75. 4776 break if length.zero?
  76. end
  77. end
  78. # if there's a current part to read from, tries to read a chunk.
  79. 23 def read_from_part(max_length = nil)
  80. 8243 part = @parts[@part_index]
  81. 8243 chunk = part.read(max_length, @buffer)
  82. 8243 return chunk if chunk && !chunk.empty?
  83. 3412 part.close if part.respond_to?(:close)
  84. 3412 @part_index += 1
  85. 552 nil
  86. end
  87. end
  88. end
  89. end

lib/httpx/transcoder/multipart/mime_type_detector.rb

92.11% lines covered

38 relevant lines. 35 lines covered and 3 lines missed.
    
  1. # frozen_string_literal: true
  2. 23 module HTTPX
  3. 23 module Transcoder::Multipart
  4. 23 module MimeTypeDetector
  5. 23 module_function
  6. 23 DEFAULT_MIMETYPE = "application/octet-stream"
  7. # inspired by https://github.com/shrinerb/shrine/blob/master/lib/shrine/plugins/determine_mime_type.rb
  8. 23 if defined?(FileMagic)
  9. 1 MAGIC_NUMBER = 256 * 1024
  10. 1 def call(file, _)
  11. 1 return nil if file.eof? # FileMagic returns "application/x-empty" for empty files
  12. 1 mime = FileMagic.open(FileMagic::MAGIC_MIME_TYPE) do |filemagic|
  13. 1 filemagic.buffer(file.read(MAGIC_NUMBER))
  14. end
  15. 1 file.rewind
  16. 1 mime
  17. end
  18. 21 elsif defined?(Marcel)
  19. 1 def call(file, filename)
  20. 1 return nil if file.eof? # marcel returns "application/octet-stream" for empty files
  21. 1 Marcel::MimeType.for(file, name: filename)
  22. end
  23. 20 elsif defined?(MimeMagic)
  24. 1 def call(file, _)
  25. 1 mime = MimeMagic.by_magic(file)
  26. 1 mime.type if mime
  27. end
  28. 19 elsif system("which file", out: File::NULL)
  29. 20 require "open3"
  30. 20 def call(file, _)
  31. 511 return if file.eof? # file command returns "application/x-empty" for empty files
  32. 477 Open3.popen3(*%w[file --mime-type --brief -]) do |stdin, stdout, stderr, thread|
  33. 75 begin
  34. 477 ::IO.copy_stream(file, stdin.binmode)
  35. rescue Errno::EPIPE
  36. end
  37. 477 file.rewind
  38. 477 stdin.close
  39. 477 status = thread.value
  40. # call to file command failed
  41. 477 if status.nil? || !status.success?
  42. $stderr.print(stderr.read)
  43. else
  44. 477 output = stdout.read.strip
  45. 477 if output.include?("cannot open")
  46. $stderr.print(output)
  47. else
  48. 477 output
  49. end
  50. end
  51. end
  52. end
  53. else
  54. def call(_, _); end
  55. end
  56. end
  57. end
  58. end

lib/httpx/transcoder/multipart/part.rb

100.0% lines covered

18 relevant lines. 18 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 23 module HTTPX
  3. 23 module Transcoder::Multipart
  4. 23 module Part
  5. 23 module_function
  6. 23 def call(value)
  7. # take out specialized objects of the way
  8. 899 if value.respond_to?(:filename) && value.respond_to?(:content_type) && value.respond_to?(:read)
  9. 96 return value, value.content_type, value.filename
  10. end
  11. 803 content_type = filename = nil
  12. 803 if value.is_a?(Hash)
  13. 242 content_type = value[:content_type]
  14. 242 filename = value[:filename]
  15. 242 value = value[:body]
  16. end
  17. 803 value = value.open(File::RDONLY) if Object.const_defined?(:Pathname) && value.is_a?(Pathname)
  18. 803 if value.respond_to?(:path) && value.respond_to?(:read)
  19. # either a File, a Tempfile, or something else which has to quack like a file
  20. 515 filename ||= File.basename(value.path)
  21. 515 content_type ||= MimeTypeDetector.call(value, filename) || "application/octet-stream"
  22. 515 [value, content_type, filename]
  23. else
  24. 288 [StringIO.new(value.to_s), content_type || "text/plain", filename]
  25. end
  26. end
  27. end
  28. end
  29. end

lib/httpx/transcoder/utils/body_reader.rb

96.0% lines covered

25 relevant lines. 24 lines covered and 1 lines missed.
    
  1. # frozen_string_literal: true
  2. 23 require "stringio"
  3. 23 module HTTPX
  4. 23 module Transcoder
  5. 23 class BodyReader
  6. 23 def initialize(body)
  7. 171 @body = if body.respond_to?(:read)
  8. 16 body.rewind if body.respond_to?(:rewind)
  9. 16 body
  10. 154 elsif body.respond_to?(:each)
  11. 30 body.enum_for(:each)
  12. else
  13. 125 StringIO.new(body.to_s)
  14. end
  15. end
  16. 23 def bytesize
  17. 335 return @body.bytesize if @body.respond_to?(:bytesize)
  18. 305 Float::INFINITY
  19. end
  20. 23 def read(length = nil, outbuf = nil)
  21. 370 return @body.read(length, outbuf) if @body.respond_to?(:read)
  22. begin
  23. 70 chunk = @body.next
  24. 40 if outbuf
  25. 40 outbuf.clear.force_encoding(Encoding::BINARY)
  26. 40 outbuf << chunk
  27. else
  28. outbuf = chunk
  29. end
  30. 40 outbuf unless length && outbuf.empty?
  31. 24 rescue StopIteration
  32. end
  33. end
  34. 23 def close
  35. 38 @body.close if @body.respond_to?(:close)
  36. end
  37. end
  38. end
  39. end

lib/httpx/transcoder/utils/deflater.rb

100.0% lines covered

37 relevant lines. 37 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 23 require_relative "body_reader"
  3. 23 module HTTPX
  4. 23 module Transcoder
  5. 23 class Deflater
  6. 23 attr_reader :content_type
  7. 23 def initialize(body)
  8. 66 @content_type = body.content_type
  9. 66 @body = BodyReader.new(body)
  10. 66 @closed = false
  11. end
  12. 23 def bytesize
  13. 254 buffer_deflate!
  14. 254 @buffer.size
  15. end
  16. 23 def read(length = nil, outbuf = nil)
  17. 326 return @buffer.read(length, outbuf) if @buffer
  18. 188 return if @closed
  19. 150 chunk = @body.read(length)
  20. 150 compressed_chunk = deflate(chunk)
  21. 150 return unless compressed_chunk
  22. 122 if outbuf
  23. 110 outbuf.clear.force_encoding(Encoding::BINARY)
  24. 110 outbuf << compressed_chunk
  25. else
  26. 12 compressed_chunk
  27. end
  28. end
  29. 23 def close
  30. 38 return if @closed
  31. 38 @buffer.close if @buffer
  32. 38 @body.close
  33. 38 @closed = true
  34. end
  35. 23 def rewind
  36. 22 return unless @buffer
  37. 12 @buffer.rewind
  38. end
  39. 23 private
  40. # rubocop:disable Naming/MemoizedInstanceVariableName
  41. 23 def buffer_deflate!
  42. 254 return @buffer if defined?(@buffer)
  43. 66 buffer = Response::Buffer.new(
  44. threshold_size: Options::MAX_BODY_THRESHOLD_SIZE
  45. )
  46. 66 ::IO.copy_stream(self, buffer)
  47. 66 buffer.rewind if buffer.respond_to?(:rewind)
  48. 66 @buffer = buffer
  49. end
  50. # rubocop:enable Naming/MemoizedInstanceVariableName
  51. end
  52. end
  53. end

lib/httpx/utils.rb

100.0% lines covered

39 relevant lines. 39 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 23 module HTTPX
  3. 23 module Utils
  4. 23 using URIExtensions
  5. 23 TOKEN = %r{[^\s()<>,;:\\"/\[\]?=]+}.freeze
  6. 23 VALUE = /"(?:\\"|[^"])*"|#{TOKEN}/.freeze
  7. 23 FILENAME_REGEX = /\s*filename=(#{VALUE})/.freeze
  8. 23 FILENAME_EXTENSION_REGEX = /\s*filename\*=(#{VALUE})/.freeze
  9. 23 module_function
  10. 23 def now
  11. 3349742 Process.clock_gettime(Process::CLOCK_MONOTONIC)
  12. end
  13. 23 def elapsed_time(monotonic_timestamp)
  14. 3325820 Process.clock_gettime(Process::CLOCK_MONOTONIC) - monotonic_timestamp
  15. end
  16. # The value of this field can be either an HTTP-date or a number of
  17. # seconds to delay after the response is received.
  18. 23 def parse_retry_after(retry_after)
  19. # first: bet on it being an integer
  20. 48 Integer(retry_after)
  21. rescue ArgumentError
  22. # Then it's a datetime
  23. 12 time = Time.httpdate(retry_after)
  24. 12 time - Time.now
  25. end
  26. 23 def get_filename(header, _prefix_regex = nil)
  27. 66 filename = nil
  28. 66 case header
  29. when FILENAME_REGEX
  30. 42 filename = Regexp.last_match(1)
  31. 42 filename = Regexp.last_match(1) if filename =~ /^"(.*)"$/
  32. when FILENAME_EXTENSION_REGEX
  33. 12 filename = Regexp.last_match(1)
  34. 12 encoding, _, filename = filename.split("'", 3)
  35. end
  36. 66 return unless filename
  37. 102 filename = URI::DEFAULT_PARSER.unescape(filename) if filename.scan(/%.?.?/).all? { |s| /%[0-9a-fA-F]{2}/.match?(s) }
  38. 54 filename.scrub!
  39. 54 filename = filename.gsub(/\\(.)/, '\1') unless /\\[^\\"]/.match?(filename)
  40. 54 filename.force_encoding ::Encoding.find(encoding) if encoding
  41. 54 filename
  42. end
  43. 23 URIParser = URI::RFC2396_Parser.new
  44. 23 def to_uri(uri)
  45. 12824 return URI(uri) unless uri.is_a?(String) && !uri.ascii_only?
  46. 25 uri = URI(URIParser.escape(uri))
  47. 25 non_ascii_hostname = URIParser.unescape(uri.host)
  48. 25 non_ascii_hostname.force_encoding(Encoding::UTF_8)
  49. 25 idna_hostname = Punycode.encode_hostname(non_ascii_hostname)
  50. 25 uri.host = idna_hostname
  51. 24 uri.non_ascii_hostname = non_ascii_hostname
  52. 24 uri
  53. end
  54. end
  55. end