loading
Generated 2025-11-05T01:39:05+00:00

All Files ( 96.92% covered at 84273.38 hits/line )

107 files in total.
8012 relevant lines, 7765 lines covered and 247 lines missed. ( 96.92% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line
lib/httpx.rb 100.00 % 66 39 39 0 1006.85
lib/httpx/adapters/datadog.rb 86.98 % 359 169 147 22 42.51
lib/httpx/adapters/faraday.rb 98.76 % 303 161 159 2 137.03
lib/httpx/adapters/sentry.rb 100.00 % 121 62 62 0 109.68
lib/httpx/adapters/webmock.rb 100.00 % 175 93 93 0 96.04
lib/httpx/altsvc.rb 96.39 % 163 83 80 3 266.53
lib/httpx/buffer.rb 100.00 % 61 27 27 0 5357.11
lib/httpx/callbacks.rb 100.00 % 35 19 19 0 151544.05
lib/httpx/chainable.rb 95.45 % 106 44 42 2 1199.20
lib/httpx/connection.rb 94.98 % 967 478 454 24 141653.54
lib/httpx/connection/http1.rb 89.59 % 399 221 198 23 4000.76
lib/httpx/connection/http2.rb 95.54 % 467 269 257 12 242384.30
lib/httpx/domain_name.rb 95.56 % 145 45 43 2 249.38
lib/httpx/errors.rb 97.67 % 111 43 42 1 87.79
lib/httpx/extensions.rb 95.24 % 45 21 20 1 605.95
lib/httpx/headers.rb 100.00 % 176 71 71 0 19380.94
lib/httpx/io.rb 100.00 % 11 5 5 0 27.00
lib/httpx/io/ssl.rb 97.50 % 163 80 78 2 2778.30
lib/httpx/io/tcp.rb 92.19 % 239 128 118 10 7838.72
lib/httpx/io/udp.rb 100.00 % 62 35 35 0 370.89
lib/httpx/io/unix.rb 97.14 % 71 35 34 1 19.11
lib/httpx/loggable.rb 100.00 % 66 25 25 0 419271.84
lib/httpx/options.rb 98.47 % 483 196 193 3 28100.61
lib/httpx/parser/http1.rb 100.00 % 186 109 109 0 8101.89
lib/httpx/plugins/auth.rb 100.00 % 25 9 9 0 24.00
lib/httpx/plugins/auth/basic.rb 100.00 % 20 10 10 0 96.20
lib/httpx/plugins/auth/digest.rb 100.00 % 102 57 57 0 136.11
lib/httpx/plugins/auth/ntlm.rb 100.00 % 35 19 19 0 2.95
lib/httpx/plugins/auth/socks5.rb 100.00 % 22 11 11 0 23.82
lib/httpx/plugins/aws_sdk_authentication.rb 100.00 % 111 44 44 0 13.09
lib/httpx/plugins/aws_sigv4.rb 100.00 % 239 107 107 0 114.13
lib/httpx/plugins/basic_auth.rb 100.00 % 29 12 12 0 35.33
lib/httpx/plugins/brotli.rb 100.00 % 50 25 25 0 10.80
lib/httpx/plugins/callbacks.rb 92.42 % 141 66 61 5 129.98
lib/httpx/plugins/circuit_breaker.rb 100.00 % 147 65 65 0 76.74
lib/httpx/plugins/circuit_breaker/circuit.rb 100.00 % 100 47 47 0 60.55
lib/httpx/plugins/circuit_breaker/circuit_store.rb 100.00 % 53 23 23 0 98.78
lib/httpx/plugins/content_digest.rb 100.00 % 204 101 101 0 79.82
lib/httpx/plugins/cookies.rb 100.00 % 107 52 52 0 122.65
lib/httpx/plugins/cookies/cookie.rb 100.00 % 174 76 76 0 322.97
lib/httpx/plugins/cookies/jar.rb 100.00 % 95 46 46 0 260.39
lib/httpx/plugins/cookies/set_cookie_parser.rb 100.00 % 143 72 72 0 155.96
lib/httpx/plugins/digest_auth.rb 100.00 % 67 30 30 0 96.80
lib/httpx/plugins/expect.rb 100.00 % 120 57 57 0 87.46
lib/httpx/plugins/fiber_concurrency.rb 88.04 % 195 92 81 11 721.53
lib/httpx/plugins/follow_redirects.rb 100.00 % 233 109 109 0 171633.64
lib/httpx/plugins/grpc.rb 100.00 % 282 134 134 0 116.96
lib/httpx/plugins/grpc/call.rb 90.91 % 63 33 30 3 40.18
lib/httpx/plugins/grpc/grpc_encoding.rb 97.87 % 90 47 46 1 76.85
lib/httpx/plugins/grpc/message.rb 95.83 % 55 24 23 1 39.50
lib/httpx/plugins/h2c.rb 95.24 % 127 63 60 3 17.56
lib/httpx/plugins/ntlm_auth.rb 100.00 % 62 31 31 0 3.94
lib/httpx/plugins/oauth.rb 100.00 % 183 90 90 0 108.67
lib/httpx/plugins/persistent.rb 100.00 % 82 31 31 0 452.00
lib/httpx/plugins/proxy.rb 94.71 % 349 170 161 9 341.22
lib/httpx/plugins/proxy/http.rb 94.02 % 212 117 110 7 188.97
lib/httpx/plugins/proxy/socks4.rb 97.47 % 135 79 77 2 185.49
lib/httpx/plugins/proxy/socks5.rb 99.12 % 194 113 112 1 302.32
lib/httpx/plugins/proxy/ssh.rb 92.45 % 94 53 49 4 8.15
lib/httpx/plugins/push_promise.rb 100.00 % 81 41 41 0 10.54
lib/httpx/plugins/query.rb 100.00 % 35 14 14 0 10.71
lib/httpx/plugins/rate_limiter.rb 100.00 % 55 18 18 0 42.11
lib/httpx/plugins/response_cache.rb 100.00 % 344 148 148 0 201.34
lib/httpx/plugins/response_cache/file_store.rb 100.00 % 141 73 73 0 184.63
lib/httpx/plugins/response_cache/store.rb 100.00 % 33 16 16 0 166.50
lib/httpx/plugins/retries.rb 96.91 % 230 97 94 3 167854.05
lib/httpx/plugins/ssrf_filter.rb 100.00 % 145 61 61 0 124.30
lib/httpx/plugins/stream.rb 97.78 % 183 90 88 2 115.32
lib/httpx/plugins/stream_bidi.rb 97.90 % 324 143 140 3 89.18
lib/httpx/plugins/upgrade.rb 100.00 % 86 38 38 0 44.03
lib/httpx/plugins/upgrade/h2.rb 89.66 % 64 29 26 3 34.34
lib/httpx/plugins/webdav.rb 100.00 % 86 39 39 0 23.23
lib/httpx/plugins/xml.rb 100.00 % 76 34 34 0 84.94
lib/httpx/pmatch_extensions.rb 100.00 % 33 17 17 0 28.12
lib/httpx/pool.rb 100.00 % 224 103 103 0 4604.08
lib/httpx/punycode.rb 100.00 % 22 9 9 0 18.78
lib/httpx/request.rb 100.00 % 318 133 133 0 7156.38
lib/httpx/request/body.rb 100.00 % 158 68 68 0 3286.87
lib/httpx/resolver.rb 98.95 % 189 95 94 1 1822.71
lib/httpx/resolver/entry.rb 100.00 % 30 16 16 0 5058.19
lib/httpx/resolver/https.rb 92.26 % 272 155 143 12 54.80
lib/httpx/resolver/multi.rb 100.00 % 83 45 45 0 4955.76
lib/httpx/resolver/native.rb 93.69 % 555 317 297 20 1108.91
lib/httpx/resolver/resolver.rb 96.70 % 190 91 88 3 1529.88
lib/httpx/resolver/system.rb 95.42 % 279 153 146 7 97.11
lib/httpx/response.rb 100.00 % 304 114 114 0 2027.67
lib/httpx/response/body.rb 100.00 % 242 108 108 0 3154.33
lib/httpx/response/buffer.rb 96.67 % 115 60 58 2 1781.55
lib/httpx/selector.rb 94.24 % 297 139 131 8 1723056.15
lib/httpx/session.rb 94.93 % 602 296 281 15 97042.58
lib/httpx/session_extensions.rb 100.00 % 30 15 15 0 6.27
lib/httpx/timers.rb 93.94 % 133 66 62 4 2925715.27
lib/httpx/transcoder.rb 100.00 % 91 52 52 0 298.42
lib/httpx/transcoder/body.rb 100.00 % 43 26 26 0 1001.38
lib/httpx/transcoder/chunker.rb 100.00 % 115 66 66 0 215.38
lib/httpx/transcoder/deflate.rb 100.00 % 42 21 21 0 29.81
lib/httpx/transcoder/form.rb 100.00 % 68 35 35 0 248.89
lib/httpx/transcoder/gzip.rb 100.00 % 71 40 40 0 113.95
lib/httpx/transcoder/json.rb 100.00 % 71 33 33 0 47.12
lib/httpx/transcoder/multipart.rb 100.00 % 31 18 18 0 962.17
lib/httpx/transcoder/multipart/decoder.rb 93.90 % 141 82 77 5 29.78
lib/httpx/transcoder/multipart/encoder.rb 100.00 % 120 70 70 0 2076.74
lib/httpx/transcoder/multipart/mime_type_detector.rb 92.11 % 78 38 35 3 196.63
lib/httpx/transcoder/multipart/part.rb 100.00 % 35 18 18 0 545.17
lib/httpx/transcoder/utils/body_reader.rb 95.83 % 45 24 23 1 103.75
lib/httpx/transcoder/utils/deflater.rb 100.00 % 74 36 36 0 100.61
lib/httpx/utils.rb 100.00 % 75 39 39 0 470993.10

lib/httpx.rb

100.0% lines covered

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

lib/httpx/adapters/datadog.rb

86.98% lines covered

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

lib/httpx/adapters/faraday.rb

98.76% lines covered

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

lib/httpx/adapters/webmock.rb

100.0% lines covered

93 relevant lines. 93 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 8 module WebMock
  3. 8 module HttpLibAdapters
  4. 8 require "net/http/status"
  5. 8 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. 8 module Plugin
  12. 8 class << self
  13. 8 def build_webmock_request_signature(request)
  14. 188 uri = WebMock::Util::URI.heuristic_parse(request.uri)
  15. 188 uri.query = request.query
  16. 188 uri.path = uri.normalized_path.gsub("[^:]//", "/")
  17. 188 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. 8 def build_webmock_response(_request, response)
  25. 6 webmock_response = WebMock::Response.new
  26. 6 webmock_response.status = [response.status, HTTP_REASONS[response.status]]
  27. 6 webmock_response.body = response.body.to_s
  28. 6 webmock_response.headers = response.headers.to_h
  29. 6 webmock_response
  30. end
  31. 8 def build_from_webmock_response(request, webmock_response)
  32. 158 return build_error_response(request, HTTPX::TimeoutError.new(1, "Timed out")) if webmock_response.should_timeout
  33. 140 return build_error_response(request, webmock_response.exception) if webmock_response.exception
  34. 133 request.options.response_class.new(request,
  35. webmock_response.status[0],
  36. "2.0",
  37. webmock_response.headers).tap do |res|
  38. 133 res.mocked = true
  39. end
  40. end
  41. 8 def build_error_response(request, exception)
  42. 25 HTTPX::ErrorResponse.new(request, exception)
  43. end
  44. end
  45. 8 module InstanceMethods
  46. 8 private
  47. 8 def do_init_connection(connection, selector)
  48. 170 super
  49. 170 connection.once(:unmock_connection) do
  50. 24 next unless connection.current_session == self
  51. 24 unless connection.addresses?
  52. # reset Happy Eyeballs, fail early
  53. 24 connection.sibling = nil
  54. 24 deselect_connection(connection, selector)
  55. end
  56. 24 resolve_connection(connection, selector)
  57. end
  58. end
  59. end
  60. 8 module ResponseMethods
  61. 8 attr_accessor :mocked
  62. 8 def initialize(*)
  63. 157 super
  64. 157 @mocked = false
  65. end
  66. end
  67. 8 module ResponseBodyMethods
  68. 8 def decode_chunk(chunk)
  69. 96 return chunk if @response.mocked
  70. 42 super
  71. end
  72. end
  73. 8 module ConnectionMethods
  74. 8 def initialize(*)
  75. 170 super
  76. 170 @mocked = true
  77. end
  78. 8 def open?
  79. 194 return true if @mocked
  80. 24 super
  81. end
  82. 8 def interests
  83. 278 return if @mocked
  84. 246 super
  85. end
  86. 8 def terminate
  87. 145 force_reset
  88. end
  89. 8 def send(request)
  90. 188 request_signature = Plugin.build_webmock_request_signature(request)
  91. 188 WebMock::RequestRegistry.instance.requested_signatures.put(request_signature)
  92. 188 if (mock_response = WebMock::StubRegistry.instance.response_for_request(request_signature))
  93. 158 response = Plugin.build_from_webmock_response(request, mock_response)
  94. 158 WebMock::CallbackRegistry.invoke_callbacks({ lib: :httpx }, request_signature, mock_response)
  95. 158 log { "mocking #{request.uri} with #{mock_response.inspect}" }
  96. 158 request.transition(:headers)
  97. 158 request.transition(:body)
  98. 158 request.transition(:trailers)
  99. 158 request.transition(:done)
  100. 158 response.finish!
  101. 158 request.response = response
  102. 158 request.emit(:response, response)
  103. 158 request_signature.headers = request.headers.to_h
  104. 158 response << mock_response.body.dup unless response.is_a?(HTTPX::ErrorResponse)
  105. 30 elsif WebMock.net_connect_allowed?(request_signature.uri)
  106. 24 if WebMock::CallbackRegistry.any_callbacks?
  107. 6 request.on(:response) do |resp|
  108. 6 unless resp.is_a?(HTTPX::ErrorResponse)
  109. 6 webmock_response = Plugin.build_webmock_response(request, resp)
  110. 6 WebMock::CallbackRegistry.invoke_callbacks(
  111. { lib: :httpx, real_request: true }, request_signature,
  112. webmock_response
  113. )
  114. end
  115. end
  116. end
  117. 24 @mocked = false
  118. 24 emit(:unmock_connection, self)
  119. 24 super
  120. else
  121. 6 raise WebMock::NetConnectNotAllowedError, request_signature
  122. end
  123. end
  124. end
  125. end
  126. 8 class HttpxAdapter < HttpLibAdapter
  127. 8 adapter_for :httpx
  128. 8 class << self
  129. 8 def enable!
  130. 370 @original_session ||= HTTPX::Session
  131. 370 webmock_session = HTTPX.plugin(Plugin)
  132. 370 HTTPX.send(:remove_const, :Session)
  133. 370 HTTPX.send(:const_set, :Session, webmock_session.class)
  134. end
  135. 8 def disable!
  136. 370 return unless @original_session
  137. 362 HTTPX.send(:remove_const, :Session)
  138. 362 HTTPX.send(:const_set, :Session, @original_session)
  139. end
  140. end
  141. end
  142. end
  143. 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. 27 require "strscan"
  3. 27 module HTTPX
  4. 27 module AltSvc
  5. # makes connections able to accept requests destined to primary service.
  6. 27 module ConnectionMixin
  7. 27 using URIExtensions
  8. 27 def send(request)
  9. 8 request.headers["alt-used"] = @origin.authority if @parser && !@write_buffer.full? && match_altsvcs?(request.uri)
  10. 8 super
  11. end
  12. 27 def match?(uri, options)
  13. 8 return false if !used? && (@state == :closing || @state == :closed)
  14. 8 match_altsvcs?(uri) && match_altsvc_options?(uri, options)
  15. end
  16. 27 private
  17. # checks if this is connection is an alternative service of
  18. # +uri+
  19. 27 def match_altsvcs?(uri)
  20. 39 @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. 27 def match_altsvc_options?(uri, options)
  27. 8 return @options == options unless @options.ssl.all? do |k, v|
  28. 8 v == (k == :hostname ? uri.host : options.ssl[k])
  29. end
  30. 8 @options.options_equals?(options, Options::REQUEST_BODY_IVARS + %i[@ssl])
  31. end
  32. 27 def altsvc_match?(uri, other_uri)
  33. 26 other_uri = URI(other_uri)
  34. 26 uri.origin == other_uri.origin || begin
  35. 13 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. 13 false
  42. end
  43. end
  44. end
  45. end
  46. 27 @altsvc_mutex = Thread::Mutex.new
  47. 51 @altsvcs = Hash.new { |h, k| h[k] = [] }
  48. 27 module_function
  49. 27 def cached_altsvc(origin)
  50. 40 now = Utils.now
  51. 40 @altsvc_mutex.synchronize do
  52. 40 lookup(origin, now)
  53. end
  54. end
  55. 27 def cached_altsvc_set(origin, entry)
  56. 24 now = Utils.now
  57. 24 @altsvc_mutex.synchronize do
  58. 24 return if @altsvcs[origin].any? { |altsvc| altsvc["origin"] == entry["origin"] }
  59. 24 entry["TTL"] = Integer(entry["ma"]) + now if entry.key?("ma")
  60. 24 @altsvcs[origin] << entry
  61. 24 entry
  62. end
  63. end
  64. 27 def lookup(origin, ttl)
  65. 40 return [] unless @altsvcs.key?(origin)
  66. 32 @altsvcs[origin] = @altsvcs[origin].select do |entry|
  67. 24 !entry.key?("TTL") || entry["TTL"] > ttl
  68. end
  69. 48 @altsvcs[origin].reject { |entry| entry["noop"] }
  70. end
  71. 27 def emit(request, response)
  72. 8293 return unless response.respond_to?(:headers)
  73. # Alt-Svc
  74. 8255 return unless response.headers.key?("alt-svc")
  75. 134 origin = request.origin
  76. 134 host = request.uri.host
  77. 134 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. 134 if altsvc == "clear"
  84. 8 @altsvc_mutex.synchronize do
  85. 8 @altsvcs[origin].clear
  86. end
  87. 8 return
  88. end
  89. 126 parse(altsvc) do |alt_origin, alt_params|
  90. 16 alt_origin.host ||= host
  91. 16 yield(alt_origin, origin, alt_params)
  92. end
  93. end
  94. 27 def parse(altsvc)
  95. 238 return enum_for(__method__, altsvc) unless block_given?
  96. 182 scanner = StringScanner.new(altsvc)
  97. 182 until scanner.eos?
  98. 182 alt_service = scanner.scan(/[^=]+=("[^"]+"|[^;,]+)/)
  99. 182 alt_params = []
  100. 182 loop do
  101. 206 alt_param = scanner.scan(/[^=]+=("[^"]+"|[^;,]+)/)
  102. 206 alt_params << alt_param.strip if alt_param
  103. 206 scanner.skip(/;/)
  104. 206 break if scanner.eos? || scanner.scan(/ *, */)
  105. end
  106. 356 alt_params = Hash[alt_params.map { |field| field.split("=", 2) }]
  107. 182 alt_proto, alt_authority = alt_service.split("=", 2)
  108. 182 alt_origin = parse_altsvc_origin(alt_proto, alt_authority)
  109. 182 return unless alt_origin
  110. 56 yield(alt_origin, alt_params.merge("proto" => alt_proto))
  111. end
  112. end
  113. 27 def parse_altsvc_scheme(alt_proto)
  114. 206 case alt_proto
  115. when "h2c"
  116. 8 "http"
  117. when "h2"
  118. 64 "https"
  119. end
  120. end
  121. 27 def parse_altsvc_origin(alt_proto, alt_origin)
  122. 182 alt_scheme = parse_altsvc_scheme(alt_proto)
  123. 182 return unless alt_scheme
  124. 56 alt_origin = alt_origin[1..-2] if alt_origin.start_with?("\"") && alt_origin.end_with?("\"")
  125. 56 URI.parse("#{alt_scheme}://#{alt_origin}")
  126. end
  127. end
  128. end

lib/httpx/buffer.rb

100.0% lines covered

27 relevant lines. 27 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 27 require "forwardable"
  3. 27 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. 27 class Buffer
  13. 27 extend Forwardable
  14. 27 def_delegator :@buffer, :to_s
  15. 27 def_delegator :@buffer, :to_str
  16. 27 def_delegator :@buffer, :empty?
  17. 27 def_delegator :@buffer, :bytesize
  18. 27 def_delegator :@buffer, :clear
  19. 27 def_delegator :@buffer, :replace
  20. 27 attr_reader :limit
  21. 27 if RUBY_VERSION >= "3.4.0"
  22. 16 def initialize(limit)
  23. 5645 @buffer = String.new("", encoding: Encoding::BINARY, capacity: limit)
  24. 5645 @limit = limit
  25. end
  26. 16 def <<(chunk)
  27. 20126 @buffer.append_as_bytes(chunk)
  28. end
  29. else
  30. 11 def initialize(limit)
  31. 17428 @buffer = "".b
  32. 17428 @limit = limit
  33. end
  34. 11 def_delegator :@buffer, :<<
  35. end
  36. 27 def full?
  37. 57711 @buffer.bytesize >= @limit
  38. end
  39. 27 def capacity
  40. 12 @limit - @buffer.bytesize
  41. end
  42. 27 def shift!(fin)
  43. 20188 @buffer = @buffer.byteslice(fin..-1) || "".b
  44. end
  45. end
  46. 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. 27 module HTTPX
  3. 27 module Callbacks
  4. 27 def on(type, &action)
  5. 311672 callbacks(type) << action
  6. 311672 action
  7. end
  8. 27 def once(type, &block)
  9. 196084 on(type) do |*args, &callback|
  10. 101092 block.call(*args, &callback)
  11. 101028 :delete
  12. end
  13. end
  14. 27 def emit(type, *args)
  15. 116786 log { "emit #{type.inspect} callbacks" } if respond_to?(:log)
  16. 243208 callbacks(type).delete_if { |pr| :delete == pr.call(*args) } # rubocop:disable Style/YodaCondition
  17. end
  18. 27 def callbacks_for?(type)
  19. 3490 @callbacks && @callbacks.key?(type) && @callbacks[type].any?
  20. end
  21. 27 protected
  22. 27 def callbacks(type = nil)
  23. 431720 return @callbacks unless type
  24. 630736 @callbacks ||= Hash.new { |h, k| h[k] = [] }
  25. 431633 @callbacks[type]
  26. end
  27. end
  28. end

lib/httpx/chainable.rb

95.45% lines covered

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

lib/httpx/connection.rb

94.98% lines covered

478 relevant lines. 454 lines covered and 24 lines missed.
    
  1. # frozen_string_literal: true
  2. 27 require "resolv"
  3. 27 require "forwardable"
  4. 27 require "httpx/io"
  5. 27 require "httpx/buffer"
  6. 27 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. 27 class Connection
  29. 27 extend Forwardable
  30. 27 include Loggable
  31. 27 include Callbacks
  32. 27 using URIExtensions
  33. 27 def_delegator :@write_buffer, :empty?
  34. 27 attr_reader :type, :io, :origin, :origins, :state, :pending, :options, :ssl_session, :sibling
  35. 27 attr_writer :current_selector
  36. 27 attr_accessor :current_session, :family
  37. 27 protected :ssl_session, :sibling
  38. 27 def initialize(uri, options)
  39. 1665 @current_session = @current_selector =
  40. @parser = @sibling = @coalesced_connection = @altsvc_connection =
  41. @family = @io = @ssl_session = @timeout =
  42. 6185 @connected_at = @response_received_at = nil
  43. 7850 @exhausted = @cloned = @main_sibling = false
  44. 7850 @options = Options.new(options)
  45. 7850 @type = initialize_type(uri, @options)
  46. 7850 @origins = [uri.origin]
  47. 7850 @origin = Utils.to_uri(uri.origin)
  48. 7850 @window_size = @options.window_size
  49. 7850 @read_buffer = Buffer.new(@options.buffer_size)
  50. 7850 @write_buffer = Buffer.new(@options.buffer_size)
  51. 7850 @pending = []
  52. 7850 @inflight = 0
  53. 7850 @keep_alive_timeout = @options.timeout[:keep_alive_timeout]
  54. 7850 if @options.io
  55. # if there's an already open IO, get its
  56. # peer address, and force-initiate the parser
  57. 64 transition(:already_open)
  58. 64 @io = build_socket
  59. 64 parser
  60. else
  61. 7786 transition(:idle)
  62. end
  63. 7850 self.addresses = @options.addresses if @options.addresses
  64. end
  65. 27 def peer
  66. 16683 @origin
  67. end
  68. # this is a semi-private method, to be used by the resolver
  69. # to initiate the io object.
  70. 27 def addresses=(addrs)
  71. 7554 if @io
  72. 330 @io.add_addresses(addrs)
  73. else
  74. 7224 @io = build_socket(addrs)
  75. end
  76. end
  77. 27 def addresses
  78. 15098 @io && @io.addresses
  79. end
  80. 27 def addresses?
  81. 8423 @io && @io.addresses?
  82. end
  83. 27 def match?(uri, options)
  84. 2232 return false if !used? && (@state == :closing || @state == :closed)
  85. 311 (
  86. 1783 @origins.include?(uri.origin) &&
  87. # if there is more than one origin to match, it means that this connection
  88. # was the result of coalescing. To prevent blind trust in the case where the
  89. # origin came from an ORIGIN frame, we're going to verify the hostname with the
  90. # SSL certificate
  91. 1959 (@origins.size == 1 || @origin == uri.origin || (@io.is_a?(SSL) && @io.verify_hostname(uri.host)))
  92. ) && @options == options
  93. end
  94. 27 def mergeable?(connection)
  95. 427 return false if @state == :closing || @state == :closed || !@io
  96. 112 return false unless connection.addresses
  97. (
  98. 112 (open? && @origin == connection.origin) ||
  99. 112 !(@io.addresses & (connection.addresses || [])).empty?
  100. ) && @options == connection.options
  101. end
  102. # coalesces +self+ into +connection+.
  103. 27 def coalesce!(connection)
  104. 25 @coalesced_connection = connection
  105. 25 close_sibling
  106. 25 connection.merge(self)
  107. end
  108. 27 def coalesced?
  109. 8583 @coalesced_connection
  110. end
  111. # coalescable connections need to be mergeable!
  112. # but internally, #mergeable? is called before #coalescable?
  113. 27 def coalescable?(connection)
  114. 50 if @io.protocol == "h2" &&
  115. @origin.scheme == "https" &&
  116. connection.origin.scheme == "https" &&
  117. @io.can_verify_peer?
  118. 25 @io.verify_hostname(connection.origin.host)
  119. else
  120. 25 @origin == connection.origin
  121. end
  122. end
  123. 27 def create_idle(options = {})
  124. self.class.new(@origin, @options.merge(options))
  125. end
  126. 27 def merge(connection)
  127. 64 @origins |= connection.instance_variable_get(:@origins)
  128. 64 if @ssl_session.nil? && connection.ssl_session
  129. 12 @ssl_session = connection.ssl_session
  130. @io.session_new_cb do |sess|
  131. 18 @ssl_session = sess
  132. 12 end if @io
  133. end
  134. 64 connection.purge_pending do |req|
  135. 22 send(req)
  136. end
  137. end
  138. 27 def purge_pending(&block)
  139. 64 pendings = []
  140. 64 if @parser
  141. 24 @inflight -= @parser.pending.size
  142. 24 pendings << @parser.pending
  143. end
  144. 64 pendings << @pending
  145. 64 pendings.each do |pending|
  146. 88 pending.reject!(&block)
  147. end
  148. end
  149. 27 def io_connected?
  150. 23 return @coalesced_connection.io_connected? if @coalesced_connection
  151. 23 @io && @io.state == :connected
  152. end
  153. 27 def connecting?
  154. 9322630 @state == :idle
  155. end
  156. 27 def inflight?
  157. 2982 @parser && (
  158. # parser may be dealing with other requests (possibly started from a different fiber)
  159. 2183 !@parser.empty? ||
  160. # connection may be doing connection termination handshake
  161. !@write_buffer.empty?
  162. )
  163. end
  164. 27 def interests
  165. # connecting
  166. 9298884 if connecting?
  167. 11957 connect
  168. 11957 return @io.interests if connecting?
  169. end
  170. 9287546 return @parser.interests if @parser
  171. 21 nil
  172. rescue StandardError => e
  173. on_error(e)
  174. nil
  175. end
  176. 27 def to_io
  177. 26919 @io.to_io
  178. end
  179. 27 def call
  180. 25681 case @state
  181. when :idle
  182. 11087 connect
  183. # when opening the tcp or ssl socket fails
  184. 11071 return if @state == :closed
  185. 11046 consume
  186. when :closed
  187. return
  188. when :closing
  189. consume
  190. transition(:closed)
  191. when :open
  192. 14267 consume
  193. end
  194. 9624 nil
  195. rescue StandardError => e
  196. 17 @write_buffer.clear
  197. 17 on_error(e)
  198. rescue Exception => e # rubocop:disable Lint/RescueException
  199. 104 force_close(true)
  200. 96 raise e
  201. end
  202. 27 def close
  203. 2877 transition(:active) if @state == :inactive
  204. 2877 @parser.close if @parser
  205. end
  206. 27 def terminate
  207. 2829 case @state
  208. when :idle
  209. purge_after_closed
  210. disconnect
  211. when :closed
  212. 16 @connected_at = nil
  213. end
  214. 2829 close
  215. end
  216. # bypasses state machine rules while setting the connection in the
  217. # :closed state.
  218. 27 def force_close(delete_pending = false)
  219. 404 if delete_pending
  220. 248 @pending.clear
  221. 156 elsif (parser = @parser)
  222. enqueue_pending_requests_from_parser(parser)
  223. end
  224. 404 return if @state == :closed
  225. 274 @state = :closed
  226. 274 @write_buffer.clear
  227. 274 purge_after_closed
  228. 274 disconnect
  229. 266 emit(:force_closed, delete_pending)
  230. end
  231. # bypasses the state machine to force closing of connections still connecting.
  232. # **only** used for Happy Eyeballs v2.
  233. 27 def force_reset(cloned = false)
  234. 168 @state = :closing
  235. 168 @cloned = cloned
  236. 168 transition(:closed)
  237. end
  238. 27 def reset
  239. 8075 return if @state == :closing || @state == :closed
  240. 8024 transition(:closing)
  241. 8024 transition(:closed)
  242. end
  243. 27 def send(request)
  244. 9495 return @coalesced_connection.send(request) if @coalesced_connection
  245. 9477 if @parser && !@write_buffer.full?
  246. 461 if @response_received_at && @keep_alive_timeout &&
  247. Utils.elapsed_time(@response_received_at) > @keep_alive_timeout
  248. # when pushing a request into an existing connection, we have to check whether there
  249. # is the possibility that the connection might have extended the keep alive timeout.
  250. # for such cases, we want to ping for availability before deciding to shovel requests.
  251. 20 log(level: 3) { "keep alive timeout expired, pinging connection..." }
  252. 20 @pending << request
  253. 20 transition(:active) if @state == :inactive
  254. 20 parser.ping
  255. 20 request.ping!
  256. 20 return
  257. end
  258. 441 send_request_to_parser(request)
  259. else
  260. 9016 @pending << request
  261. end
  262. end
  263. 27 def timeout
  264. 9176829 return if @state == :closed || @state == :inactive
  265. 9176829 return @timeout if @timeout
  266. 9160253 return @options.timeout[:connect_timeout] if @state == :idle
  267. 9160253 @options.timeout[:operation_timeout]
  268. end
  269. 27 def idling
  270. 869 purge_after_closed
  271. 869 @write_buffer.clear
  272. 869 transition(:idle)
  273. 869 @parser = nil if @parser
  274. end
  275. 27 def used?
  276. 2467 @connected_at
  277. end
  278. 27 def deactivate
  279. 465 transition(:inactive)
  280. end
  281. 27 def open?
  282. 7688 @state == :open || @state == :inactive
  283. end
  284. 27 def handle_socket_timeout(interval)
  285. 46 error = OperationTimeoutError.new(interval, "timed out while waiting on select")
  286. 46 error.set_backtrace(caller)
  287. 46 on_error(error)
  288. end
  289. 27 def sibling=(connection)
  290. 118 @sibling = connection
  291. 118 return unless connection
  292. 94 @main_sibling = connection.sibling.nil?
  293. 94 return unless @main_sibling
  294. 47 connection.sibling = self
  295. end
  296. 27 def handle_connect_error(error)
  297. 342 return on_error(error) unless @sibling && @sibling.connecting?
  298. 11 @sibling.merge(self)
  299. 11 force_reset(true)
  300. end
  301. # disconnects from the current session it's attached to
  302. 27 def disconnect
  303. 11544 return if @exhausted # it'll reset
  304. 11536 return unless (current_session = @current_session) && (current_selector = @current_selector)
  305. 8656 @current_session = @current_selector = nil
  306. 8656 current_session.deselect_connection(self, current_selector, @cloned)
  307. end
  308. 27 def on_error(error, request = nil)
  309. 956 if error.is_a?(OperationTimeoutError)
  310. # inactive connections do not contribute to the select loop, therefore
  311. # they should not fail due to such errors.
  312. 46 return if @state == :inactive
  313. 46 if @timeout
  314. 40 @timeout -= error.timeout
  315. 40 return unless @timeout <= 0
  316. end
  317. 38 error = error.to_connection_error if connecting?
  318. end
  319. 948 handle_error(error, request)
  320. 932 reset
  321. end
  322. skipped # :nocov:
  323. skipped def inspect
  324. skipped "#<#{self.class}:#{object_id} " \
  325. skipped "@origin=#{@origin} " \
  326. skipped "@state=#{@state} " \
  327. skipped "@pending=#{@pending.size} " \
  328. skipped "@io=#{@io}>"
  329. skipped end
  330. skipped # :nocov:
  331. 27 private
  332. 27 def connect
  333. 21974 transition(:open)
  334. end
  335. 27 def consume
  336. 28724 return unless @io
  337. 28724 catch(:called) do
  338. 28724 epiped = false
  339. 28724 loop do
  340. # connection may have
  341. 43729 return if @state == :idle
  342. 40255 parser.consume
  343. # we exit if there's no more requests to process
  344. #
  345. # this condition takes into account:
  346. #
  347. # * the number of inflight requests
  348. # * the number of pending requests
  349. # * whether the write buffer has bytes (i.e. for close handshake)
  350. 40239 if @pending.empty? && @inflight.zero? && @write_buffer.empty?
  351. 2941 log(level: 3) { "NO MORE REQUESTS..." } if @parser && @parser.pending.any?
  352. # terminate if an altsvc connection has been established
  353. 2941 terminate if @altsvc_connection
  354. 2941 return
  355. end
  356. 37298 @timeout = @current_timeout
  357. 37298 read_drained = false
  358. 37298 write_drained = nil
  359. #
  360. # tight read loop.
  361. #
  362. # read as much of the socket as possible.
  363. #
  364. # this tight loop reads all the data it can from the socket and pipes it to
  365. # its parser.
  366. #
  367. 5342 loop do
  368. 57239 siz = @io.read(@window_size, @read_buffer)
  369. 57406 log(level: 3, color: :cyan) { "IO READ: #{siz} bytes... (wsize: #{@window_size}, rbuffer: #{@read_buffer.bytesize})" }
  370. 57237 unless siz
  371. 16 @write_buffer.clear
  372. 16 ex = EOFError.new("descriptor closed")
  373. 16 ex.set_backtrace(caller)
  374. 16 on_error(ex)
  375. 16 return
  376. end
  377. # socket has been drained. mark and exit the read loop.
  378. 57221 if siz.zero?
  379. 13754 read_drained = @read_buffer.empty?
  380. 13754 epiped = false
  381. 13754 break
  382. end
  383. 43467 parser << @read_buffer.to_s
  384. # continue reading if possible.
  385. 38895 break if interests == :w && !epiped
  386. # exit the read loop if connection is preparing to be closed
  387. 34001 break if @state == :closing || @state == :closed
  388. # exit #consume altogether if all outstanding requests have been dealt with
  389. 33912 if @pending.empty? && @inflight.zero? && @write_buffer.empty? # rubocop:disable Style/Next
  390. 3213 log(level: 3) { "NO MORE REQUESTS..." } if @parser && @parser.pending.any?
  391. # terminate if an altsvc connection has been established
  392. 3213 terminate if @altsvc_connection
  393. 3213 return
  394. end
  395. 37298 end unless ((ints = interests).nil? || ints == :w || @state == :closing) && !epiped
  396. #
  397. # tight write loop.
  398. #
  399. # flush as many bytes as the sockets allow.
  400. #
  401. 3572 loop do
  402. # buffer has been drainned, mark and exit the write loop.
  403. 22500 if @write_buffer.empty?
  404. # we only mark as drained on the first loop
  405. 3011 write_drained = write_drained.nil? && @inflight.positive?
  406. 3011 break
  407. end
  408. 4274 begin
  409. 19489 siz = @io.write(@write_buffer)
  410. rescue Errno::EPIPE
  411. # this can happen if we still have bytes in the buffer to send to the server, but
  412. # the server wants to respond immediately with some message, or an error. An example is
  413. # when one's uploading a big file to an unintended endpoint, and the server stops the
  414. # consumption, and responds immediately with an authorization of even method not allowed error.
  415. # at this point, we have to let the connection switch to read-mode.
  416. 14 log(level: 2) { "pipe broken, could not flush buffer..." }
  417. 14 epiped = true
  418. 14 read_drained = false
  419. 14 break
  420. end
  421. 19570 log(level: 3, color: :cyan) { "IO WRITE: #{siz} bytes..." }
  422. 19474 unless siz
  423. @write_buffer.clear
  424. ex = EOFError.new("descriptor closed")
  425. ex.set_backtrace(caller)
  426. on_error(ex)
  427. return
  428. end
  429. # socket closed for writing. mark and exit the write loop.
  430. 19474 if siz.zero?
  431. 16 write_drained = !@write_buffer.empty?
  432. 16 break
  433. end
  434. # exit write loop if marked to consume from peer, or is closing.
  435. 19458 break if interests == :r || @state == :closing || @state == :closed
  436. 3282 write_drained = false
  437. 29495 end unless (ints = interests) == :r
  438. 29494 send_pending if @state == :open
  439. # return if socket is drained
  440. 29494 next unless (ints != :r || read_drained) && (ints != :w || write_drained)
  441. # gotta go back to the event loop. It happens when:
  442. #
  443. # * the socket is drained of bytes or it's not the interest of the conn to read;
  444. # * theres nothing more to write, or it's not in the interest of the conn to write;
  445. 14562 log(level: 3) { "(#{ints}): WAITING FOR EVENTS..." }
  446. 14489 return
  447. end
  448. end
  449. end
  450. 27 def send_pending
  451. 77802 while !@write_buffer.full? && (request = @pending.shift)
  452. 19065 send_request_to_parser(request)
  453. end
  454. end
  455. 27 def parser
  456. 111577 @parser ||= build_parser
  457. end
  458. 27 def send_request_to_parser(request)
  459. 19506 @inflight += 1
  460. 19506 request.peer_address = @io.ip && @io.ip.address
  461. 19506 set_request_timeouts(request)
  462. 19506 parser.send(request)
  463. 19506 return unless @state == :inactive
  464. 26 transition(:active)
  465. # mark request as ping, as this inactive connection may have been
  466. # closed by the server, and we don't want that to influence retry
  467. # bookkeeping.
  468. 26 request.ping!
  469. end
  470. 27 def enqueue_pending_requests_from_parser(parser)
  471. 4195 parser_pending_requests = parser.pending
  472. 4195 return if parser_pending_requests.empty?
  473. # the connection will be reused, so parser requests must come
  474. # back to the pending list before the parser is reset.
  475. 216 @inflight -= parser_pending_requests.size
  476. 216 @pending.unshift(*parser_pending_requests)
  477. end
  478. 27 def build_parser(protocol = @io.protocol)
  479. 7739 parser = parser_type(protocol).new(@write_buffer, @options)
  480. 7739 set_parser_callbacks(parser)
  481. 7739 parser
  482. end
  483. 27 def set_parser_callbacks(parser)
  484. 7848 parser.on(:response) do |request, response|
  485. 8285 AltSvc.emit(request, response) do |alt_origin, origin, alt_params|
  486. 16 build_altsvc_connection(alt_origin, origin, alt_params)
  487. end
  488. 8285 @response_received_at = Utils.now
  489. 8285 @inflight -= 1
  490. 8285 response.finish!
  491. 8285 request.emit(:response, response)
  492. end
  493. 7848 parser.on(:altsvc) do |alt_origin, origin, alt_params|
  494. build_altsvc_connection(alt_origin, origin, alt_params)
  495. end
  496. 7848 parser.on(:pong, &method(:send_pending))
  497. 7848 parser.on(:promise) do |request, stream|
  498. 24 request.emit(:promise, parser, stream)
  499. end
  500. 7848 parser.on(:exhausted) do
  501. 8 enqueue_pending_requests_from_parser(parser)
  502. 8 @exhausted = true
  503. 8 parser.close
  504. 8 idling
  505. 8 @exhausted = false
  506. end
  507. 7848 parser.on(:origin) do |origin|
  508. @origins |= [origin]
  509. end
  510. 7848 parser.on(:close) do
  511. 2932 reset
  512. 2924 disconnect
  513. end
  514. 7848 parser.on(:close_handshake) do
  515. 8 consume unless @state == :closed
  516. end
  517. 7848 parser.on(:reset) do
  518. 4179 enqueue_pending_requests_from_parser(parser)
  519. 4179 reset
  520. # :reset event only fired in http/1.1, so this guarantees
  521. # that the connection will be closed here.
  522. 4171 idling unless @pending.empty?
  523. end
  524. 7848 parser.on(:current_timeout) do
  525. 3339 @current_timeout = @timeout = parser.timeout
  526. end
  527. 7848 parser.on(:timeout) do |tout|
  528. 2868 @timeout = tout
  529. end
  530. 7848 parser.on(:error) do |request, error|
  531. 78 case error
  532. when :http_1_1_required
  533. 16 current_session = @current_session
  534. 16 current_selector = @current_selector
  535. 16 parser.close
  536. 16 other_connection = current_session.find_connection(@origin, current_selector,
  537. @options.merge(ssl: { alpn_protocols: %w[http/1.1] }))
  538. 16 other_connection.merge(self)
  539. 16 request.transition(:idle)
  540. 16 other_connection.send(request)
  541. 16 next
  542. when OperationTimeoutError
  543. # request level timeouts should take precedence
  544. next unless request.active_timeouts.empty?
  545. end
  546. 62 @inflight -= 1
  547. 62 response = ErrorResponse.new(request, error)
  548. 62 request.response = response
  549. 62 request.emit(:response, response)
  550. end
  551. end
  552. 27 def transition(nextstate)
  553. 49103 handle_transition(nextstate)
  554. rescue Errno::ECONNABORTED,
  555. Errno::ECONNREFUSED,
  556. Errno::ECONNRESET,
  557. Errno::EADDRNOTAVAIL,
  558. Errno::EHOSTUNREACH,
  559. Errno::EINVAL,
  560. Errno::ENETUNREACH,
  561. Errno::EPIPE,
  562. Errno::ENOENT,
  563. SocketError,
  564. IOError => e
  565. # connect errors, exit gracefully
  566. 82 error = ConnectionError.new(e.message)
  567. 82 error.set_backtrace(e.backtrace)
  568. 82 handle_connect_error(error) if connecting?
  569. 82 force_close
  570. rescue TLSError, ::HTTP2::Error::ProtocolError, ::HTTP2::Error::HandshakeError => e
  571. # connect errors, exit gracefully
  572. 24 handle_error(e)
  573. 24 handle_connect_error(e) if connecting?
  574. 24 force_close
  575. end
  576. 27 def handle_transition(nextstate)
  577. 48587 case nextstate
  578. when :idle
  579. 8661 @timeout = @current_timeout = @options.timeout[:connect_timeout]
  580. 8661 @connected_at = @response_received_at = nil
  581. when :open
  582. 22320 return if @state == :closed
  583. 22320 @io.connect
  584. 22214 close_sibling if @io.state == :connected
  585. 22214 return unless @io.connected?
  586. 7768 @connected_at = Utils.now
  587. 7768 send_pending
  588. 7768 @timeout = @current_timeout = parser.timeout
  589. 7768 emit(:open)
  590. when :inactive
  591. 465 return unless @state == :open
  592. # do not deactivate connection in use
  593. 431 return if @inflight.positive? || @parser.waiting_for_ping?
  594. 425 disconnect
  595. when :closing
  596. 8024 return unless @state == :idle || @state == :open
  597. 8024 unless @write_buffer.empty?
  598. # preset state before handshake, as error callbacks
  599. # may take it back here.
  600. 2881 @state = nextstate
  601. # handshakes, try sending
  602. 2881 consume
  603. 2881 @write_buffer.clear
  604. 2881 return
  605. end
  606. when :closed
  607. 8192 return unless @state == :closing
  608. 8192 return unless @write_buffer.empty?
  609. 8192 purge_after_closed
  610. 8192 disconnect if @pending.empty?
  611. when :already_open
  612. 64 nextstate = :open
  613. # the first check for given io readiness must still use a timeout.
  614. # connect is the reasonable choice in such a case.
  615. 64 @timeout = @options.timeout[:connect_timeout]
  616. 64 send_pending
  617. when :active
  618. 252 return unless @state == :inactive
  619. 252 nextstate = :open
  620. # activate
  621. 252 @current_session.select_connection(self, @current_selector)
  622. end
  623. 31358 log(level: 3) { "#{@state} -> #{nextstate}" }
  624. 31082 @state = nextstate
  625. end
  626. 27 def close_sibling
  627. 10205 return unless @sibling
  628. 23 if @sibling.io_connected?
  629. reset
  630. # TODO: transition connection to closed
  631. end
  632. 23 unless @sibling.state == :closed
  633. 12 merge(@sibling) unless @main_sibling
  634. 12 @sibling.force_reset(true)
  635. end
  636. 23 @sibling = nil
  637. end
  638. 27 def purge_after_closed
  639. 9341 @io.close if @io
  640. 9341 @read_buffer.clear
  641. 9341 @timeout = nil
  642. end
  643. 27 def initialize_type(uri, options)
  644. 7478 options.transport || begin
  645. 7454 case uri.scheme
  646. when "http"
  647. 4183 "tcp"
  648. when "https"
  649. 3271 "ssl"
  650. else
  651. raise UnsupportedSchemeError, "#{uri}: #{uri.scheme}: unsupported URI scheme"
  652. end
  653. end
  654. end
  655. # returns an HTTPX::Connection for the negotiated Alternative Service (or none).
  656. 27 def build_altsvc_connection(alt_origin, origin, alt_params)
  657. 16 return if @altsvc_connection
  658. # do not allow security downgrades on altsvc negotiation
  659. 8 return if @origin.scheme == "https" && alt_origin.scheme != "https"
  660. 8 altsvc = AltSvc.cached_altsvc_set(origin, alt_params.merge("origin" => alt_origin))
  661. # altsvc already exists, somehow it wasn't advertised, probably noop
  662. 8 return unless altsvc
  663. 8 alt_options = @options.merge(ssl: @options.ssl.merge(hostname: URI(origin).host))
  664. 8 connection = @current_session.find_connection(alt_origin, @current_selector, alt_options)
  665. # advertised altsvc is the same origin being used, ignore
  666. 8 return if connection == self
  667. 8 connection.extend(AltSvc::ConnectionMixin) unless connection.is_a?(AltSvc::ConnectionMixin)
  668. 8 @altsvc_connection = connection
  669. 8 log(level: 1) { "#{origin}: alt-svc connection##{connection.object_id} established to #{alt_origin}" }
  670. 8 connection.merge(self)
  671. rescue UnsupportedSchemeError
  672. altsvc["noop"] = true
  673. nil
  674. end
  675. 27 def build_socket(addrs = nil)
  676. 7288 case @type
  677. when "tcp"
  678. 4190 TCP.new(peer, addrs, @options)
  679. when "ssl"
  680. 3074 SSL.new(peer, addrs, @options) do |sock|
  681. 3052 sock.ssl_session = @ssl_session
  682. 3052 sock.session_new_cb do |sess|
  683. 4635 @ssl_session = sess
  684. 4635 sock.ssl_session = sess
  685. end
  686. end
  687. when "unix"
  688. 24 path = Array(addrs).first
  689. 24 path = String(path) if path
  690. 24 UNIX.new(peer, path, @options)
  691. else
  692. raise Error, "unsupported transport (#{@type})"
  693. end
  694. end
  695. 27 def handle_error(error, request = nil)
  696. 972 parser.handle_error(error, request) if @parser && @parser.respond_to?(:handle_error)
  697. 2151 while (req = @pending.shift)
  698. 483 next if request && req == request
  699. 483 response = ErrorResponse.new(req, error)
  700. 483 req.response = response
  701. 467 req.emit(:response, response)
  702. end
  703. 956 return unless request
  704. 412 @inflight -= 1
  705. 412 response = ErrorResponse.new(request, error)
  706. 412 request.response = response
  707. 412 request.emit(:response, response)
  708. end
  709. 27 def set_request_timeouts(request)
  710. 19506 set_request_write_timeout(request)
  711. 19506 set_request_read_timeout(request)
  712. 19506 set_request_request_timeout(request)
  713. end
  714. 27 def set_request_read_timeout(request)
  715. 19506 read_timeout = request.read_timeout
  716. 19506 return if read_timeout.nil? || read_timeout.infinite?
  717. 19192 set_request_timeout(:read_timeout, request, read_timeout, :done, :response) do
  718. 16 read_timeout_callback(request, read_timeout)
  719. end
  720. end
  721. 27 def set_request_write_timeout(request)
  722. 19506 write_timeout = request.write_timeout
  723. 19506 return if write_timeout.nil? || write_timeout.infinite?
  724. 19506 set_request_timeout(:write_timeout, request, write_timeout, :headers, %i[done response]) do
  725. 16 write_timeout_callback(request, write_timeout)
  726. end
  727. end
  728. 27 def set_request_request_timeout(request)
  729. 19222 request_timeout = request.request_timeout
  730. 19222 return if request_timeout.nil? || request_timeout.infinite?
  731. 549 set_request_timeout(:request_timeout, request, request_timeout, :headers, :complete) do
  732. 380 read_timeout_callback(request, request_timeout, RequestTimeoutError)
  733. end
  734. end
  735. 27 def write_timeout_callback(request, write_timeout)
  736. 16 return if request.state == :done
  737. 16 @write_buffer.clear
  738. 16 error = WriteTimeoutError.new(request, nil, write_timeout)
  739. 16 on_error(error, request)
  740. end
  741. 27 def read_timeout_callback(request, read_timeout, error_type = ReadTimeoutError)
  742. 396 response = request.response
  743. 396 return if response && response.finished?
  744. 396 @write_buffer.clear
  745. 396 error = error_type.new(request, request.response, read_timeout)
  746. 396 on_error(error, request)
  747. end
  748. 27 def set_request_timeout(label, request, timeout, start_event, finish_events, &callback)
  749. 39327 request.set_timeout_callback(start_event) do
  750. 39109 unless @current_selector
  751. raise Error, "request has been resend to an out-of-session connection, and this " \
  752. "should never happen!!! Please report this error! " \
  753. "(state:#{@state}, " \
  754. "parser?:#{!!@parser}, " \
  755. "bytes in write buffer?:#{!@write_buffer.empty?}, " \
  756. "cloned?:#{@cloned}, " \
  757. "sibling?:#{!!@sibling}, " \
  758. "coalesced?:#{coalesced?})"
  759. end
  760. 39109 timer = @current_selector.after(timeout, callback)
  761. 39109 request.active_timeouts << label
  762. 39109 Array(finish_events).each do |event|
  763. # clean up request timeouts if the connection errors out
  764. 58594 request.set_timeout_callback(event) do
  765. 58012 timer.cancel
  766. 58012 request.active_timeouts.delete(label)
  767. end
  768. end
  769. end
  770. end
  771. 27 def parser_type(protocol)
  772. 7895 case protocol
  773. 3354 when "h2" then @options.http2_class
  774. 4541 when "http/1.1" then @options.http1_class
  775. else
  776. raise Error, "unsupported protocol (##{protocol})"
  777. end
  778. end
  779. end
  780. end

lib/httpx/connection/http1.rb

89.59% lines covered

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

lib/httpx/connection/http2.rb

95.54% lines covered

269 relevant lines. 257 lines covered and 12 lines missed.
    
  1. # frozen_string_literal: true
  2. 27 require "securerandom"
  3. 27 require "http/2"
  4. 27 module HTTPX
  5. 27 class Connection::HTTP2
  6. 27 include Callbacks
  7. 27 include Loggable
  8. 27 MAX_CONCURRENT_REQUESTS = ::HTTP2::DEFAULT_MAX_CONCURRENT_STREAMS
  9. 27 class Error < Error
  10. 27 def initialize(id, error)
  11. 55 super("stream #{id} closed with error: #{error}")
  12. end
  13. end
  14. 27 class PingError < Error
  15. 27 def initialize
  16. super(0, :ping_error)
  17. end
  18. end
  19. 27 class GoawayError < Error
  20. 27 def initialize(code = :no_error)
  21. 31 super(0, code)
  22. end
  23. end
  24. 27 attr_reader :streams, :pending
  25. 27 def initialize(buffer, options)
  26. 3384 @options = options
  27. 3384 @settings = @options.http2_settings
  28. 3384 @pending = []
  29. 3384 @streams = {}
  30. 3384 @drains = {}
  31. 3384 @pings = []
  32. 3384 @buffer = buffer
  33. 3384 @handshake_completed = false
  34. 3384 @wait_for_handshake = @settings.key?(:wait_for_handshake) ? @settings.delete(:wait_for_handshake) : true
  35. 3384 @max_concurrent_requests = @options.max_concurrent_requests || MAX_CONCURRENT_REQUESTS
  36. 3384 @max_requests = @options.max_requests
  37. 3384 init_connection
  38. end
  39. 27 def timeout
  40. 6677 return @options.timeout[:operation_timeout] if @handshake_completed
  41. 3338 @options.timeout[:settings_timeout]
  42. end
  43. 27 def interests
  44. 9243202 if @connection.state == :closed
  45. 8844 return unless @handshake_completed
  46. 8774 return if @buffer.empty?
  47. 5724 return :w
  48. end
  49. 9234358 unless @connection.state == :connected && @handshake_completed
  50. 20187 return @buffer.empty? ? :r : :rw
  51. end
  52. 9214171 unless @connection.send_buffer.empty?
  53. 20 return :rw unless @buffer.empty?
  54. # waiting for WINDOW_UPDATE frames
  55. 4 return :r
  56. end
  57. 9214151 return :w if !@pending.empty? && can_buffer_more_requests?
  58. 9214151 return :w unless @drains.empty?
  59. 9210103 if @buffer.empty?
  60. 9200610 return if @streams.empty? && @pings.empty?
  61. 47641 :r
  62. else
  63. 9493 :w
  64. end
  65. end
  66. 27 def close
  67. 2876 unless @connection.state == :closed
  68. 2868 @connection.goaway
  69. 2868 emit(:timeout, @options.timeout[:close_handshake_timeout])
  70. end
  71. 2876 emit(:close)
  72. end
  73. 27 def empty?
  74. 2812 @connection.state == :closed || @streams.empty?
  75. end
  76. 27 def exhausted?
  77. 3385 !@max_requests.positive?
  78. end
  79. 27 def <<(data)
  80. 35872 @connection << data
  81. end
  82. 27 def send(request, head = false)
  83. 7409 unless can_buffer_more_requests?
  84. 3590 head ? @pending.unshift(request) : @pending << request
  85. 3590 return false
  86. end
  87. 3819 unless (stream = @streams[request])
  88. 3819 stream = @connection.new_stream
  89. 3819 handle_stream(stream, request)
  90. 3819 @streams[request] = stream
  91. 3819 @max_requests -= 1
  92. end
  93. 3819 handle(request, stream)
  94. 3803 true
  95. rescue ::HTTP2::Error::StreamLimitExceeded
  96. @pending.unshift(request)
  97. false
  98. end
  99. 27 def consume
  100. 22682 @streams.each do |request, stream|
  101. 10187 next unless request.can_buffer?
  102. 1173 handle(request, stream)
  103. end
  104. end
  105. 27 def handle_error(ex, request = nil)
  106. 285 if ex.is_a?(OperationTimeoutError) && !@handshake_completed && @connection.state != :closed
  107. 8 @connection.goaway(:settings_timeout, "closing due to settings timeout")
  108. 8 emit(:close_handshake)
  109. 8 settings_ex = SettingsTimeoutError.new(ex.timeout, ex.message)
  110. 8 settings_ex.set_backtrace(ex.backtrace)
  111. 8 ex = settings_ex
  112. end
  113. 285 @streams.each_key do |req|
  114. 225 next if request && request == req
  115. 16 emit(:error, req, ex)
  116. end
  117. 552 while (req = @pending.shift)
  118. 46 next if request && request == req
  119. 46 emit(:error, req, ex)
  120. end
  121. end
  122. 27 def ping
  123. 20 ping = SecureRandom.gen_random(8)
  124. 20 @connection.ping(ping.dup)
  125. ensure
  126. 20 @pings << ping
  127. end
  128. 27 def waiting_for_ping?
  129. 407 @pings.any?
  130. end
  131. 27 private
  132. 27 def can_buffer_more_requests?
  133. 8176 (@handshake_completed || !@wait_for_handshake) &&
  134. @streams.size < @max_concurrent_requests &&
  135. @streams.size < @max_requests
  136. end
  137. 27 def send_pending
  138. 8582 while (request = @pending.shift)
  139. 3459 break unless send(request, true)
  140. end
  141. end
  142. 27 def handle(request, stream)
  143. 5136 catch(:buffer_full) do
  144. 5136 request.transition(:headers)
  145. 5128 join_headers(stream, request) if request.state == :headers
  146. 5128 request.transition(:body)
  147. 5128 join_body(stream, request) if request.state == :body
  148. 3974 request.transition(:trailers)
  149. 3974 join_trailers(stream, request) if request.state == :trailers && !request.body.empty?
  150. 3974 request.transition(:done)
  151. end
  152. end
  153. 27 def init_connection
  154. 3384 @connection = ::HTTP2::Client.new(@settings)
  155. 3384 @connection.on(:frame, &method(:on_frame))
  156. 3384 @connection.on(:frame_sent, &method(:on_frame_sent))
  157. 3384 @connection.on(:frame_received, &method(:on_frame_received))
  158. 3384 @connection.on(:origin, &method(:on_origin))
  159. 3384 @connection.on(:promise, &method(:on_promise))
  160. 3384 @connection.on(:altsvc) { |frame| on_altsvc(frame[:origin], frame) }
  161. 3384 @connection.on(:settings_ack, &method(:on_settings))
  162. 3384 @connection.on(:ack, &method(:on_pong))
  163. 3384 @connection.on(:goaway, &method(:on_close))
  164. #
  165. # Some servers initiate HTTP/2 negotiation right away, some don't.
  166. # As such, we have to check the socket buffer. If there is something
  167. # to read, the server initiated the negotiation. If not, we have to
  168. # initiate it.
  169. #
  170. 3384 @connection.send_connection_preface
  171. end
  172. 27 alias_method :reset, :init_connection
  173. 27 public :reset
  174. 27 def handle_stream(stream, request)
  175. 3835 request.on(:refuse, &method(:on_stream_refuse).curry(3)[stream, request])
  176. 3835 stream.on(:close, &method(:on_stream_close).curry(3)[stream, request])
  177. 3835 stream.on(:half_close) do
  178. 3795 log(level: 2) { "#{stream.id}: waiting for response..." }
  179. end
  180. 3835 stream.on(:altsvc, &method(:on_altsvc).curry(2)[request.origin])
  181. 3835 stream.on(:headers, &method(:on_stream_headers).curry(3)[stream, request])
  182. 3835 stream.on(:data, &method(:on_stream_data).curry(3)[stream, request])
  183. end
  184. 27 def set_protocol_headers(request)
  185. {
  186. 3801 ":scheme" => request.scheme,
  187. ":method" => request.verb,
  188. ":path" => request.path,
  189. ":authority" => request.authority,
  190. }
  191. end
  192. 27 def join_headers(stream, request)
  193. 3803 extra_headers = set_protocol_headers(request)
  194. 3803 if request.headers.key?("host")
  195. 8 log { "forbidden \"host\" header found (#{log_redact_headers(request.headers["host"])}), will use it as authority..." }
  196. 8 extra_headers[":authority"] = request.headers["host"]
  197. end
  198. 3803 log(level: 1, color: :yellow) do
  199. 144 "\n#{request.headers.merge(extra_headers).each.map { |k, v| "#{stream.id}: -> HEADER: #{k}: #{log_redact_headers(v)}" }.join("\n")}"
  200. end
  201. 3803 stream.headers(request.headers.each(extra_headers), end_stream: request.body.empty?)
  202. end
  203. 27 def join_trailers(stream, request)
  204. 1585 unless request.trailers?
  205. 1577 stream.data("", end_stream: true) if request.callbacks_for?(:trailers)
  206. 1577 return
  207. end
  208. 8 log(level: 1, color: :yellow) do
  209. 16 request.trailers.each.map { |k, v| "#{stream.id}: -> HEADER: #{k}: #{log_redact_headers(v)}" }.join("\n")
  210. end
  211. 8 stream.headers(request.trailers.each, end_stream: true)
  212. end
  213. 27 def join_body(stream, request)
  214. 4941 return if request.body.empty?
  215. 2739 chunk = @drains.delete(request) || request.drain_body
  216. 2739 while chunk
  217. 2955 next_chunk = request.drain_body
  218. 2955 send_chunk(request, stream, chunk, next_chunk)
  219. 2859 if next_chunk && (@buffer.full? || request.body.unbounded_body?)
  220. 1058 @drains[request] = next_chunk
  221. 1058 throw(:buffer_full)
  222. end
  223. 1801 chunk = next_chunk
  224. end
  225. 1585 return unless (error = request.drain_error)
  226. 24 on_stream_refuse(stream, request, error)
  227. end
  228. 27 def send_chunk(request, stream, chunk, next_chunk)
  229. 2979 log(level: 1, color: :green) { "#{stream.id}: -> DATA: #{chunk.bytesize} bytes..." }
  230. 2979 log(level: 2, color: :green) { "#{stream.id}: -> #{log_redact_body(chunk.inspect)}" }
  231. 2955 stream.data(chunk, end_stream: end_stream?(request, next_chunk))
  232. end
  233. 27 def end_stream?(request, next_chunk)
  234. 2859 !(next_chunk || request.trailers? || request.callbacks_for?(:trailers))
  235. end
  236. ######
  237. # HTTP/2 Callbacks
  238. ######
  239. 27 def on_stream_headers(stream, request, h)
  240. 3753 response = request.response
  241. 3753 if response.is_a?(Response) && response.version == "2.0"
  242. 114 on_stream_trailers(stream, response, h)
  243. 114 return
  244. end
  245. 3639 log(color: :yellow) do
  246. 144 h.map { |k, v| "#{stream.id}: <- HEADER: #{k}: #{log_redact_headers(v)}" }.join("\n")
  247. end
  248. 3639 _, status = h.shift
  249. 3639 headers = request.options.headers_class.new(h)
  250. 3639 response = request.options.response_class.new(request, status, "2.0", headers)
  251. 3639 request.response = response
  252. 3631 @streams[request] = stream
  253. 3631 handle(request, stream) if request.expects?
  254. end
  255. 27 def on_stream_trailers(stream, response, h)
  256. 114 log(color: :yellow) do
  257. h.map { |k, v| "#{stream.id}: <- HEADER: #{k}: #{log_redact_headers(v)}" }.join("\n")
  258. end
  259. 114 response.merge_headers(h)
  260. end
  261. 27 def on_stream_data(stream, request, data)
  262. 6736 log(level: 1, color: :green) { "#{stream.id}: <- DATA: #{data.bytesize} bytes..." }
  263. 6736 log(level: 2, color: :green) { "#{stream.id}: <- #{log_redact_body(data.inspect)}" }
  264. 6716 request.response << data
  265. end
  266. 27 def on_stream_refuse(stream, request, error)
  267. 24 on_stream_close(stream, request, error)
  268. 24 stream.close
  269. end
  270. 27 def on_stream_close(stream, request, error)
  271. 3569 return if error == :stream_closed && !@streams.key?(request)
  272. 3561 log(level: 2) { "#{stream.id}: closing stream" }
  273. 3545 teardown(request)
  274. 3545 if error
  275. 24 case error
  276. when :http_1_1_required
  277. emit(:error, request, error)
  278. else
  279. 24 ex = Error.new(stream.id, error)
  280. 24 ex.set_backtrace(caller)
  281. 24 response = ErrorResponse.new(request, ex)
  282. 24 request.response = response
  283. 24 emit(:response, request, response)
  284. end
  285. else
  286. 3521 response = request.response
  287. 3521 if response && response.is_a?(Response) && response.status == 421
  288. 8 emit(:error, request, :http_1_1_required)
  289. else
  290. 3513 emit(:response, request, response)
  291. end
  292. end
  293. 3537 send(@pending.shift) unless @pending.empty?
  294. 3537 return unless @streams.empty? && exhausted?
  295. 8 if @pending.empty?
  296. close
  297. else
  298. 8 emit(:exhausted)
  299. end
  300. end
  301. 27 def on_frame(bytes)
  302. 21081 @buffer << bytes
  303. end
  304. 27 def on_settings(*)
  305. 3339 @handshake_completed = true
  306. 3339 emit(:current_timeout)
  307. 3339 @max_concurrent_requests = [@max_concurrent_requests, @connection.remote_settings[:settings_max_concurrent_streams]].min
  308. 3339 send_pending
  309. end
  310. 27 def on_close(_last_frame, error, _payload)
  311. 39 is_connection_closed = @connection.state == :closed
  312. 39 if error
  313. 39 @buffer.clear if is_connection_closed
  314. 39 case error
  315. when :http_1_1_required
  316. 20 while (request = @pending.shift)
  317. 8 emit(:error, request, error)
  318. end
  319. else
  320. 31 ex = GoawayError.new(error)
  321. 31 ex.set_backtrace(caller)
  322. 31 @pending.unshift(*@streams.keys)
  323. 31 teardown
  324. 31 handle_error(ex)
  325. end
  326. end
  327. 39 return unless is_connection_closed && @streams.empty?
  328. 39 emit(:close) if is_connection_closed
  329. end
  330. 27 def on_frame_sent(frame)
  331. 17784 log(level: 2) { "#{frame[:stream]}: frame was sent!" }
  332. 17688 log(level: 2, color: :blue) do
  333. payload =
  334. 94 case frame[:type]
  335. when :data
  336. 24 frame.merge(payload: frame[:payload].bytesize)
  337. when :headers, :ping
  338. 24 frame.merge(payload: log_redact_headers(frame[:payload]))
  339. else
  340. 48 frame
  341. end
  342. 96 "#{frame[:stream]}: #{payload}"
  343. end
  344. end
  345. 27 def on_frame_received(frame)
  346. 18650 log(level: 2) { "#{frame[:stream]}: frame was received!" }
  347. 18582 log(level: 2, color: :magenta) do
  348. payload =
  349. 66 case frame[:type]
  350. when :data
  351. 20 frame.merge(payload: frame[:payload].bytesize)
  352. when :headers, :ping
  353. 16 frame.merge(payload: log_redact_headers(frame[:payload]))
  354. else
  355. 32 frame
  356. end
  357. 68 "#{frame[:stream]}: #{payload}"
  358. end
  359. end
  360. 27 def on_altsvc(origin, frame)
  361. log(level: 2) { "#{frame[:stream]}: altsvc frame was received" }
  362. log(level: 2) { "#{frame[:stream]}: #{log_redact_headers(frame.inspect)}" }
  363. alt_origin = URI.parse("#{frame[:proto]}://#{frame[:host]}:#{frame[:port]}")
  364. params = { "ma" => frame[:max_age] }
  365. emit(:altsvc, origin, alt_origin, origin, params)
  366. end
  367. 27 def on_promise(stream)
  368. 24 emit(:promise, @streams.key(stream.parent), stream)
  369. end
  370. 27 def on_origin(origin)
  371. emit(:origin, origin)
  372. end
  373. 27 def on_pong(ping)
  374. 8 raise PingError unless @pings.delete(ping.to_s)
  375. 8 emit(:pong)
  376. end
  377. 27 def teardown(request = nil)
  378. 3576 if request
  379. 3545 @drains.delete(request)
  380. 3545 @streams.delete(request)
  381. else
  382. 31 @drains.clear
  383. 31 @streams.clear
  384. end
  385. end
  386. end
  387. 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. 27 require "ipaddr"
  28. 27 module HTTPX
  29. # Represents a domain name ready for extracting its registered domain
  30. # and TLD.
  31. 27 class DomainName
  32. 27 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. 27 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. 27 attr_reader :domain
  47. 27 class << self
  48. 27 def new(domain)
  49. 856 return domain if domain.is_a?(self)
  50. 792 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. 27 def normalize(domain)
  55. 760 unless domain.ascii_only?
  56. domain = domain.chomp(".").unicode_normalize(:nfc)
  57. domain = Punycode.encode_hostname(domain)
  58. end
  59. 760 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. 27 def initialize(hostname)
  65. 792 hostname = String(hostname)
  66. 792 raise ArgumentError, "domain name must not start with a dot: #{hostname}" if hostname.start_with?(".")
  67. 196 begin
  68. 792 @ipaddr = IPAddr.new(hostname)
  69. 32 @hostname = @ipaddr.to_s
  70. 32 return
  71. rescue IPAddr::Error
  72. 760 nil
  73. end
  74. 760 @hostname = DomainName.normalize(hostname)
  75. 760 tld = if (last_dot = @hostname.rindex("."))
  76. 184 @hostname[(last_dot + 1)..-1]
  77. else
  78. 576 @hostname
  79. end
  80. # unknown/local TLD
  81. 760 @domain = if last_dot
  82. # fallback - accept cookies down to second level
  83. # cf. http://www.dkim-reputation.org/regdom-libs/
  84. 184 if (penultimate_dot = @hostname.rindex(".", last_dot - 1))
  85. 48 @hostname[(penultimate_dot + 1)..-1]
  86. else
  87. 136 @hostname
  88. end
  89. else
  90. # no domain part - must be a local hostname
  91. 576 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. 27 def cookie_domain?(domain, host_only = false)
  100. # RFC 6265 #5.3
  101. # When the user agent "receives a cookie":
  102. 32 return self == @domain if host_only
  103. 32 domain = DomainName.new(domain)
  104. # RFC 6265 #5.1.3
  105. # Do not perform subdomain matching against IP addresses.
  106. 32 @hostname == domain.hostname if @ipaddr
  107. # RFC 6265 #4.1.1
  108. # Domain-value must be a subdomain.
  109. 32 @domain && self <= domain && domain <= @domain
  110. end
  111. 27 def <=>(other)
  112. 48 other = DomainName.new(other)
  113. 48 othername = other.hostname
  114. 48 if othername == @hostname
  115. 16 0
  116. 30 elsif @hostname.end_with?(othername) && @hostname[-othername.size - 1, 1] == "."
  117. # The other is higher
  118. 16 -1
  119. else
  120. # The other is lower
  121. 16 1
  122. end
  123. end
  124. end
  125. end

lib/httpx/errors.rb

97.67% lines covered

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

lib/httpx/extensions.rb

95.24% lines covered

21 relevant lines. 20 lines covered and 1 lines missed.
    
  1. # frozen_string_literal: true
  2. 27 require "uri"
  3. 27 module HTTPX
  4. 27 module ArrayExtensions
  5. 27 module Intersect
  6. refine Array do
  7. # Ruby 3.1 backport
  8. 4 def intersect?(arr)
  9. 18 if size < arr.size
  10. smaller = self
  11. else
  12. 18 smaller, arr = arr, self
  13. end
  14. 18 (arr & smaller).size > 0
  15. end
  16. 25 end unless Array.method_defined?(:intersect?)
  17. end
  18. end
  19. 27 module URIExtensions
  20. # uri 0.11 backport, ships with ruby 3.1
  21. 27 refine URI::Generic do
  22. 27 def non_ascii_hostname
  23. 722 @non_ascii_hostname
  24. end
  25. 27 def non_ascii_hostname=(hostname)
  26. 32 @non_ascii_hostname = hostname
  27. end
  28. def authority
  29. 6034 return host if port == default_port
  30. 657 "#{host}:#{port}"
  31. 25 end unless URI::HTTP.method_defined?(:authority)
  32. def origin
  33. 4931 "#{scheme}://#{authority}"
  34. 25 end unless URI::HTTP.method_defined?(:origin)
  35. end
  36. end
  37. 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. 27 module HTTPX
  3. 27 class Headers
  4. 27 class << self
  5. 27 def new(headers = nil)
  6. 26834 return headers if headers.is_a?(self)
  7. 12214 super
  8. end
  9. end
  10. 27 def initialize(headers = nil)
  11. 12214 if headers.nil? || headers.empty?
  12. 1776 @headers = headers.to_h
  13. 1776 return
  14. end
  15. 10438 @headers = {}
  16. 10438 headers.each do |field, value|
  17. 64356 field = downcased(field)
  18. 64356 value = array_value(value)
  19. 64356 current = @headers[field]
  20. 64356 if current.nil?
  21. 64302 @headers[field] = value
  22. else
  23. 54 current.concat(value)
  24. end
  25. end
  26. end
  27. # cloned initialization
  28. 27 def initialize_clone(orig, **kwargs)
  29. 8 super
  30. 8 @headers = orig.instance_variable_get(:@headers).clone(**kwargs)
  31. end
  32. # dupped initialization
  33. 27 def initialize_dup(orig)
  34. 20960 super
  35. 20960 @headers = orig.instance_variable_get(:@headers).dup
  36. end
  37. # freezes the headers hash
  38. 27 def freeze
  39. 21550 @headers.freeze
  40. 21550 super
  41. end
  42. # merges headers with another header-quack.
  43. # the merge rule is, if the header already exists,
  44. # ignore what the +other+ headers has. Otherwise, set
  45. #
  46. 27 def merge(other)
  47. 5138 headers = dup
  48. 5138 other.each do |field, value|
  49. 14128 headers[downcased(field)] = value
  50. end
  51. 5138 headers
  52. end
  53. # returns the comma-separated values of the header field
  54. # identified by +field+, or nil otherwise.
  55. #
  56. 27 def [](field)
  57. 66654 a = @headers[downcased(field)] || return
  58. 27727 a.join(", ")
  59. end
  60. # sets +value+ (if not nil) as single value for the +field+ header.
  61. #
  62. 27 def []=(field, value)
  63. 25832 return unless value
  64. 25832 @headers[downcased(field)] = array_value(value)
  65. end
  66. # deletes all values associated with +field+ header.
  67. #
  68. 27 def delete(field)
  69. 294 canonical = downcased(field)
  70. 294 @headers.delete(canonical) if @headers.key?(canonical)
  71. end
  72. # adds additional +value+ to the existing, for header +field+.
  73. #
  74. 27 def add(field, value)
  75. 592 (@headers[downcased(field)] ||= []) << String(value)
  76. end
  77. # helper to be used when adding an header field as a value to another field
  78. #
  79. # h2_headers.add_header("vary", "accept-encoding")
  80. # h2_headers["vary"] #=> "accept-encoding"
  81. # h1_headers.add_header("vary", "accept-encoding")
  82. # h1_headers["vary"] #=> "Accept-Encoding"
  83. #
  84. 27 alias_method :add_header, :add
  85. # returns the enumerable headers store in pairs of header field + the values in
  86. # the comma-separated string format
  87. #
  88. 27 def each(extra_headers = nil)
  89. 70772 return enum_for(__method__, extra_headers) { @headers.size } unless block_given?
  90. 37770 @headers.each do |field, value|
  91. 128036 yield(field, value.join(", ")) unless value.empty?
  92. end
  93. 5290 extra_headers.each do |field, value|
  94. 25521 yield(field, value) unless value.empty?
  95. 37768 end if extra_headers
  96. end
  97. 27 def ==(other)
  98. 22116 other == to_hash
  99. end
  100. 27 def empty?
  101. 320 @headers.empty?
  102. end
  103. # the headers store in Hash format
  104. 27 def to_hash
  105. 23821 Hash[to_a]
  106. end
  107. 27 alias_method :to_h, :to_hash
  108. # the headers store in array of pairs format
  109. 27 def to_a
  110. 23844 Array(each)
  111. end
  112. # headers as string
  113. 27 def to_s
  114. 1948 @headers.to_s
  115. end
  116. skipped # :nocov:
  117. skipped def inspect
  118. skipped "#<#{self.class}:#{object_id} " \
  119. skipped "#{to_hash.inspect}>"
  120. skipped end
  121. skipped # :nocov:
  122. # this is internal API and doesn't abide to other public API
  123. # guarantees, like downcasing strings.
  124. # Please do not use this outside of core!
  125. #
  126. 27 def key?(downcased_key)
  127. 75704 @headers.key?(downcased_key)
  128. end
  129. # returns the values for the +field+ header in array format.
  130. # This method is more internal, and for this reason doesn't try
  131. # to "correct" the user input, i.e. it doesn't downcase the key.
  132. #
  133. 27 def get(field)
  134. 299 @headers[field] || EMPTY
  135. end
  136. 27 private
  137. 27 def array_value(value)
  138. 90188 Array(value)
  139. end
  140. 27 def downcased(field)
  141. 171856 String(field).downcase
  142. end
  143. end
  144. 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. 27 require "socket"
  3. 27 require "httpx/io/udp"
  4. 27 require "httpx/io/tcp"
  5. 27 require "httpx/io/unix"
  6. begin
  7. 27 require "httpx/io/ssl"
  8. rescue LoadError
  9. end

lib/httpx/io/ssl.rb

97.5% lines covered

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

lib/httpx/io/tcp.rb

92.19% lines covered

128 relevant lines. 118 lines covered and 10 lines missed.
    
  1. # frozen_string_literal: true
  2. 27 require "resolv"
  3. 27 module HTTPX
  4. 27 class TCP
  5. 27 include Loggable
  6. 27 using URIExtensions
  7. 27 attr_reader :ip, :port, :addresses, :state, :interests
  8. 27 alias_method :host, :ip
  9. 27 def initialize(origin, addresses, options)
  10. 7423 @state = :idle
  11. 7423 @keep_open = false
  12. 7423 @addresses = []
  13. 7423 @ip_index = -1
  14. 7423 @ip = nil
  15. 7423 @hostname = origin.host
  16. 7423 @options = options
  17. 7423 @fallback_protocol = @options.fallback_protocol
  18. 7423 @port = origin.port
  19. 7423 @interests = :w
  20. 7423 if @options.io
  21. 52 @io = case @options.io
  22. when Hash
  23. 16 @options.io[origin.authority]
  24. else
  25. 36 @options.io
  26. end
  27. 52 raise Error, "Given IO objects do not match the request authority" unless @io
  28. 52 _, _, _, ip = @io.addr
  29. 52 @ip = Resolver::Entry.new(ip)
  30. 52 @addresses << @ip
  31. 52 @keep_open = true
  32. 52 @state = :connected
  33. else
  34. 7371 add_addresses(addresses)
  35. end
  36. 7423 @ip_index = @addresses.size - 1
  37. end
  38. 27 def socket
  39. 195 @io
  40. end
  41. 27 def add_addresses(addrs)
  42. 7741 return if addrs.empty?
  43. 7733 ip_index = @ip_index || (@addresses.size - 1)
  44. 7733 if addrs.first.ipv6?
  45. # should be the next in line
  46. 341 @addresses = [*@addresses[0, ip_index], *addrs, *@addresses[ip_index..-1]]
  47. else
  48. 7392 @addresses.unshift(*addrs)
  49. end
  50. 7733 @ip_index += addrs.size
  51. end
  52. # eliminates expired entries and returns whether there are still any left.
  53. 27 def addresses?
  54. 682 prev_addr_size = @addresses.size
  55. 682 @addresses.delete_if(&:expired?).sort! do |addr1, addr2|
  56. 1458 if addr1.ipv6?
  57. addr2.ipv6? ? 0 : 1
  58. else
  59. 1458 addr2.ipv6? ? -1 : 0
  60. end
  61. end
  62. 682 @ip_index = @addresses.size - 1 if prev_addr_size != @addresses.size
  63. 682 @addresses.any?
  64. end
  65. 27 def to_io
  66. 27058 @io.to_io
  67. end
  68. 27 def protocol
  69. 4599 @fallback_protocol
  70. end
  71. 27 def connect
  72. 29135 return unless closed?
  73. 29008 if @addresses.empty?
  74. # an idle connection trying to connect with no available addresses is a connection
  75. # out of the initial context which is back to the DNS resolution loop. This may
  76. # happen in a fiber-aware context where a connection reconnects with expired addresses,
  77. # and context is passed back to a fiber on the same connection while waiting for the
  78. # DNS answer.
  79. log { "tried connecting while resolving, skipping..." }
  80. return
  81. end
  82. 29008 if !@io || @io.closed?
  83. 8070 transition(:idle)
  84. 8070 @io = build_socket
  85. end
  86. 29008 try_connect
  87. rescue Errno::EHOSTUNREACH,
  88. Errno::ENETUNREACH => e
  89. 42 @ip_index -= 1
  90. 42 raise e if @ip_index.negative?
  91. 34 log { "failed connecting to #{@ip} (#{e.message}), evict from cache and trying next..." }
  92. 34 Resolver.cached_lookup_evict(@hostname, @ip)
  93. 34 @io = build_socket
  94. 34 retry
  95. rescue Errno::ECONNREFUSED,
  96. Errno::EADDRNOTAVAIL,
  97. SocketError,
  98. IOError => e
  99. 893 @ip_index -= 1
  100. 893 raise e if @ip_index.negative?
  101. 837 log { "failed connecting to #{@ip} (#{e.message}), trying next..." }
  102. 825 @io = build_socket
  103. 825 retry
  104. rescue Errno::ETIMEDOUT => e
  105. @ip_index -= 1
  106. raise ConnectTimeoutError.new(@options.timeout[:connect_timeout], e.message) if @ip_index.negative?
  107. log { "failed connecting to #{@ip} (#{e.message}), trying next..." }
  108. @io = build_socket
  109. retry
  110. end
  111. 27 def try_connect
  112. 29008 ret = @io.connect_nonblock(Socket.sockaddr_in(@port, @ip.to_s), exception: false)
  113. 17035 log(level: 3, color: :cyan) { "TCP CONNECT: #{ret}..." }
  114. 16883 case ret
  115. when :wait_readable
  116. @interests = :r
  117. return
  118. when :wait_writable
  119. 8917 @interests = :w
  120. 8917 return
  121. end
  122. 7966 transition(:connected)
  123. 7966 @interests = :w
  124. rescue Errno::EALREADY
  125. 11190 @interests = :w
  126. end
  127. 27 private :try_connect
  128. 27 def read(size, buffer)
  129. 57283 ret = @io.read_nonblock(size, buffer, exception: false)
  130. 57281 if ret == :wait_readable
  131. 13768 buffer.clear
  132. 13768 return 0
  133. end
  134. 43513 return if ret.nil?
  135. 43593 log { "READ: #{buffer.bytesize} bytes..." }
  136. 43497 buffer.bytesize
  137. end
  138. 27 def write(buffer)
  139. 19506 siz = @io.write_nonblock(buffer, exception: false)
  140. 19492 return 0 if siz == :wait_writable
  141. 19476 return if siz.nil?
  142. 19572 log { "WRITE: #{siz} bytes..." }
  143. 19476 buffer.shift!(siz)
  144. 19476 siz
  145. end
  146. 27 def close
  147. 8853 return if @keep_open || closed?
  148. 1766 begin
  149. 7787 @io.close
  150. ensure
  151. 7787 transition(:closed)
  152. end
  153. end
  154. 27 def connected?
  155. 20657 @state == :connected
  156. end
  157. 27 def closed?
  158. 38076 @state == :idle || @state == :closed
  159. end
  160. skipped # :nocov:
  161. skipped def inspect
  162. skipped "#<#{self.class}:#{object_id} " \
  163. skipped "#{@ip}:#{@port} " \
  164. skipped "@state=#{@state} " \
  165. skipped "@hostname=#{@hostname} " \
  166. skipped "@addresses=#{@addresses} " \
  167. skipped "@state=#{@state}>"
  168. skipped end
  169. skipped # :nocov:
  170. 27 private
  171. 27 def build_socket
  172. 8929 @ip = @addresses[@ip_index]
  173. 8929 Socket.new(@ip.family, :STREAM, 0)
  174. end
  175. 27 def transition(nextstate)
  176. 13992 case nextstate
  177. # when :idle
  178. when :connected
  179. 4709 return unless @state == :idle
  180. when :closed
  181. 4537 return unless @state == :connected
  182. end
  183. 13992 do_transition(nextstate)
  184. end
  185. 27 def do_transition(nextstate)
  186. 27383 log(level: 1) { log_transition_state(nextstate) }
  187. 27163 @state = nextstate
  188. end
  189. 27 def log_transition_state(nextstate)
  190. 220 label = host
  191. 220 label = "#{label}(##{@io.fileno})" if nextstate == :connected
  192. 220 "#{label} #{@state} -> #{nextstate}"
  193. end
  194. end
  195. 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. 27 require "ipaddr"
  3. 27 module HTTPX
  4. 27 class UDP
  5. 27 include Loggable
  6. 27 def initialize(ip, port, options)
  7. 456 @host = ip
  8. 456 @port = port
  9. 456 @io = UDPSocket.new(IPAddr.new(ip).family)
  10. 456 @options = options
  11. end
  12. 27 def to_io
  13. 1286 @io.to_io
  14. end
  15. 27 def connect; end
  16. 27 def connected?
  17. 456 true
  18. end
  19. 27 def close
  20. 458 @io.close
  21. end
  22. 27 if RUBY_ENGINE == "jruby"
  23. # In JRuby, sendmsg_nonblock is not implemented
  24. 2 def write(buffer)
  25. 102 siz = @io.send(buffer.to_s, 0, @host, @port)
  26. 102 log { "WRITE: #{siz} bytes..." }
  27. 102 buffer.shift!(siz)
  28. 102 siz
  29. end
  30. else
  31. 25 def write(buffer)
  32. 610 siz = @io.sendmsg_nonblock(buffer.to_s, 0, Socket.sockaddr_in(@port, @host.to_s), exception: false)
  33. 610 return 0 if siz == :wait_writable
  34. 610 return if siz.nil?
  35. 610 log { "WRITE: #{siz} bytes..." }
  36. 610 buffer.shift!(siz)
  37. 610 siz
  38. end
  39. end
  40. 27 def read(size, buffer)
  41. 1294 ret = @io.recvfrom_nonblock(size, 0, buffer, exception: false)
  42. 1294 return 0 if ret == :wait_readable
  43. 659 return if ret.nil?
  44. 659 log { "READ: #{buffer.bytesize} bytes..." }
  45. 659 buffer.bytesize
  46. rescue IOError
  47. end
  48. end
  49. end

lib/httpx/io/unix.rb

97.14% lines covered

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

lib/httpx/loggable.rb

100.0% lines covered

25 relevant lines. 25 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 27 module HTTPX
  3. 27 module Loggable
  4. 27 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. 27 USE_DEBUG_LOG = ENV.key?("HTTPX_DEBUG")
  15. 27 def log(
  16. level: @options.debug_level,
  17. color: nil,
  18. debug_level: @options.debug_level,
  19. debug: @options.debug,
  20. &msg
  21. )
  22. 9866277 return unless debug_level >= level
  23. 288780 debug_stream = debug || ($stderr if USE_DEBUG_LOG)
  24. 288780 return unless debug_stream
  25. 4280 klass = self.class
  26. 9468 until (class_name = klass.name)
  27. 2300 klass = klass.superclass
  28. end
  29. 4280 message = +"(time:#{Time.now.utc}, pid:#{Process.pid}, " \
  30. "tid:#{Thread.current.object_id}, " \
  31. "fid:#{Fiber.current.object_id}, " \
  32. "self:#{class_name}##{object_id}) "
  33. 4280 message << msg.call << "\n"
  34. 4280 message = "\e[#{COLORS[color]}m#{message}\e[0m" if color && debug_stream.respond_to?(:isatty) && debug_stream.isatty
  35. 4280 debug_stream << message
  36. end
  37. 27 def log_exception(ex, level: @options.debug_level, color: nil, debug_level: @options.debug_level, debug: @options.debug)
  38. 1264 log(level: level, color: color, debug_level: debug_level, debug: debug) { ex.full_message }
  39. end
  40. 27 def log_redact_headers(text)
  41. 928 log_redact(text, @options.debug_redact == :headers)
  42. end
  43. 27 def log_redact_body(text)
  44. 92 log_redact(text, @options.debug_redact == :body)
  45. end
  46. 27 def log_redact(text, should_redact)
  47. 1020 should_redact ||= @options.debug_redact == true
  48. 1020 return text.to_s unless should_redact
  49. 224 "[REDACTED]"
  50. end
  51. end
  52. end

lib/httpx/options.rb

98.47% lines covered

196 relevant lines. 193 lines covered and 3 lines missed.
    
  1. # frozen_string_literal: true
  2. 27 module HTTPX
  3. # Contains a set of options which are passed and shared across from session to its requests or
  4. # responses.
  5. 27 class Options
  6. 27 BUFFER_SIZE = 1 << 14
  7. 27 WINDOW_SIZE = 1 << 14 # 16K
  8. 27 MAX_BODY_THRESHOLD_SIZE = (1 << 10) * 112 # 112K
  9. 27 KEEP_ALIVE_TIMEOUT = 20
  10. 27 SETTINGS_TIMEOUT = 10
  11. 27 CLOSE_HANDSHAKE_TIMEOUT = 10
  12. 27 CONNECT_TIMEOUT = READ_TIMEOUT = WRITE_TIMEOUT = 60
  13. 27 REQUEST_TIMEOUT = OPERATION_TIMEOUT = nil
  14. # default value used for "user-agent" header, when not overridden.
  15. 27 USER_AGENT = "httpx.rb/#{VERSION}".freeze # rubocop:disable Style/RedundantFreeze
  16. 27 @options_names = []
  17. 27 class << self
  18. 27 attr_reader :options_names
  19. 27 def inherited(klass)
  20. 44 super
  21. 44 klass.instance_variable_set(:@options_names, @options_names.dup)
  22. end
  23. 27 def new(options = {})
  24. # let enhanced options go through
  25. 12455 return options if self == Options && options.class < self
  26. 9625 return options if options.is_a?(self)
  27. 4581 super
  28. end
  29. 27 def freeze
  30. 12987 @options_names.freeze
  31. 12987 super
  32. end
  33. 27 def method_added(meth)
  34. 21939 super
  35. 21939 return unless meth =~ /^option_(.+)$/
  36. 10205 optname = Regexp.last_match(1)
  37. 10205 if optname =~ /^(.+[^_])_+with/
  38. # ignore alias method chain generated methods.
  39. # this is the case with RBS runtime tests.
  40. # it relies on the "_with/_without" separator, which is the most used convention,
  41. # however it shouldn't be used in practice in httpx given the plugin architecture
  42. # as the main extension API.
  43. orig_name = Regexp.last_match(1)
  44. return if @options_names.include?(orig_name.to_sym)
  45. end
  46. 10205 optname = optname.to_sym
  47. 10205 attr_reader(optname)
  48. 10205 @options_names << optname unless @options_names.include?(optname)
  49. end
  50. end
  51. # creates a new options instance from a given hash, which optionally define the following:
  52. #
  53. # :debug :: an object which log messages are written to (must respond to <tt><<</tt>)
  54. # :debug_level :: the log level of messages (can be 1, 2, or 3).
  55. # :debug_redact :: whether header/body payload should be redacted (defaults to <tt>false</tt>).
  56. # :ssl :: a hash of options which can be set as params of OpenSSL::SSL::SSLContext (see HTTPX::SSL)
  57. # :http2_settings :: a hash of options to be passed to a HTTP2::Connection (ex: <tt>{ max_concurrent_streams: 2 }</tt>)
  58. # :fallback_protocol :: version of HTTP protocol to use by default in the absence of protocol negotiation
  59. # like ALPN (defaults to <tt>"http/1.1"</tt>)
  60. # :supported_compression_formats :: list of compressions supported by the transcoder layer (defaults to <tt>%w[gzip deflate]</tt>).
  61. # :decompress_response_body :: whether to auto-decompress response body (defaults to <tt>true</tt>).
  62. # :compress_request_body :: whether to auto-decompress response body (defaults to <tt>true</tt>)
  63. # :timeout :: hash of timeout configurations (supports <tt>:connect_timeout</tt>, <tt>:settings_timeout</tt>,
  64. # <tt>:operation_timeout</tt>, <tt>:keep_alive_timeout</tt>, <tt>:read_timeout</tt>, <tt>:write_timeout</tt>
  65. # and <tt>:request_timeout</tt>
  66. # :headers :: hash of HTTP headers (ex: <tt>{ "x-custom-foo" => "bar" }</tt>)
  67. # :window_size :: number of bytes to read from a socket
  68. # :buffer_size :: internal read and write buffer size in bytes
  69. # :body_threshold_size :: maximum size in bytes of response payload that is buffered in memory.
  70. # :request_class :: class used to instantiate a request
  71. # :response_class :: class used to instantiate a response
  72. # :headers_class :: class used to instantiate headers
  73. # :request_body_class :: class used to instantiate a request body
  74. # :response_body_class :: class used to instantiate a response body
  75. # :connection_class :: class used to instantiate connections
  76. # :http1_class :: class used to manage HTTP1 sessions
  77. # :http2_class :: class used to imanage HTTP2 sessions
  78. # :resolver_native_class :: class used to resolve names using pure ruby DNS implementation
  79. # :resolver_system_class :: class used to resolve names using system-based (getaddrinfo) name resolution
  80. # :resolver_https_class :: class used to resolve names using DoH
  81. # :pool_class :: class used to instantiate the session connection pool
  82. # :options_class :: class used to instantiate options
  83. # :transport :: type of transport to use (set to "unix" for UNIX sockets)
  84. # :addresses :: bucket of peer addresses (can be a list of IP addresses, a hash of domain to list of adddresses;
  85. # paths should be used for UNIX sockets instead)
  86. # :io :: open socket, or domain/ip-to-socket hash, which requests should be sent to
  87. # :persistent :: whether to persist connections in between requests (defaults to <tt>true</tt>)
  88. # :resolver_class :: which resolver to use (defaults to <tt>:native</tt>, can also be <tt>:system<tt> for
  89. # using getaddrinfo or <tt>:https</tt> for DoH resolver, or a custom class)
  90. # :resolver_options :: hash of options passed to the resolver. Accepted keys depend on the resolver type.
  91. # :pool_options :: hash of options passed to the connection pool (See Pool#initialize).
  92. # :ip_families :: which socket families are supported (system-dependent)
  93. # :origin :: HTTP origin to set on requests with relative path (ex: "https://api.serv.com")
  94. # :base_path :: path to prefix given relative paths with (ex: "/v2")
  95. # :max_concurrent_requests :: max number of requests which can be set concurrently
  96. # :max_requests :: max number of requests which can be made on socket before it reconnects.
  97. # :close_on_fork :: whether the session automatically closes when the process is fork (defaults to <tt>false</tt>).
  98. # it only works if the session is persistent (and ruby 3.1 or higher is used).
  99. #
  100. # This list of options are enhanced with each loaded plugin, see the plugin docs for details.
  101. 27 def initialize(options = EMPTY_HASH)
  102. 4581 options_names = self.class.options_names
  103. 1032 defaults =
  104. 3549 case options
  105. when Options
  106. 3093 unknown_options = options.class.options_names - options_names
  107. 3093 raise Error, "unknown option: #{unknown_options.first}" unless unknown_options.empty?
  108. 3093 DEFAULT_OPTIONS.merge(options)
  109. else
  110. 1488 options.each_key do |k|
  111. 10251 raise Error, "unknown option: #{k}" unless options_names.include?(k)
  112. end
  113. 1480 options.empty? ? DEFAULT_OPTIONS : DEFAULT_OPTIONS.merge(options)
  114. end
  115. 4573 options_names.each do |k|
  116. 194483 v = defaults[k]
  117. 194483 if v.nil?
  118. 47243 instance_variable_set(:"@#{k}", v)
  119. 47243 next
  120. end
  121. 147240 value = __send__(:"option_#{k}", v)
  122. 147232 instance_variable_set(:"@#{k}", value)
  123. end
  124. 4565 do_initialize
  125. 4565 freeze
  126. end
  127. 27 def freeze
  128. 12986 self.class.options_names.each do |ivar|
  129. # avoid freezing debug option, as when it's set, it's usually an
  130. # object which cannot be frozen, like stderr or stdout. It's a
  131. # documented exception then, and still does not defeat the purpose
  132. # here, which is to make option objects shareable across ractors,
  133. # and in most cases debug should be nil, or one of the objects
  134. # which will eventually be shareable, like STDOUT or STDERR.
  135. 552892 next if ivar == :debug
  136. 539906 instance_variable_get(:"@#{ivar}").freeze
  137. end
  138. 12986 super
  139. end
  140. 27 REQUEST_BODY_IVARS = %i[@headers].freeze
  141. 27 def ==(other)
  142. 2061 super || options_equals?(other)
  143. end
  144. 27 def options_equals?(other, ignore_ivars = REQUEST_BODY_IVARS)
  145. # headers and other request options do not play a role, as they are
  146. # relevant only for the request.
  147. 487 ivars = instance_variables - ignore_ivars
  148. 487 other_ivars = other.instance_variables - ignore_ivars
  149. 487 return false if ivars.size != other_ivars.size
  150. 487 return false if ivars.sort != other_ivars.sort
  151. 487 ivars.all? do |ivar|
  152. 19320 instance_variable_get(ivar) == other.instance_variable_get(ivar)
  153. end
  154. end
  155. 27 def merge(other)
  156. 37984 ivar_map = nil
  157. 37984 other_ivars = case other
  158. when Options
  159. 11643 other.instance_variables
  160. else
  161. 26341 other = Hash[other] unless other.is_a?(Hash)
  162. 45854 ivar_map = other.keys.to_h { |k| [:"@#{k}", k] }
  163. 26334 ivar_map.keys
  164. end
  165. 37977 return self if other_ivars.empty?
  166. 474175 return self if other_ivars.all? { |ivar| instance_variable_get(ivar) == access_option(other, ivar, ivar_map) }
  167. 13941 opts = dup
  168. 13941 other_ivars.each do |ivar|
  169. 145920 v = access_option(other, ivar, ivar_map)
  170. 145920 unless v
  171. 37447 opts.instance_variable_set(ivar, v)
  172. 37447 next
  173. end
  174. 108473 v = opts.__send__(:"option_#{ivar[1..-1]}", v)
  175. 108457 orig_v = instance_variable_get(ivar)
  176. 108457 v = orig_v.merge(v) if orig_v.respond_to?(:merge) && v.respond_to?(:merge)
  177. 108457 opts.instance_variable_set(ivar, v)
  178. end
  179. 13925 opts
  180. end
  181. 27 def to_hash
  182. 3559 instance_variables.each_with_object({}) do |ivar, hs|
  183. 145086 val = instance_variable_get(ivar)
  184. 145086 next if val.nil?
  185. 114880 hs[ivar[1..-1].to_sym] = val
  186. end
  187. end
  188. 27 def extend_with_plugin_classes(pl)
  189. # extend request class
  190. 8378 if defined?(pl::RequestMethods) || defined?(pl::RequestClassMethods)
  191. 2711 @request_class = @request_class.dup
  192. 2711 SET_TEMPORARY_NAME[@request_class, pl]
  193. 2711 @request_class.__send__(:include, pl::RequestMethods) if defined?(pl::RequestMethods)
  194. 2711 @request_class.extend(pl::RequestClassMethods) if defined?(pl::RequestClassMethods)
  195. end
  196. # extend response class
  197. 8378 if defined?(pl::ResponseMethods) || defined?(pl::ResponseClassMethods)
  198. 2421 @response_class = @response_class.dup
  199. 2421 SET_TEMPORARY_NAME[@response_class, pl]
  200. 2421 @response_class.__send__(:include, pl::ResponseMethods) if defined?(pl::ResponseMethods)
  201. 2421 @response_class.extend(pl::ResponseClassMethods) if defined?(pl::ResponseClassMethods)
  202. end
  203. # extend headers class
  204. 8378 if defined?(pl::HeadersMethods) || defined?(pl::HeadersClassMethods)
  205. 160 @headers_class = @headers_class.dup
  206. 160 SET_TEMPORARY_NAME[@headers_class, pl]
  207. 160 @headers_class.__send__(:include, pl::HeadersMethods) if defined?(pl::HeadersMethods)
  208. 160 @headers_class.extend(pl::HeadersClassMethods) if defined?(pl::HeadersClassMethods)
  209. end
  210. # extend request body class
  211. 8378 if defined?(pl::RequestBodyMethods) || defined?(pl::RequestBodyClassMethods)
  212. 338 @request_body_class = @request_body_class.dup
  213. 338 SET_TEMPORARY_NAME[@request_body_class, pl]
  214. 338 @request_body_class.__send__(:include, pl::RequestBodyMethods) if defined?(pl::RequestBodyMethods)
  215. 338 @request_body_class.extend(pl::RequestBodyClassMethods) if defined?(pl::RequestBodyClassMethods)
  216. end
  217. # extend response body class
  218. 8378 if defined?(pl::ResponseBodyMethods) || defined?(pl::ResponseBodyClassMethods)
  219. 1024 @response_body_class = @response_body_class.dup
  220. 1024 SET_TEMPORARY_NAME[@response_body_class, pl]
  221. 1024 @response_body_class.__send__(:include, pl::ResponseBodyMethods) if defined?(pl::ResponseBodyMethods)
  222. 1024 @response_body_class.extend(pl::ResponseBodyClassMethods) if defined?(pl::ResponseBodyClassMethods)
  223. end
  224. # extend connection pool class
  225. 8378 if defined?(pl::PoolMethods)
  226. 798 @pool_class = @pool_class.dup
  227. 798 SET_TEMPORARY_NAME[@pool_class, pl]
  228. 798 @pool_class.__send__(:include, pl::PoolMethods)
  229. end
  230. # extend connection class
  231. 8378 if defined?(pl::ConnectionMethods)
  232. 3944 @connection_class = @connection_class.dup
  233. 3944 SET_TEMPORARY_NAME[@connection_class, pl]
  234. 3944 @connection_class.__send__(:include, pl::ConnectionMethods)
  235. end
  236. # extend http1 class
  237. 8378 if defined?(pl::HTTP1Methods)
  238. 536 @http1_class = @http1_class.dup
  239. 536 SET_TEMPORARY_NAME[@http1_class, pl]
  240. 536 @http1_class.__send__(:include, pl::HTTP1Methods)
  241. end
  242. # extend http2 class
  243. 8378 if defined?(pl::HTTP2Methods)
  244. 552 @http2_class = @http2_class.dup
  245. 552 SET_TEMPORARY_NAME[@http2_class, pl]
  246. 552 @http2_class.__send__(:include, pl::HTTP2Methods)
  247. end
  248. # extend native resolver class
  249. 8378 if defined?(pl::ResolverNativeMethods)
  250. 900 @resolver_native_class = @resolver_native_class.dup
  251. 900 SET_TEMPORARY_NAME[@resolver_native_class, pl]
  252. 900 @resolver_native_class.__send__(:include, pl::ResolverNativeMethods)
  253. end
  254. # extend system resolver class
  255. 8378 if defined?(pl::ResolverSystemMethods)
  256. 106 @resolver_system_class = @resolver_system_class.dup
  257. 106 SET_TEMPORARY_NAME[@resolver_system_class, pl]
  258. 106 @resolver_system_class.__send__(:include, pl::ResolverSystemMethods)
  259. end
  260. # extend https resolver class
  261. 8378 if defined?(pl::ResolverHTTPSMethods)
  262. 106 @resolver_https_class = @resolver_https_class.dup
  263. 106 SET_TEMPORARY_NAME[@resolver_https_class, pl]
  264. 106 @resolver_https_class.__send__(:include, pl::ResolverHTTPSMethods)
  265. end
  266. 8378 return unless defined?(pl::OptionsMethods)
  267. # extend option class
  268. # works around lack of initialize_dup callback
  269. 3093 @options_class = @options_class.dup
  270. # (self.class.options_names)
  271. 3093 @options_class.__send__(:include, pl::OptionsMethods)
  272. end
  273. 27 private
  274. # number options
  275. 27 %i[
  276. max_concurrent_requests max_requests window_size buffer_size
  277. body_threshold_size debug_level
  278. ].each do |option|
  279. 162 class_eval(<<-OUT, __FILE__, __LINE__ + 1)
  280. # converts +v+ into an Integer before setting the +#{option}+ option.
  281. private def option_#{option}(value) # private def option_max_requests(v)
  282. value = Integer(value) unless value.respond_to?(:infinite?) && value.infinite?
  283. raise TypeError, ":#{option} must be positive" unless value.positive? # raise TypeError, ":max_requests must be positive" unless value.positive?
  284. value
  285. end
  286. OUT
  287. end
  288. # hashable options
  289. 27 %i[ssl http2_settings resolver_options pool_options].each do |option|
  290. 108 class_eval(<<-OUT, __FILE__, __LINE__ + 1)
  291. # converts +v+ into an Hash before setting the +#{option}+ option.
  292. private def option_#{option}(value) # def option_ssl(v)
  293. Hash[value]
  294. end
  295. OUT
  296. end
  297. 27 %i[
  298. request_class response_class headers_class request_body_class
  299. response_body_class connection_class http1_class http2_class
  300. resolver_native_class resolver_system_class resolver_https_class options_class pool_class
  301. io fallback_protocol debug debug_redact resolver_class
  302. compress_request_body decompress_response_body
  303. persistent close_on_fork
  304. ].each do |method_name|
  305. 594 class_eval(<<-OUT, __FILE__, __LINE__ + 1)
  306. # sets +v+ as the value of the +#{method_name}+ option
  307. private def option_#{method_name}(v); v; end # private def option_smth(v); v; end
  308. OUT
  309. end
  310. 27 def option_origin(value)
  311. 608 URI(value)
  312. end
  313. 27 def option_base_path(value)
  314. 32 String(value)
  315. end
  316. 27 def option_headers(value)
  317. 8291 value = value.dup if value.frozen?
  318. 8291 headers_class.new(value)
  319. end
  320. 27 def option_timeout(value)
  321. 8901 Hash[value]
  322. end
  323. 27 def option_supported_compression_formats(value)
  324. 7683 Array(value).map(&:to_s)
  325. end
  326. 27 def option_transport(value)
  327. 42 transport = value.to_s
  328. 42 raise TypeError, "#{transport} is an unsupported transport type" unless %w[unix].include?(transport)
  329. 42 transport
  330. end
  331. 27 def option_addresses(value)
  332. 86 Array(value).map { |entry| Resolver::Entry.convert(entry) }
  333. end
  334. 27 def option_ip_families(value)
  335. 168 Array(value)
  336. end
  337. # called after all options are initialized
  338. 27 def do_initialize
  339. 4565 hs = @headers
  340. # initialized default request headers
  341. 4565 hs["user-agent"] = USER_AGENT unless hs.key?("user-agent")
  342. 4565 hs["accept"] = "*/*" unless hs.key?("accept")
  343. 4565 if hs.key?("range")
  344. hs.delete("accept-encoding")
  345. else
  346. 4565 hs["accept-encoding"] = supported_compression_formats unless hs.key?("accept-encoding")
  347. end
  348. end
  349. 27 def access_option(obj, k, ivar_map)
  350. 596848 case obj
  351. when Hash
  352. 31221 obj[ivar_map[k]]
  353. else
  354. 565627 obj.instance_variable_get(k)
  355. end
  356. end
  357. 27 SET_TEMPORARY_NAME = ->(klass, pl = nil) do
  358. 13947 if klass.respond_to?(:set_temporary_name) # ruby 3.4 only
  359. 5228 name = klass.name || "#{klass.superclass.name}(plugin)"
  360. 5228 name = "#{name}/#{pl}" if pl
  361. 5228 klass.set_temporary_name(name)
  362. end
  363. end
  364. 2 DEFAULT_OPTIONS = {
  365. 25 :max_requests => Float::INFINITY,
  366. :debug => nil,
  367. 27 :debug_level => (ENV["HTTPX_DEBUG"] || 1).to_i,
  368. :debug_redact => ENV.key?("HTTPX_DEBUG_REDACT"),
  369. :ssl => EMPTY_HASH,
  370. :http2_settings => { settings_enable_push: 0 }.freeze,
  371. :fallback_protocol => "http/1.1",
  372. :supported_compression_formats => %w[gzip deflate],
  373. :decompress_response_body => true,
  374. :compress_request_body => true,
  375. :timeout => {
  376. connect_timeout: CONNECT_TIMEOUT,
  377. settings_timeout: SETTINGS_TIMEOUT,
  378. close_handshake_timeout: CLOSE_HANDSHAKE_TIMEOUT,
  379. operation_timeout: OPERATION_TIMEOUT,
  380. keep_alive_timeout: KEEP_ALIVE_TIMEOUT,
  381. read_timeout: READ_TIMEOUT,
  382. write_timeout: WRITE_TIMEOUT,
  383. request_timeout: REQUEST_TIMEOUT,
  384. }.freeze,
  385. :headers_class => Class.new(Headers, &SET_TEMPORARY_NAME),
  386. :headers => EMPTY_HASH,
  387. :window_size => WINDOW_SIZE,
  388. :buffer_size => BUFFER_SIZE,
  389. :body_threshold_size => MAX_BODY_THRESHOLD_SIZE,
  390. :request_class => Class.new(Request, &SET_TEMPORARY_NAME),
  391. :response_class => Class.new(Response, &SET_TEMPORARY_NAME),
  392. :request_body_class => Class.new(Request::Body, &SET_TEMPORARY_NAME),
  393. :response_body_class => Class.new(Response::Body, &SET_TEMPORARY_NAME),
  394. :pool_class => Class.new(Pool, &SET_TEMPORARY_NAME),
  395. :connection_class => Class.new(Connection, &SET_TEMPORARY_NAME),
  396. :http1_class => Class.new(Connection::HTTP1, &SET_TEMPORARY_NAME),
  397. :http2_class => Class.new(Connection::HTTP2, &SET_TEMPORARY_NAME),
  398. :resolver_native_class => Class.new(Resolver::Native, &SET_TEMPORARY_NAME),
  399. :resolver_system_class => Class.new(Resolver::System, &SET_TEMPORARY_NAME),
  400. :resolver_https_class => Class.new(Resolver::HTTPS, &SET_TEMPORARY_NAME),
  401. :options_class => Class.new(self, &SET_TEMPORARY_NAME),
  402. :transport => nil,
  403. :addresses => nil,
  404. :persistent => false,
  405. 27 :resolver_class => (ENV["HTTPX_RESOLVER"] || :native).to_sym,
  406. :resolver_options => { cache: true }.freeze,
  407. :pool_options => EMPTY_HASH,
  408. :ip_families => nil,
  409. :close_on_fork => false,
  410. }.freeze
  411. end
  412. 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. 27 module HTTPX
  3. 27 module Parser
  4. 27 class Error < Error; end
  5. 27 class HTTP1
  6. 27 VERSIONS = %w[1.0 1.1].freeze
  7. 27 attr_reader :status_code, :http_version, :headers
  8. 27 def initialize(observer)
  9. 4749 @observer = observer
  10. 4749 @state = :idle
  11. 4749 @buffer = "".b
  12. 4749 @headers = {}
  13. end
  14. 27 def <<(chunk)
  15. 7491 @buffer << chunk
  16. 7491 parse
  17. end
  18. 27 def reset!
  19. 9687 @state = :idle
  20. 9687 @headers = {}
  21. 9687 @content_length = nil
  22. 9687 @_has_trailers = nil
  23. end
  24. 27 def upgrade?
  25. 4756 @upgrade
  26. end
  27. 27 def upgrade_data
  28. 30 @buffer
  29. end
  30. 27 private
  31. 27 def parse
  32. 7491 loop do
  33. 16066 state = @state
  34. 16066 case @state
  35. when :idle
  36. 5121 parse_headline
  37. when :headers, :trailers
  38. 5207 parse_headers
  39. when :data
  40. 5733 parse_data
  41. end
  42. 11724 return if @buffer.empty? || state == @state
  43. end
  44. end
  45. 27 def parse_headline
  46. 5121 idx = @buffer.index("\n")
  47. 5121 return unless idx
  48. 5121 (m = %r{\AHTTP(?:/(\d+\.\d+))?\s+(\d\d\d)(?:\s+(.*))?}in.match(@buffer)) ||
  49. raise(Error, "wrong head line format")
  50. 5113 version, code, _ = m.captures
  51. 5113 raise(Error, "unsupported HTTP version (HTTP/#{version})") unless version && VERSIONS.include?(version)
  52. 5105 @http_version = version.split(".").map(&:to_i)
  53. 5105 @status_code = code.to_i
  54. 5105 raise(Error, "wrong status code (#{@status_code})") unless (100..599).cover?(@status_code)
  55. 5097 @buffer = @buffer.byteslice((idx + 1)..-1)
  56. 5097 nextstate(:headers)
  57. end
  58. 27 def parse_headers
  59. 5212 headers = @headers
  60. 5212 buffer = @buffer
  61. 36953 while (idx = buffer.index("\n"))
  62. # @type var line: String
  63. 39969 line = buffer.byteslice(0..idx)
  64. 39969 raise Error, "wrong header format" if line.start_with?("\s", "\t")
  65. 39961 line.lstrip!
  66. 39961 buffer = @buffer = buffer.byteslice((idx + 1)..-1)
  67. 39961 if line.empty?
  68. 5097 case @state
  69. when :headers
  70. 5081 prepare_data(headers)
  71. 5081 @observer.on_headers(headers)
  72. 4426 return unless @state == :headers
  73. # state might have been reset
  74. # in the :headers callback
  75. 4345 nextstate(:data)
  76. 4345 headers.clear
  77. when :trailers
  78. 16 @observer.on_trailers(headers)
  79. 16 headers.clear
  80. 16 nextstate(:complete)
  81. end
  82. 4353 return
  83. end
  84. 34864 separator_index = line.index(":")
  85. 34864 raise Error, "wrong header format" unless separator_index
  86. # @type var key: String
  87. 34856 key = line.byteslice(0..(separator_index - 1))
  88. 34856 key.rstrip! # was lstripped previously!
  89. # @type var value: String
  90. 34856 value = line.byteslice((separator_index + 1)..-1)
  91. 34856 value.strip!
  92. 34856 raise Error, "wrong header format" if value.nil?
  93. 34856 (headers[key.downcase] ||= []) << value
  94. end
  95. end
  96. 27 def parse_data
  97. 5733 if @buffer.respond_to?(:each)
  98. 156 @buffer.each do |chunk|
  99. 227 @observer.on_data(chunk)
  100. end
  101. 5575 elsif @content_length
  102. # @type var data: String
  103. 5537 data = @buffer.byteslice(0, @content_length)
  104. 5537 @buffer = @buffer.byteslice(@content_length..-1) || "".b
  105. 5537 @content_length -= data.bytesize
  106. 5537 @observer.on_data(data)
  107. 5515 data.clear
  108. else
  109. 40 @observer.on_data(@buffer)
  110. 40 @buffer.clear
  111. end
  112. 5703 return unless no_more_data?
  113. 4195 @buffer = @buffer.to_s
  114. 4195 if @_has_trailers
  115. 16 nextstate(:trailers)
  116. else
  117. 4179 nextstate(:complete)
  118. end
  119. end
  120. 27 def prepare_data(headers)
  121. 5081 @upgrade = headers.key?("upgrade")
  122. 5081 @_has_trailers = headers.key?("trailer")
  123. 5081 if (tr_encodings = headers["transfer-encoding"])
  124. 115 tr_encodings.reverse_each do |tr_encoding|
  125. 115 tr_encoding.split(/ *, */).each do |encoding|
  126. 115 case encoding
  127. when "chunked"
  128. 115 @buffer = Transcoder::Chunker::Decoder.new(@buffer, @_has_trailers)
  129. end
  130. end
  131. end
  132. else
  133. 4966 @content_length = headers["content-length"][0].to_i if headers.key?("content-length")
  134. end
  135. end
  136. 27 def no_more_data?
  137. 5703 if @content_length
  138. 5515 @content_length <= 0
  139. 186 elsif @buffer.respond_to?(:finished?)
  140. 148 @buffer.finished?
  141. else
  142. 40 false
  143. end
  144. end
  145. 27 def nextstate(state)
  146. 13653 @state = state
  147. 13653 case state
  148. when :headers
  149. 5097 @observer.on_start
  150. when :complete
  151. 4195 @observer.on_complete
  152. 578 reset!
  153. 578 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. 8 module HTTPX
  3. 8 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. 8 module Auth
  12. 8 module InstanceMethods
  13. 8 def authorization(token)
  14. 144 with(headers: { "authorization" => token })
  15. end
  16. 8 def bearer_auth(token)
  17. 16 authorization("Bearer #{token}")
  18. end
  19. end
  20. end
  21. 8 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. 9 require "httpx/base64"
  3. 9 module HTTPX
  4. 9 module Plugins
  5. 9 module Authentication
  6. 9 class Basic
  7. 9 def initialize(user, password, **)
  8. 306 @user = user
  9. 306 @password = password
  10. end
  11. 9 def authenticate(*)
  12. 287 "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. 8 require "time"
  3. 8 require "securerandom"
  4. 8 require "digest"
  5. 8 module HTTPX
  6. 8 module Plugins
  7. 8 module Authentication
  8. 8 class Digest
  9. 8 def initialize(user, password, hashed: false, **)
  10. 176 @user = user
  11. 176 @password = password
  12. 176 @nonce = 0
  13. 176 @hashed = hashed
  14. end
  15. 8 def can_authenticate?(authenticate)
  16. 160 authenticate && /Digest .*/.match?(authenticate)
  17. end
  18. 8 def authenticate(request, authenticate)
  19. 160 "Digest #{generate_header(request.verb, request.path, authenticate)}"
  20. end
  21. 8 private
  22. 8 def generate_header(meth, uri, authenticate)
  23. # discard first token, it's Digest
  24. 160 auth_info = authenticate[/^(\w+) (.*)/, 2]
  25. 160 params = auth_info.split(/ *, */)
  26. 832 .to_h { |val| val.split("=", 2) }
  27. 832 .transform_values { |v| v.delete("\"") }
  28. 160 nonce = params["nonce"]
  29. 160 nc = next_nonce
  30. # verify qop
  31. 160 qop = params["qop"]
  32. 160 if params["algorithm"] =~ /(.*?)(-sess)?$/
  33. 144 alg = Regexp.last_match(1)
  34. 144 algorithm = ::Digest.const_get(alg)
  35. 144 raise DigestError, "unknown algorithm \"#{alg}\"" unless algorithm
  36. 144 sess = Regexp.last_match(2)
  37. else
  38. 16 algorithm = ::Digest::MD5
  39. end
  40. 160 if qop || sess
  41. 160 cnonce = make_cnonce
  42. 160 nc = format("%<nonce>08x", nonce: nc)
  43. end
  44. 160 a1 = if sess
  45. [
  46. 32 (@hashed ? @password : algorithm.hexdigest("#{@user}:#{params["realm"]}:#{@password}")),
  47. nonce,
  48. cnonce,
  49. 6 ].join ":"
  50. else
  51. 128 @hashed ? @password : "#{@user}:#{params["realm"]}:#{@password}"
  52. end
  53. 160 ha1 = algorithm.hexdigest(a1)
  54. 160 ha2 = algorithm.hexdigest("#{meth}:#{uri}")
  55. 160 request_digest = [ha1, nonce]
  56. 160 request_digest.push(nc, cnonce, qop) if qop
  57. 160 request_digest << ha2
  58. 160 request_digest = request_digest.join(":")
  59. 40 header = [
  60. 120 %(username="#{@user}"),
  61. %(nonce="#{nonce}"),
  62. %(uri="#{uri}"),
  63. %(response="#{algorithm.hexdigest(request_digest)}"),
  64. ]
  65. 160 header << %(realm="#{params["realm"]}") if params.key?("realm")
  66. 160 header << %(algorithm=#{params["algorithm"]}) if params.key?("algorithm")
  67. 160 header << %(cnonce="#{cnonce}") if cnonce
  68. 160 header << %(nc=#{nc})
  69. 160 header << %(qop=#{qop}) if qop
  70. 160 header << %(opaque="#{params["opaque"]}") if params.key?("opaque")
  71. 160 header.join ", "
  72. end
  73. 8 def make_cnonce
  74. 200 ::Digest::MD5.hexdigest [
  75. Time.now.to_i,
  76. Process.pid,
  77. SecureRandom.random_number(2**32),
  78. ].join ":"
  79. end
  80. 8 def next_nonce
  81. 160 @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. 3 require "httpx/base64"
  3. 3 require "ntlm"
  4. 3 module HTTPX
  5. 3 module Plugins
  6. 3 module Authentication
  7. 3 class Ntlm
  8. 3 def initialize(user, password, domain: nil)
  9. 4 @user = user
  10. 4 @password = password
  11. 4 @domain = domain
  12. end
  13. 3 def can_authenticate?(authenticate)
  14. 2 authenticate && /NTLM .*/.match?(authenticate)
  15. end
  16. 3 def negotiate
  17. 4 "NTLM #{NTLM.negotiate(domain: @domain).to_base64}"
  18. end
  19. 3 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. 10 module HTTPX
  3. 10 module Plugins
  4. 10 module Authentication
  5. 10 class Socks5
  6. 10 def initialize(user, password, **)
  7. 48 @user = user
  8. 48 @password = password
  9. end
  10. 10 def can_authenticate?(*)
  11. 48 @user && @password
  12. end
  13. 10 def authenticate(*)
  14. 48 [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

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

lib/httpx/plugins/aws_sigv4.rb

100.0% lines covered

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

lib/httpx/plugins/callbacks.rb

92.42% lines covered

66 relevant lines. 61 lines covered and 5 lines missed.
    
  1. # frozen_string_literal: true
  2. 27 module HTTPX
  3. 27 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. 27 module Callbacks
  10. 27 CALLBACKS = %i[
  11. connection_opened connection_closed
  12. request_error
  13. request_started request_body_chunk request_completed
  14. response_started response_body_chunk response_completed
  15. ].freeze
  16. # connection closed user-space errors happen after errors can be surfaced to requests,
  17. # so they need to pierce through the scheduler, which is only possible by simulating an
  18. # interrupt.
  19. 27 class CallbackError < Exception; end # rubocop:disable Lint/InheritException
  20. 27 module InstanceMethods
  21. 27 include HTTPX::Callbacks
  22. 27 CALLBACKS.each do |meth|
  23. 243 class_eval(<<-MOD, __FILE__, __LINE__ + 1)
  24. def on_#{meth}(&blk) # def on_connection_opened(&blk)
  25. on(:#{meth}, &blk) # on(:connection_opened, &blk)
  26. self # self
  27. end # end
  28. MOD
  29. end
  30. 27 def plugin(*args, &blk)
  31. super(*args).tap do |sess|
  32. CALLBACKS.each do |cb|
  33. next unless callbacks_for?(cb)
  34. sess.callbacks(cb).concat(callbacks(cb))
  35. end
  36. sess.wrap(&blk) if blk
  37. end
  38. end
  39. 27 private
  40. 27 def branch(options, &blk)
  41. 32 super(options).tap do |sess|
  42. 32 CALLBACKS.each do |cb|
  43. 288 next unless callbacks_for?(cb)
  44. 16 sess.callbacks(cb).concat(callbacks(cb))
  45. end
  46. 32 sess.wrap(&blk) if blk
  47. end
  48. end
  49. 27 def do_init_connection(connection, selector)
  50. 225 super
  51. 225 connection.on(:open) do
  52. 195 next unless connection.current_session == self
  53. 195 emit_or_callback_error(:connection_opened, connection.origin, connection.io.socket)
  54. end
  55. 225 connection.on(:callback_connection_closed) do
  56. 227 next unless connection.current_session == self
  57. 227 emit_or_callback_error(:connection_closed, connection.origin) if connection.used?
  58. end
  59. 225 connection
  60. end
  61. 27 def set_request_callbacks(request)
  62. 227 super
  63. 227 request.on(:headers) do
  64. 179 emit_or_callback_error(:request_started, request)
  65. end
  66. 227 request.on(:body_chunk) do |chunk|
  67. 16 emit_or_callback_error(:request_body_chunk, request, chunk)
  68. end
  69. 227 request.on(:done) do
  70. 163 emit_or_callback_error(:request_completed, request)
  71. end
  72. 227 request.on(:response_started) do |res|
  73. 179 if res.is_a?(Response)
  74. 147 emit_or_callback_error(:response_started, request, res)
  75. 131 res.on(:chunk_received) do |chunk|
  76. 155 emit_or_callback_error(:response_body_chunk, request, res, chunk)
  77. end
  78. else
  79. 32 emit_or_callback_error(:request_error, request, res.error)
  80. end
  81. end
  82. 227 request.on(:response) do |res|
  83. 131 emit_or_callback_error(:response_completed, request, res) if res.is_a?(Response)
  84. end
  85. end
  86. 27 def emit_or_callback_error(*args)
  87. 1205 emit(*args)
  88. rescue StandardError => e
  89. 136 ex = CallbackError.new(e.message)
  90. 136 ex.set_backtrace(e.backtrace)
  91. 136 raise ex
  92. end
  93. 27 def receive_requests(*)
  94. 227 super
  95. rescue CallbackError => e
  96. 120 raise e.cause
  97. end
  98. 27 def close(*)
  99. 225 super
  100. rescue CallbackError => e
  101. 8 raise e.cause
  102. end
  103. end
  104. 27 module ConnectionMethods
  105. 27 private
  106. 27 def disconnect
  107. 267 return if @exhausted
  108. 267 return unless @current_session && @current_selector
  109. 227 emit(:callback_connection_closed)
  110. 203 super
  111. end
  112. end
  113. end
  114. 27 register_plugin :callbacks, Callbacks
  115. end
  116. end

lib/httpx/plugins/circuit_breaker.rb

100.0% lines covered

65 relevant lines. 65 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 8 module HTTPX
  3. 8 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. 8 module CircuitBreaker
  10. 8 using URIExtensions
  11. 8 def self.load_dependencies(*)
  12. 56 require_relative "circuit_breaker/circuit"
  13. 56 require_relative "circuit_breaker/circuit_store"
  14. end
  15. 8 def self.extra_options(options)
  16. 56 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. 8 module InstanceMethods
  24. 8 include HTTPX::Callbacks
  25. 8 def initialize(*)
  26. 56 super
  27. 56 @circuit_store = CircuitStore.new(@options)
  28. end
  29. 8 %i[circuit_open].each do |meth|
  30. 8 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. self # self
  34. end # end
  35. MOD
  36. end
  37. 8 private
  38. 8 def send_requests(*requests)
  39. # @type var short_circuit_responses: Array[response]
  40. 224 short_circuit_responses = []
  41. # run all requests through the circuit breaker, see if the circuit is
  42. # open for any of them.
  43. 224 real_requests = requests.each_with_index.with_object([]) do |(req, idx), real_reqs|
  44. 224 short_circuit_response = @circuit_store.try_respond(req)
  45. 224 if short_circuit_response.nil?
  46. 176 real_reqs << req
  47. 176 next
  48. end
  49. 48 short_circuit_responses[idx] = short_circuit_response
  50. end
  51. # run requests for the remainder
  52. 224 unless real_requests.empty?
  53. 176 responses = super(*real_requests)
  54. 176 real_requests.each_with_index do |request, idx|
  55. 176 short_circuit_responses[requests.index(request)] = responses[idx]
  56. end
  57. end
  58. 224 short_circuit_responses
  59. end
  60. 8 def set_request_callbacks(request)
  61. 224 super
  62. 224 request.on(:response) do |response|
  63. 176 emit(:circuit_open, request) if try_circuit_open(request, response)
  64. end
  65. end
  66. 8 def try_circuit_open(request, response)
  67. 176 if response.is_a?(ErrorResponse)
  68. 128 case response.error
  69. when RequestTimeoutError
  70. 80 @circuit_store.try_open(request.uri, response)
  71. else
  72. 48 @circuit_store.try_open(request.origin, response)
  73. end
  74. 48 elsif (break_on = request.options.circuit_breaker_break_on) && break_on.call(response)
  75. 16 @circuit_store.try_open(request.uri, response)
  76. else
  77. 32 @circuit_store.try_close(request.uri)
  78. 12 nil
  79. end
  80. end
  81. end
  82. # adds support for the following options:
  83. #
  84. # :circuit_breaker_max_attempts :: the number of attempts the circuit allows, before it is opened (defaults to <tt>3</tt>).
  85. # :circuit_breaker_reset_attempts_in :: the time a circuit stays open at most, before it resets (defaults to <tt>60</tt>).
  86. # :circuit_breaker_break_on :: callable defining an alternative rule for a response to break
  87. # (i.e. <tt>->(res) { res.status == 429 } </tt>)
  88. # :circuit_breaker_break_in :: the time that must elapse before an open circuit can transit to the half-open state
  89. # (defaults to <tt><60</tt>).
  90. # :circuit_breaker_half_open_drip_rate :: the rate of requests a circuit allows to be performed when in an half-open state
  91. # (defaults to <tt>1</tt>).
  92. 8 module OptionsMethods
  93. 8 private
  94. 8 def option_circuit_breaker_max_attempts(value)
  95. 112 attempts = Integer(value)
  96. 112 raise TypeError, ":circuit_breaker_max_attempts must be positive" unless attempts.positive?
  97. 112 attempts
  98. end
  99. 8 def option_circuit_breaker_reset_attempts_in(value)
  100. 64 timeout = Float(value)
  101. 64 raise TypeError, ":circuit_breaker_reset_attempts_in must be positive" unless timeout.positive?
  102. 64 timeout
  103. end
  104. 8 def option_circuit_breaker_break_in(value)
  105. 88 timeout = Float(value)
  106. 88 raise TypeError, ":circuit_breaker_break_in must be positive" unless timeout.positive?
  107. 88 timeout
  108. end
  109. 8 def option_circuit_breaker_half_open_drip_rate(value)
  110. 88 ratio = Float(value)
  111. 88 raise TypeError, ":circuit_breaker_half_open_drip_rate must be a number between 0 and 1" unless (0..1).cover?(ratio)
  112. 88 ratio
  113. end
  114. 8 def option_circuit_breaker_break_on(value)
  115. 16 raise TypeError, ":circuit_breaker_break_on must be called with the response" unless value.respond_to?(:call)
  116. 16 value
  117. end
  118. end
  119. end
  120. 8 register_plugin :circuit_breaker, CircuitBreaker
  121. end
  122. 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. 8 module HTTPX
  3. 8 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. 8 class Circuit
  13. 8 def initialize(max_attempts, reset_attempts_in, break_in, circuit_breaker_half_open_drip_rate)
  14. 56 @max_attempts = max_attempts
  15. 56 @reset_attempts_in = reset_attempts_in
  16. 56 @break_in = break_in
  17. 56 @circuit_breaker_half_open_drip_rate = circuit_breaker_half_open_drip_rate
  18. 56 @attempts = 0
  19. 56 total_real_attempts = @max_attempts * @circuit_breaker_half_open_drip_rate
  20. 56 @drip_factor = (@max_attempts / total_real_attempts).round
  21. 56 @state = :closed
  22. end
  23. 8 def respond
  24. 224 try_close
  25. 224 case @state
  26. when :closed
  27. 51 nil
  28. when :half_open
  29. 56 @attempts += 1
  30. # do real requests while drip rate valid
  31. 56 if (@real_attempts % @drip_factor).zero?
  32. 40 @real_attempts += 1
  33. 40 return
  34. end
  35. 16 @response
  36. when :open
  37. 32 @response
  38. end
  39. end
  40. 8 def try_open(response)
  41. 144 case @state
  42. when :closed
  43. 120 now = Utils.now
  44. 120 if @attempts.positive?
  45. # reset if error happened long ago
  46. 48 @attempts = 0 if now - @attempted_at > @reset_attempts_in
  47. else
  48. 72 @attempted_at = now
  49. end
  50. 120 @attempts += 1
  51. 120 return unless @attempts >= @max_attempts
  52. 64 @state = :open
  53. 64 @opened_at = now
  54. 64 @response = response
  55. when :half_open
  56. # open immediately
  57. 24 @state = :open
  58. 24 @attempted_at = @opened_at = Utils.now
  59. 24 @response = response
  60. end
  61. end
  62. 8 def try_close
  63. 256 case @state
  64. when :closed
  65. 51 nil
  66. when :half_open
  67. # do not close circuit unless attempts exhausted
  68. 48 return unless @attempts >= @max_attempts
  69. # reset!
  70. 16 @attempts = 0
  71. 16 @opened_at = @attempted_at = @response = nil
  72. 16 @state = :closed
  73. when :open
  74. 72 if Utils.elapsed_time(@opened_at) > @break_in
  75. 40 @state = :half_open
  76. 40 @attempts = 0
  77. 40 @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. 8 module HTTPX::Plugins::CircuitBreaker
  3. 8 using HTTPX::URIExtensions
  4. 8 class CircuitStore
  5. 8 def initialize(options)
  6. 56 @circuits = Hash.new do |h, k|
  7. 56 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. 56 @circuits_mutex = Thread::Mutex.new
  15. end
  16. 8 def try_open(uri, response)
  17. 288 circuit = @circuits_mutex.synchronize { get_circuit_for_uri(uri) }
  18. 144 circuit.try_open(response)
  19. end
  20. 8 def try_close(uri)
  21. 32 circuit = @circuits_mutex.synchronize do
  22. 32 return unless @circuits.key?(uri.origin) || @circuits.key?(uri.to_s)
  23. 32 get_circuit_for_uri(uri)
  24. end
  25. 32 circuit.try_close
  26. end
  27. # if circuit is open, it'll respond with the stored response.
  28. # if not, nil.
  29. 8 def try_respond(request)
  30. 448 circuit = @circuits_mutex.synchronize { get_circuit_for_uri(request.uri) }
  31. 224 circuit.respond
  32. end
  33. 8 private
  34. 8 def get_circuit_for_uri(uri)
  35. 400 if uri.respond_to?(:origin) && @circuits.key?(uri.origin)
  36. 288 @circuits[uri.origin]
  37. else
  38. 112 @circuits[uri.to_s]
  39. end
  40. end
  41. end
  42. end

lib/httpx/plugins/content_digest.rb

100.0% lines covered

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

lib/httpx/plugins/cookies.rb

100.0% lines covered

52 relevant lines. 52 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 8 require "forwardable"
  3. 8 module HTTPX
  4. 8 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. 8 module Cookies
  13. 8 def self.load_dependencies(*)
  14. 144 require "httpx/plugins/cookies/jar"
  15. 144 require "httpx/plugins/cookies/cookie"
  16. 144 require "httpx/plugins/cookies/set_cookie_parser"
  17. end
  18. 8 module InstanceMethods
  19. 8 extend Forwardable
  20. 8 def_delegator :@options, :cookies
  21. 8 def initialize(options = {}, &blk)
  22. 288 super({ cookies: Jar.new }.merge(options), &blk)
  23. end
  24. 8 def wrap
  25. 16 return super unless block_given?
  26. 16 super do |session|
  27. 16 old_cookies_jar = @options.cookies.dup
  28. 2 begin
  29. 16 yield session
  30. ensure
  31. 16 @options = @options.merge(cookies: old_cookies_jar)
  32. end
  33. end
  34. end
  35. 8 def build_request(*)
  36. 320 request = super
  37. 320 request.headers.set_cookie(request.options.cookies[request.uri])
  38. 320 request
  39. end
  40. 8 private
  41. 8 def set_request_callbacks(request)
  42. 320 super
  43. 320 request.on(:response) do |response|
  44. 320 next unless response && response.respond_to?(:headers) && (set_cookie = response.headers["set-cookie"])
  45. 64 log { "cookies: set-cookie is over #{Cookie::MAX_LENGTH}" } if set_cookie.bytesize > Cookie::MAX_LENGTH
  46. 64 @options.cookies.parse(set_cookie)
  47. end
  48. end
  49. end
  50. 8 module HeadersMethods
  51. 8 def set_cookie(cookies)
  52. 320 return if cookies.empty?
  53. 272 header_value = cookies.sort.join("; ")
  54. 272 add("cookie", header_value)
  55. end
  56. end
  57. # adds support for the following options:
  58. #
  59. # :cookies :: cookie jar for the session (can be a Hash, an Array, an instance of HTTPX::Plugins::Cookies::CookieJar)
  60. 8 module OptionsMethods
  61. 8 private
  62. 8 def option_headers(*)
  63. 320 value = super
  64. 320 merge_cookie_in_jar(value.delete("cookie"), @cookies) if defined?(@cookies) && value.key?("cookie")
  65. 320 value
  66. end
  67. 8 def option_cookies(value)
  68. 480 jar = value.is_a?(Jar) ? value : Jar.new(value)
  69. 480 merge_cookie_in_jar(@headers.delete("cookie"), jar) if defined?(@headers) && @headers.key?("cookie")
  70. 480 jar
  71. end
  72. 8 def merge_cookie_in_jar(cookies, jar)
  73. 16 cookies.each do |ck|
  74. 16 ck.split(/ *; */).each do |cookie|
  75. 32 name, value = cookie.split("=", 2)
  76. 32 jar.add(Cookie.new(name, value))
  77. end
  78. end
  79. end
  80. end
  81. end
  82. 8 register_plugin :cookies, Cookies
  83. end
  84. 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. 8 module HTTPX
  3. 8 module Plugins::Cookies
  4. # The HTTP Cookie.
  5. #
  6. # Contains the single cookie info: name, value and attributes.
  7. 8 class Cookie
  8. 8 include Comparable
  9. # Maximum number of bytes per cookie (RFC 6265 6.1 requires 4096 at
  10. # least)
  11. 8 MAX_LENGTH = 4096
  12. 8 attr_reader :domain, :path, :name, :value, :created_at
  13. 8 def path=(path)
  14. 184 path = String(path)
  15. 184 @path = path.start_with?("/") ? path : "/"
  16. end
  17. # See #domain.
  18. 8 def domain=(domain)
  19. 40 domain = String(domain)
  20. 40 if domain.start_with?(".")
  21. 16 @for_domain = true
  22. 16 domain = domain[1..-1]
  23. end
  24. 40 return if domain.empty?
  25. 40 @domain_name = DomainName.new(domain)
  26. # RFC 6265 5.3 5.
  27. 40 @for_domain = false if @domain_name.domain.nil? # a public suffix or IP address
  28. 40 @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. 8 def <=>(other)
  33. # RFC 6265 5.4
  34. # Precedence: 1. longer path 2. older creation
  35. 690 (@name <=> other.name).nonzero? ||
  36. 62 (other.path.length <=> @path.length).nonzero? ||
  37. 36 (@created_at <=> other.created_at).nonzero? || 0
  38. end
  39. 8 class << self
  40. 8 def new(cookie, *args)
  41. 504 return cookie if cookie.is_a?(self)
  42. 504 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. 8 def path_match?(base_path, target_path)
  62. 1352 base_path.start_with?("/") || (return false)
  63. # RFC 6265 5.1.4
  64. 1352 bsize = base_path.size
  65. 1352 tsize = target_path.size
  66. 1352 return bsize == 1 if tsize.zero? # treat empty target_path as "/"
  67. 1352 return false unless target_path.start_with?(base_path)
  68. 1344 return true if bsize == tsize || base_path.end_with?("/")
  69. 16 target_path[bsize] == "/"
  70. end
  71. end
  72. 8 def initialize(arg, *attrs)
  73. 504 @created_at = Time.now
  74. 504 if attrs.empty?
  75. 24 attr_hash = Hash.try_convert(arg)
  76. else
  77. 480 @name = arg
  78. 480 @value, attr_hash = attrs
  79. 480 attr_hash = Hash.try_convert(attr_hash)
  80. end
  81. attr_hash.each do |key, val|
  82. 312 key = key.downcase.tr("-", "_").to_sym unless key.is_a?(Symbol)
  83. 312 case key
  84. when :domain, :path
  85. 178 __send__(:"#{key}=", val)
  86. else
  87. 88 instance_variable_set(:"@#{key}", val)
  88. end
  89. 502 end if attr_hash
  90. 504 @path ||= "/"
  91. 504 raise ArgumentError, "name must be specified" if @name.nil?
  92. 504 @name = @name.to_s
  93. end
  94. 8 def expires
  95. 760 @expires || (@created_at && @max_age ? @created_at + @max_age : nil)
  96. end
  97. 8 def expired?(time = Time.now)
  98. 728 return false unless expires
  99. 32 expires <= time
  100. end
  101. # Returns a string for use in the Cookie header, i.e. `name=value`
  102. # or `name="value"`.
  103. 8 def cookie_value
  104. 552 "#{@name}=#{Scanner.quote(@value.to_s)}"
  105. end
  106. 8 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. 8 def valid_for_uri?(uri)
  110. 712 uri = URI(uri)
  111. # RFC 6265 5.4
  112. 712 return false if @secure && uri.scheme != "https"
  113. 704 acceptable_from_uri?(uri) && Cookie.path_match?(@path, uri.path)
  114. end
  115. 8 private
  116. # Tests if it is OK to accept this cookie if it is sent from a given
  117. # URI/URL, `uri`.
  118. 8 def acceptable_from_uri?(uri)
  119. 736 uri = URI(uri)
  120. 736 host = DomainName.new(uri.host)
  121. # RFC 6265 5.3
  122. 736 if host.hostname == @domain
  123. 16 true
  124. 718 elsif @for_domain # !host-only-flag
  125. 32 host.cookie_domain?(@domain_name)
  126. else
  127. 688 @domain.nil?
  128. end
  129. end
  130. 8 module Scanner
  131. 8 RE_BAD_CHAR = /([\x00-\x20\x7F",;\\])/.freeze
  132. 8 module_function
  133. 8 def quote(s)
  134. 552 return s unless s.match(RE_BAD_CHAR)
  135. 8 "\"#{s.gsub(/([\\"])/, "\\\\\\1")}\""
  136. end
  137. end
  138. end
  139. end
  140. end

lib/httpx/plugins/cookies/jar.rb

100.0% lines covered

46 relevant lines. 46 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 8 module HTTPX
  3. 8 module Plugins::Cookies
  4. # The Cookie Jar
  5. #
  6. # It holds a bunch of cookies.
  7. 8 class Jar
  8. 8 using URIExtensions
  9. 8 include Enumerable
  10. 8 def initialize_dup(orig)
  11. 216 super
  12. 216 @cookies = orig.instance_variable_get(:@cookies).dup
  13. end
  14. 8 def initialize(cookies = nil)
  15. 536 @cookies = []
  16. 100 cookies.each do |elem|
  17. 176 cookie = case elem
  18. when Cookie
  19. 16 elem
  20. when Array
  21. 144 Cookie.new(*elem)
  22. else
  23. 16 Cookie.new(elem)
  24. end
  25. 176 @cookies << cookie
  26. 534 end if cookies
  27. end
  28. 8 def parse(set_cookie)
  29. 144 SetCookieParser.call(set_cookie) do |name, value, attrs|
  30. 208 add(Cookie.new(name, value, attrs))
  31. end
  32. end
  33. 8 def add(cookie, path = nil)
  34. 456 c = cookie.dup
  35. 456 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. 864 @cookies.delete_if { |ck| ck.name == c.name && ck.domain == c.domain && ck.path == c.path }
  39. 456 @cookies << c
  40. end
  41. 8 def [](uri)
  42. 472 each(uri).sort
  43. end
  44. 8 def each(uri = nil, &blk)
  45. 1184 return enum_for(__method__, uri) unless blk
  46. 680 return @cookies.each(&blk) unless uri
  47. 472 now = Time.now
  48. 472 tpath = uri.path
  49. 472 @cookies.delete_if do |cookie|
  50. 728 if cookie.expired?(now)
  51. 16 true
  52. else
  53. 712 yield cookie if cookie.valid_for_uri?(uri) && Cookie.path_match?(cookie.path, tpath)
  54. 712 false
  55. end
  56. end
  57. end
  58. 8 def merge(other)
  59. 200 cookies_dup = dup
  60. 200 other.each do |elem|
  61. 216 cookie = case elem
  62. when Cookie
  63. 200 elem
  64. when Array
  65. 8 Cookie.new(*elem)
  66. else
  67. 8 Cookie.new(elem)
  68. end
  69. 216 cookies_dup.add(cookie)
  70. end
  71. 200 cookies_dup
  72. end
  73. end
  74. end
  75. end

lib/httpx/plugins/cookies/set_cookie_parser.rb

100.0% lines covered

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

lib/httpx/plugins/digest_auth.rb

100.0% lines covered

30 relevant lines. 30 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 8 module HTTPX
  3. 8 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. 8 module DigestAuth
  10. 8 DigestError = Class.new(Error)
  11. 8 class << self
  12. 8 def extra_options(options)
  13. 160 options.merge(max_concurrent_requests: 1)
  14. end
  15. 8 def load_dependencies(*)
  16. 160 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. 8 module OptionsMethods
  23. 8 private
  24. 8 def option_digest(value)
  25. 320 raise TypeError, ":digest must be a #{Authentication::Digest}" unless value.is_a?(Authentication::Digest)
  26. 320 value
  27. end
  28. end
  29. 8 module InstanceMethods
  30. 8 def digest_auth(user, password, hashed: false)
  31. 160 with(digest: Authentication::Digest.new(user, password, hashed: hashed))
  32. end
  33. 8 private
  34. 8 def send_requests(*requests)
  35. 192 requests.flat_map do |request|
  36. 192 digest = request.options.digest
  37. 192 next super(request) unless digest
  38. 320 probe_response = wrap { super(request).first }
  39. 160 return ([probe_response] * requests.size) unless probe_response.is_a?(Response)
  40. 160 if probe_response.status == 401 && digest.can_authenticate?(probe_response.headers["www-authenticate"])
  41. 144 request.transition(:idle)
  42. 144 request.headers["authorization"] = digest.authenticate(request, probe_response.headers["www-authenticate"])
  43. 144 super(request)
  44. else
  45. 16 probe_response
  46. end
  47. end
  48. end
  49. end
  50. end
  51. 8 register_plugin :digest_auth, DigestAuth
  52. end
  53. end

lib/httpx/plugins/expect.rb

100.0% lines covered

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

lib/httpx/plugins/fiber_concurrency.rb

88.04% lines covered

92 relevant lines. 81 lines covered and 11 lines missed.
    
  1. # frozen_string_literal: true
  2. 17 module HTTPX
  3. 17 module Plugins
  4. # This plugin makes a session reuse the same selector across all fibers in a given thread.
  5. #
  6. # This enables integration with fiber scheduler implementations such as [async](https://github.com/async).
  7. #
  8. # # https://gitlab.com/os85/httpx/wikis/FiberConcurrency
  9. #
  10. 17 module FiberConcurrency
  11. 17 def self.subplugins
  12. {
  13. 1882 h2c: FiberConcurrencyH2C,
  14. }
  15. end
  16. 17 module InstanceMethods
  17. 17 private
  18. 17 def send_request(request, *)
  19. 644 request.set_context!
  20. 644 super
  21. end
  22. 17 def get_current_selector
  23. 570 super(&nil) || begin
  24. 504 return unless block_given?
  25. 504 default = yield
  26. 504 set_current_selector(default)
  27. 504 default
  28. end
  29. end
  30. end
  31. 17 module RequestMethods
  32. # the execution context (fiber) this request was sent on.
  33. 17 attr_reader :context
  34. 17 def initialize(*)
  35. 582 super
  36. 582 @context = nil
  37. end
  38. # sets the execution context for this request. the default is the current fiber.
  39. 17 def set_context!
  40. 1302 @context ||= Fiber.current # rubocop:disable Naming/MemoizedInstanceVariableName
  41. end
  42. # checks whether the current execution context is the one where the request was created.
  43. 17 def current_context?
  44. 3972 @context == Fiber.current
  45. end
  46. 17 def complete!(response = @response)
  47. 582 @context = nil
  48. 582 super
  49. end
  50. end
  51. 17 module ConnectionMethods
  52. 17 def current_context?
  53. @pending.any?(&:current_context?) || (
  54. @sibling && @sibling.pending.any?(&:current_context?)
  55. )
  56. end
  57. 17 def interests
  58. 10592 return if connecting? && @pending.none?(&:current_context?)
  59. 10291 super
  60. end
  61. 17 def send(request)
  62. # DoH requests bypass the session, so context needs to be set here.
  63. 658 request.set_context!
  64. 658 super
  65. end
  66. end
  67. 17 module HTTP1Methods
  68. 17 def interests
  69. 1181 request = @request || @requests.first
  70. 1181 return unless request
  71. 1163 return unless request.current_context? || @requests.any?(&:current_context?) || @pending.any?(&:current_context?)
  72. 1161 super
  73. end
  74. end
  75. 17 module HTTP2Methods
  76. 17 def initialize(*)
  77. 392 super
  78. 1249 @contexts = Hash.new { |hs, k| hs[k] = Set.new }
  79. end
  80. 17 def interests
  81. 7965 if @connection.state == :connected && @handshake_completed && !@contexts.key?(Fiber.current)
  82. 505 return :w unless @pings.empty?
  83. 457 return
  84. end
  85. 7460 super
  86. end
  87. 17 def send(request, *)
  88. 885 add_to_context(request)
  89. 885 super
  90. end
  91. 17 private
  92. 17 def on_close(_, error, _)
  93. 16 if error == :http_1_1_required
  94. # remove all pending requests context
  95. @pending.each do |req|
  96. clear_from_context(req)
  97. end
  98. end
  99. 16 super
  100. end
  101. 17 def on_stream_close(_, request, error)
  102. 448 clear_from_context(request) if error != :stream_closed && @streams.key?(request)
  103. 448 super
  104. end
  105. 17 def teardown(request = nil)
  106. 440 super
  107. 440 if request
  108. 424 clear_from_context(request)
  109. else
  110. 16 @contexts.clear
  111. end
  112. end
  113. 17 def add_to_context(request)
  114. 885 @contexts[request.context] << request
  115. end
  116. 17 def clear_from_context(request)
  117. 848 requests = @contexts[request.context]
  118. 848 requests.delete(request)
  119. 848 @contexts.delete(request.context) if requests.empty?
  120. end
  121. end
  122. 17 module NativeResolverMethods
  123. 17 private
  124. 17 def calculate_interests
  125. return if @queries.empty?
  126. return unless @queries.values.any?(&:current_context?) || @connections.any?(&:current_context?)
  127. super
  128. end
  129. end
  130. 17 module SystemResolverMethods
  131. 17 def interests
  132. return unless @queries.any? { |_, conn| conn.current_context? }
  133. super
  134. end
  135. end
  136. 17 module FiberConcurrencyH2C
  137. 17 module HTTP2Methods
  138. 17 def upgrade(request, *)
  139. @contexts[request.context] << request
  140. super
  141. end
  142. end
  143. end
  144. end
  145. 17 register_plugin :fiber_concurrency, FiberConcurrency
  146. end
  147. end

lib/httpx/plugins/follow_redirects.rb

100.0% lines covered

109 relevant lines. 109 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 15 module HTTPX
  3. 15 InsecureRedirectError = Class.new(Error)
  4. 15 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. 15 module FollowRedirects
  21. 15 MAX_REDIRECTS = 3
  22. 15 REDIRECT_STATUS = (300..399).freeze
  23. 15 REQUEST_BODY_HEADERS = %w[transfer-encoding content-encoding content-type content-length content-language content-md5 trailer].freeze
  24. 15 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. 15 module OptionsMethods
  34. 15 private
  35. 15 def option_max_redirects(value)
  36. 462 num = Integer(value)
  37. 462 raise TypeError, ":max_redirects must be positive" if num.negative?
  38. 462 num
  39. end
  40. 15 def option_follow_insecure_redirects(value)
  41. 24 value
  42. end
  43. 15 def option_allow_auth_to_other_origins(value)
  44. 24 value
  45. end
  46. 15 def option_redirect_on(value)
  47. 48 raise TypeError, ":redirect_on must be callable" unless value.respond_to?(:call)
  48. 48 value
  49. end
  50. end
  51. 15 module InstanceMethods
  52. # returns a session with the *max_redirects* option set to +n+
  53. 15 def max_redirects(n)
  54. 48 with(max_redirects: n.to_i)
  55. end
  56. 15 private
  57. 15 def fetch_response(request, selector, options)
  58. 3738309 redirect_request = request.redirect_request
  59. 3738309 response = super(redirect_request, selector, options)
  60. 3738309 return unless response
  61. 568 max_redirects = redirect_request.max_redirects
  62. 568 return response unless response.is_a?(Response)
  63. 552 return response unless REDIRECT_STATUS.include?(response.status) && response.headers.key?("location")
  64. 361 return response unless max_redirects.positive?
  65. 329 redirect_uri = __get_location_from_response(response)
  66. 329 if options.redirect_on
  67. 32 redirect_allowed = options.redirect_on.call(redirect_uri)
  68. 32 return response unless redirect_allowed
  69. end
  70. # build redirect request
  71. 313 request_body = redirect_request.body
  72. 313 redirect_method = "GET"
  73. 313 redirect_params = {}
  74. 313 if response.status == 305 && options.respond_to?(:proxy)
  75. 8 request_body.rewind
  76. # The requested resource MUST be accessed through the proxy given by
  77. # the Location field. The Location field gives the URI of the proxy.
  78. 8 redirect_options = options.merge(headers: redirect_request.headers,
  79. proxy: { uri: redirect_uri },
  80. max_redirects: max_redirects - 1)
  81. 8 redirect_params[:body] = request_body
  82. 8 redirect_uri = redirect_request.uri
  83. 8 options = redirect_options
  84. else
  85. 305 redirect_headers = redirect_request_headers(redirect_request.uri, redirect_uri, request.headers, options)
  86. 305 redirect_opts = Hash[options]
  87. 305 redirect_params[:max_redirects] = max_redirects - 1
  88. 305 unless request_body.empty?
  89. 24 if response.status == 307
  90. # The method and the body of the original request are reused to perform the redirected request.
  91. 8 redirect_method = redirect_request.verb
  92. 8 request_body.rewind
  93. 8 redirect_params[:body] = request_body
  94. else
  95. # redirects are **ALWAYS** GET, so remove body-related headers
  96. 16 REQUEST_BODY_HEADERS.each do |h|
  97. 112 redirect_headers.delete(h)
  98. end
  99. 16 redirect_params[:body] = nil
  100. end
  101. end
  102. 305 options = options.class.new(redirect_opts.merge(headers: redirect_headers.to_h))
  103. end
  104. 313 redirect_uri = Utils.to_uri(redirect_uri)
  105. 313 if !options.follow_insecure_redirects &&
  106. response.uri.scheme == "https" &&
  107. redirect_uri.scheme == "http"
  108. 8 error = InsecureRedirectError.new(redirect_uri.to_s)
  109. 8 error.set_backtrace(caller)
  110. 8 return ErrorResponse.new(request, error)
  111. end
  112. 305 retry_request = build_request(redirect_method, redirect_uri, redirect_params, options)
  113. 305 request.redirect_request = retry_request
  114. 305 redirect_after = response.headers["retry-after"]
  115. 305 if redirect_after
  116. # Servers send the "Retry-After" header field to indicate how long the
  117. # user agent ought to wait before making a follow-up request.
  118. # When sent with any 3xx (Redirection) response, Retry-After indicates
  119. # the minimum time that the user agent is asked to wait before issuing
  120. # the redirected request.
  121. #
  122. 31 redirect_after = Utils.parse_retry_after(redirect_after)
  123. 31 retry_start = Utils.now
  124. 31 log { "redirecting after #{redirect_after} secs..." }
  125. 31 selector.after(redirect_after) do
  126. 31 if (response = request.response)
  127. 15 response.finish!
  128. 15 retry_request.response = response
  129. # request has terminated abruptly meanwhile
  130. 15 retry_request.emit(:response, response)
  131. else
  132. 16 log { "redirecting (elapsed time: #{Utils.elapsed_time(retry_start)})!!" }
  133. 16 send_request(retry_request, selector, options)
  134. end
  135. end
  136. else
  137. 274 send_request(retry_request, selector, options)
  138. end
  139. 116 nil
  140. end
  141. # :nodoc:
  142. 15 def redirect_request_headers(original_uri, redirect_uri, headers, options)
  143. 305 headers = headers.dup
  144. 305 return headers if options.allow_auth_to_other_origins
  145. 297 return headers unless headers.key?("authorization")
  146. 8 return headers if original_uri.origin == redirect_uri.origin
  147. 8 headers.delete("authorization")
  148. 8 headers
  149. end
  150. # :nodoc:
  151. 15 def __get_location_from_response(response)
  152. # @type var location_uri: http_uri
  153. 329 location_uri = URI(response.headers["location"])
  154. 329 location_uri = response.uri.merge(location_uri) if location_uri.relative?
  155. 329 location_uri
  156. end
  157. end
  158. 15 module RequestMethods
  159. # returns the top-most original HTTPX::Request from the redirect chain
  160. 15 attr_accessor :root_request
  161. # returns the follow-up redirect request, or itself
  162. 15 def redirect_request
  163. 3738309 @redirect_request || self
  164. end
  165. # sets the follow-up redirect request
  166. 15 def redirect_request=(req)
  167. 305 @redirect_request = req
  168. 305 req.root_request = @root_request || self
  169. 305 @response = nil
  170. end
  171. 15 def response
  172. 3740235 return super unless @redirect_request && @response.nil?
  173. 97 @redirect_request.response
  174. end
  175. 15 def max_redirects
  176. 568 @options.max_redirects || MAX_REDIRECTS
  177. end
  178. end
  179. 15 module ConnectionMethods
  180. 15 private
  181. 15 def set_request_request_timeout(request)
  182. 541 return unless request.root_request.nil?
  183. 257 super
  184. end
  185. end
  186. end
  187. 15 register_plugin :follow_redirects, FollowRedirects
  188. end
  189. end

lib/httpx/plugins/grpc.rb

100.0% lines covered

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

47 relevant lines. 46 lines covered and 1 lines missed.
    
  1. # frozen_string_literal: true
  2. 6 module HTTPX
  3. 6 module Transcoder
  4. 6 module GRPCEncoding
  5. 6 class Deflater
  6. 6 extend Forwardable
  7. 6 attr_reader :content_type
  8. 6 def initialize(body, compressed:)
  9. 126 @content_type = body.content_type
  10. 126 @body = BodyReader.new(body)
  11. 126 @compressed = compressed
  12. end
  13. 6 def bytesize
  14. 450 return @body.bytesize if @body.respond_to?(:bytesize)
  15. Float::INFINITY
  16. end
  17. 6 def read(length = nil, outbuf = nil)
  18. 276 buf = @body.read(length, outbuf)
  19. 252 return unless buf
  20. 138 compressed_flag = @compressed ? 1 : 0
  21. 138 buf = outbuf if outbuf
  22. 138 buf = buf.b if buf.frozen?
  23. 138 buf.prepend([compressed_flag, buf.bytesize].pack("CL>"))
  24. 138 buf
  25. end
  26. end
  27. 6 class Inflater
  28. 6 def initialize(response)
  29. 90 @response = response
  30. 90 @grpc_encodings = nil
  31. end
  32. 6 def call(message, &blk)
  33. 114 data = "".b
  34. 114 until message.empty?
  35. 114 compressed, size = message.unpack("CL>")
  36. 114 encoded_data = message.byteslice(5..size + 5 - 1)
  37. 114 if compressed == 1
  38. 12 grpc_encodings.reverse_each do |encoding|
  39. 12 decoder = @response.body.class.initialize_inflater_by_encoding(encoding, @response, bytesize: encoded_data.bytesize)
  40. 12 encoded_data = decoder.call(encoded_data)
  41. 12 blk.call(encoded_data) if blk
  42. 12 data << encoded_data
  43. end
  44. else
  45. 102 blk.call(encoded_data) if blk
  46. 102 data << encoded_data
  47. end
  48. 114 message = message.byteslice((size + 5)..-1)
  49. end
  50. 114 data
  51. end
  52. 6 private
  53. 6 def grpc_encodings
  54. 12 @grpc_encodings ||= @response.headers.get("grpc-encoding")
  55. end
  56. end
  57. 6 def self.encode(*args, **kwargs)
  58. 126 Deflater.new(*args, **kwargs)
  59. end
  60. 6 def self.decode(response)
  61. 90 Inflater.new(response)
  62. end
  63. end
  64. end
  65. 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. 6 module HTTPX
  3. 6 module Plugins
  4. 6 module GRPC
  5. # Encoding module for GRPC responses
  6. #
  7. # Can encode and decode grpc messages.
  8. 6 module Message
  9. 6 module_function
  10. # decodes a unary grpc response
  11. 6 def unary(response)
  12. 90 verify_status(response)
  13. 66 decoder = Transcoder::GRPCEncoding.decode(response)
  14. 66 decoder.call(response.to_s)
  15. end
  16. # lazy decodes a grpc stream response
  17. 6 def stream(response, &block)
  18. 48 return enum_for(__method__, response) unless block
  19. 24 decoder = Transcoder::GRPCEncoding.decode(response)
  20. 24 response.each do |frame|
  21. 48 decoder.call(frame, &block)
  22. end
  23. 24 verify_status(response)
  24. end
  25. 6 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. 6 def verify_status(response)
  31. # return standard errors if need be
  32. 114 response.raise_for_status
  33. 114 status = Integer(response.headers["grpc-status"])
  34. 114 message = response.headers["grpc-message"]
  35. 114 return if status.zero?
  36. 24 response.close
  37. 24 raise GRPCError.new(status, message, response.trailing_metadata)
  38. end
  39. end
  40. end
  41. end
  42. end

lib/httpx/plugins/h2c.rb

95.24% lines covered

63 relevant lines. 60 lines covered and 3 lines missed.
    
  1. # frozen_string_literal: true
  2. 17 module HTTPX
  3. 17 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. 17 module H2C
  11. 17 VALID_H2C_VERBS = %w[GET OPTIONS HEAD].freeze
  12. 17 class << self
  13. 17 def load_dependencies(klass)
  14. 16 klass.plugin(:upgrade)
  15. end
  16. 17 def call(connection, request, response)
  17. 16 connection.upgrade_to_h2c(request, response)
  18. end
  19. 17 def extra_options(options)
  20. 20 options.merge(
  21. 16 h2c_class: Class.new(options.http2_class) { include(H2CParser) },
  22. max_concurrent_requests: 1,
  23. upgrade_handlers: options.upgrade_handlers.merge("h2c" => self),
  24. )
  25. end
  26. end
  27. 17 module OptionsMethods
  28. 17 def option_h2c_class(value)
  29. 16 value
  30. end
  31. end
  32. 17 module RequestMethods
  33. 17 def valid_h2c_verb?
  34. 16 VALID_H2C_VERBS.include?(@verb)
  35. end
  36. end
  37. 17 module ConnectionMethods
  38. 17 using URIExtensions
  39. 17 def initialize(*)
  40. 16 super
  41. 16 @h2c_handshake = false
  42. end
  43. 17 def send(request)
  44. 56 return super if @h2c_handshake
  45. 16 return super unless request.valid_h2c_verb? && request.scheme == "http"
  46. 16 return super if @upgrade_protocol == "h2c"
  47. 16 @h2c_handshake = true
  48. # build upgrade request
  49. 16 request.headers.add("connection", "upgrade")
  50. 16 request.headers.add("connection", "http2-settings")
  51. 16 request.headers["upgrade"] = "h2c"
  52. 16 request.headers["http2-settings"] = ::HTTP2::Client.settings_header(request.options.http2_settings)
  53. 16 super
  54. end
  55. 17 def upgrade_to_h2c(request, response)
  56. 16 prev_parser = @parser
  57. 16 if prev_parser
  58. 16 prev_parser.reset
  59. 16 @inflight -= prev_parser.requests.size
  60. end
  61. 16 @parser = request.options.h2c_class.new(@write_buffer, @options)
  62. 16 set_parser_callbacks(@parser)
  63. 16 @inflight += 1
  64. 16 @parser.upgrade(request, response)
  65. 16 @upgrade_protocol = "h2c"
  66. 16 prev_parser.requests.each do |req|
  67. 16 req.transition(:idle)
  68. 16 send(req)
  69. end
  70. end
  71. 17 private
  72. 17 def send_request_to_parser(request)
  73. 56 super
  74. 56 return unless request.headers["upgrade"] == "h2c" && parser.is_a?(Connection::HTTP1)
  75. 16 max_concurrent_requests = parser.max_concurrent_requests
  76. 16 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. 17 module H2CParser
  84. 17 def upgrade(request, response)
  85. # skip checks, it is assumed that this is the first
  86. # request in the connection
  87. 16 stream = @connection.upgrade
  88. # on_settings
  89. 16 handle_stream(stream, request)
  90. 16 @streams[request] = stream
  91. # clean up data left behind in the buffer, if the server started
  92. # sending frames
  93. 16 data = response.read
  94. 16 @connection << data
  95. end
  96. end
  97. end
  98. 17 register_plugin(:h2c, H2C)
  99. end
  100. end

lib/httpx/plugins/ntlm_auth.rb

100.0% lines covered

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

lib/httpx/plugins/oauth.rb

100.0% lines covered

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

lib/httpx/plugins/persistent.rb

100.0% lines covered

31 relevant lines. 31 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 17 module HTTPX
  3. 17 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. 17 module Persistent
  20. 17 class << self
  21. 17 def load_dependencies(klass)
  22. 520 klass.plugin(:fiber_concurrency)
  23. 520 max_retries = if klass.default_options.respond_to?(:max_retries)
  24. 8 [klass.default_options.max_retries, 1].max
  25. else
  26. 512 1
  27. end
  28. 520 klass.plugin(:retries, max_retries: max_retries)
  29. end
  30. end
  31. 17 def self.extra_options(options)
  32. 520 options.merge(persistent: true)
  33. end
  34. 17 module InstanceMethods
  35. 17 def close(*)
  36. 299 super
  37. # traverse other threads and unlink respective selector
  38. # WARNING: this is not thread safe, make sure that the session isn't being
  39. # used anymore, or all non-main threads are stopped.
  40. 299 Thread.list.each do |th|
  41. 4266 store = thread_selector_store(th)
  42. 4266 next unless store && store.key?(self)
  43. 292 selector = store.delete(self)
  44. 292 selector_close(selector)
  45. end
  46. end
  47. 17 private
  48. 17 def repeatable_request?(request, _)
  49. 600 super || begin
  50. 196 response = request.response
  51. 196 return false unless response && response.is_a?(ErrorResponse)
  52. 29 error = response.error
  53. 319 Retries::RECONNECTABLE_ERRORS.any? { |klass| error.is_a?(klass) }
  54. end
  55. end
  56. 17 def retryable_error?(ex)
  57. 84 super &&
  58. # under the persistent plugin rules, requests are only retried for connection related errors,
  59. # which do not include request timeout related errors. This only gets overriden if the end user
  60. # manually changed +:max_retries+ to something else, which means it is aware of the
  61. # consequences.
  62. 70 (!ex.is_a?(RequestTimeoutError) || @options.max_retries != 1)
  63. end
  64. end
  65. end
  66. 17 register_plugin :persistent, Persistent
  67. end
  68. end

lib/httpx/plugins/proxy.rb

94.71% lines covered

170 relevant lines. 161 lines covered and 9 lines missed.
    
  1. # frozen_string_literal: true
  2. 10 module HTTPX
  3. 10 class ProxyError < ConnectionError; end
  4. 10 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. 10 module Proxy
  16. 10 class ProxyConnectionError < ProxyError; end
  17. 10 PROXY_ERRORS = [TimeoutError, IOError, SystemCallError, Error].freeze
  18. 10 class << self
  19. 10 def configure(klass)
  20. 343 klass.plugin(:"proxy/http")
  21. 343 klass.plugin(:"proxy/socks4")
  22. 343 klass.plugin(:"proxy/socks5")
  23. end
  24. 10 def extra_options(options)
  25. 343 options.merge(supported_proxy_protocols: [])
  26. end
  27. 10 def subplugins
  28. {
  29. 1389 retries: ProxyRetries,
  30. }
  31. end
  32. end
  33. 10 class Parameters
  34. 10 attr_reader :uri, :username, :password, :scheme, :no_proxy
  35. 10 def initialize(uri: nil, scheme: nil, username: nil, password: nil, no_proxy: nil, **extra)
  36. 377 @no_proxy = Array(no_proxy) if no_proxy
  37. 377 @uris = Array(uri)
  38. 377 uri = @uris.first
  39. 377 @username = username
  40. 377 @password = password
  41. 377 @ns = 0
  42. 377 if uri
  43. 337 @uri = uri.is_a?(URI::Generic) ? uri : URI(uri)
  44. 337 @username ||= @uri.user
  45. 337 @password ||= @uri.password
  46. end
  47. 377 @scheme = scheme
  48. 377 return unless @uri && @username && @password
  49. 210 @authenticator = nil
  50. 210 @scheme ||= infer_default_auth_scheme(@uri)
  51. 210 return unless @scheme
  52. 162 @authenticator = load_authenticator(@scheme, @username, @password, **extra)
  53. end
  54. 10 def shift
  55. # TODO: this operation must be synchronized
  56. 16 @ns += 1
  57. 16 @uri = @uris[@ns]
  58. 16 return unless @uri
  59. 16 @uri = URI(@uri) unless @uri.is_a?(URI::Generic)
  60. 16 scheme = infer_default_auth_scheme(@uri)
  61. 16 return unless scheme != @scheme
  62. 16 @scheme = scheme
  63. 16 @username = username || @uri.user
  64. 16 @password = password || @uri.password
  65. 16 @authenticator = load_authenticator(scheme, @username, @password)
  66. end
  67. 10 def can_authenticate?(*args)
  68. 184 return false unless @authenticator
  69. 64 @authenticator.can_authenticate?(*args)
  70. end
  71. 10 def authenticate(*args)
  72. 159 return unless @authenticator
  73. 159 @authenticator.authenticate(*args)
  74. end
  75. 10 def ==(other)
  76. 454 case other
  77. when Parameters
  78. 414 @uri == other.uri &&
  79. @username == other.username &&
  80. @password == other.password &&
  81. @scheme == other.scheme
  82. when URI::Generic, String
  83. 24 proxy_uri = @uri.dup
  84. 24 proxy_uri.user = @username
  85. 24 proxy_uri.password = @password
  86. 24 other_uri = other.is_a?(URI::Generic) ? other : URI.parse(other)
  87. 24 proxy_uri == other_uri
  88. else
  89. 16 super
  90. end
  91. end
  92. 10 private
  93. 10 def infer_default_auth_scheme(uri)
  94. 210 case uri.scheme
  95. when "socks5"
  96. 48 uri.scheme
  97. when "http", "https"
  98. 88 "basic"
  99. end
  100. end
  101. 10 def load_authenticator(scheme, username, password, **extra)
  102. 178 auth_scheme = scheme.to_s.capitalize
  103. 178 require_relative "auth/#{scheme}" unless defined?(Authentication) && Authentication.const_defined?(auth_scheme, false)
  104. 178 Authentication.const_get(auth_scheme).new(username, password, **extra)
  105. end
  106. end
  107. # adds support for the following options:
  108. #
  109. # :proxy :: proxy options defining *:uri*, *:username*, *:password* or
  110. # *:scheme* (i.e. <tt>{ uri: "http://proxy" }</tt>)
  111. 10 module OptionsMethods
  112. 10 private
  113. 10 def option_proxy(value)
  114. 684 value.is_a?(Parameters) ? value : Parameters.new(**Hash[value])
  115. end
  116. 10 def option_supported_proxy_protocols(value)
  117. 1729 raise TypeError, ":supported_proxy_protocols must be an Array" unless value.is_a?(Array)
  118. 1729 value.map(&:to_s)
  119. end
  120. end
  121. 10 module InstanceMethods
  122. 10 def find_connection(request_uri, selector, options)
  123. 435 return super unless options.respond_to?(:proxy)
  124. 435 if (next_proxy = request_uri.find_proxy)
  125. 4 return super(request_uri, selector, options.merge(proxy: Parameters.new(uri: next_proxy)))
  126. end
  127. 431 proxy = options.proxy
  128. 431 return super unless proxy
  129. 421 next_proxy = proxy.uri
  130. 421 raise ProxyError, "Failed to connect to proxy" unless next_proxy
  131. raise ProxyError,
  132. 405 "#{next_proxy.scheme}: unsupported proxy protocol" unless options.supported_proxy_protocols.include?(next_proxy.scheme)
  133. 397 if (no_proxy = proxy.no_proxy)
  134. 16 no_proxy = no_proxy.join(",") if no_proxy.is_a?(Array)
  135. # TODO: setting proxy to nil leaks the connection object in the pool
  136. 16 return super(request_uri, selector, options.merge(proxy: nil)) unless URI::Generic.use_proxy?(request_uri.host, next_proxy.host,
  137. next_proxy.port, no_proxy)
  138. end
  139. 389 super(request_uri, selector, options.merge(proxy: proxy))
  140. end
  141. 10 private
  142. 10 def fetch_response(request, selector, options)
  143. 1791 response = request.response # in case it goes wrong later
  144. 311 begin
  145. 1791 response = super
  146. 1791 if response.is_a?(ErrorResponse) && proxy_error?(request, response, options)
  147. 16 options.proxy.shift
  148. # return last error response if no more proxies to try
  149. 16 return response if options.proxy.uri.nil?
  150. 16 log { "failed connecting to proxy, trying next..." }
  151. 16 request.transition(:idle)
  152. 16 send_request(request, selector, options)
  153. 16 return
  154. end
  155. 1775 response
  156. rescue ProxyError
  157. # may happen if coupled with retries, and there are no more proxies to try, in which case
  158. # it'll end up here
  159. response
  160. end
  161. end
  162. 10 def proxy_error?(_request, response, options)
  163. 169 return false unless options.proxy
  164. 168 error = response.error
  165. 168 case error
  166. when NativeResolveError
  167. 16 proxy_uri = URI(options.proxy.uri)
  168. 16 unresolved_host = error.host
  169. # failed resolving proxy domain
  170. 16 unresolved_host == proxy_uri.host
  171. when ResolveError
  172. proxy_uri = URI(options.proxy.uri)
  173. error.message.end_with?(proxy_uri.to_s)
  174. when ProxyConnectionError
  175. # timeout errors connecting to proxy
  176. true
  177. else
  178. 152 false
  179. end
  180. end
  181. end
  182. 10 module ConnectionMethods
  183. 10 using URIExtensions
  184. 10 def initialize(*)
  185. 390 super
  186. 390 return unless @options.proxy
  187. # redefining the connection origin as the proxy's URI,
  188. # as this will be used as the tcp peer ip.
  189. 372 @proxy_uri = URI(@options.proxy.uri)
  190. end
  191. 10 def peer
  192. 954 @proxy_uri || super
  193. end
  194. 10 def connecting?
  195. 5399 return super unless @options.proxy
  196. 5243 super || @state == :connecting || @state == :connected
  197. end
  198. 10 def call
  199. 1336 super
  200. 1336 return unless @options.proxy
  201. 1303 case @state
  202. when :connecting
  203. 339 consume
  204. end
  205. rescue *PROXY_ERRORS => e
  206. if connecting?
  207. error = ProxyConnectionError.new(e.message)
  208. error.set_backtrace(e.backtrace)
  209. raise error
  210. end
  211. raise e
  212. end
  213. 10 def reset
  214. 412 return super unless @options.proxy
  215. 395 @state = :open
  216. 395 super
  217. # emit(:close)
  218. end
  219. 10 private
  220. 10 def initialize_type(uri, options)
  221. 390 return super unless options.proxy
  222. 372 "tcp"
  223. end
  224. 10 def connect
  225. 1116 return super unless @options.proxy
  226. 1082 case @state
  227. when :idle
  228. 736 transition(:connecting)
  229. when :connected
  230. 346 transition(:open)
  231. end
  232. end
  233. 10 def handle_transition(nextstate)
  234. 2267 return super unless @options.proxy
  235. 2180 case nextstate
  236. when :closing
  237. # this is a hack so that we can use the super method
  238. # and it'll think that the current state is open
  239. 395 @state = :open if @state == :connecting
  240. end
  241. 2180 super
  242. end
  243. 10 def purge_after_closed
  244. 435 super
  245. 435 @io = @io.proxy_io if @io.respond_to?(:proxy_io)
  246. end
  247. end
  248. 10 module ProxyRetries
  249. 10 module InstanceMethods
  250. 10 def retryable_error?(ex)
  251. 54 super || ex.is_a?(ProxyConnectionError)
  252. end
  253. end
  254. end
  255. end
  256. 10 register_plugin :proxy, Proxy
  257. end
  258. 10 class ProxySSL < SSL
  259. 10 attr_reader :proxy_io
  260. 10 def initialize(tcp, request_uri, options)
  261. 101 @proxy_io = tcp
  262. 101 @io = tcp.to_io
  263. 101 super(request_uri, tcp.addresses, options)
  264. 101 @hostname = request_uri.host
  265. 101 @state = :connected
  266. end
  267. end
  268. end

lib/httpx/plugins/proxy/http.rb

94.02% lines covered

117 relevant lines. 110 lines covered and 7 lines missed.
    
  1. # frozen_string_literal: true
  2. 10 module HTTPX
  3. 10 module Plugins
  4. 10 module Proxy
  5. 10 module HTTP
  6. 10 class << self
  7. 10 def extra_options(options)
  8. 343 options.merge(supported_proxy_protocols: options.supported_proxy_protocols + %w[http])
  9. end
  10. end
  11. 10 module InstanceMethods
  12. 10 def with_proxy_basic_auth(opts)
  13. 8 with(proxy: opts.merge(scheme: "basic"))
  14. end
  15. 10 def with_proxy_digest_auth(opts)
  16. 24 with(proxy: opts.merge(scheme: "digest"))
  17. end
  18. 10 def with_proxy_ntlm_auth(opts)
  19. 8 with(proxy: opts.merge(scheme: "ntlm"))
  20. end
  21. 10 def fetch_response(request, selector, options)
  22. 1791 response = super
  23. 1791 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. 8 request.transition(:idle)
  29. 8 request.headers["proxy-authorization"] =
  30. options.proxy.authenticate(request, response.headers["proxy-authenticate"])
  31. 8 send_request(request, selector, options)
  32. 8 return
  33. end
  34. 1783 response
  35. end
  36. end
  37. 10 module ConnectionMethods
  38. 10 def connecting?
  39. 5399 super || @state == :connecting || @state == :connected
  40. end
  41. 10 def force_close(*)
  42. if @state == :connecting
  43. # proxy connect related requests should not be reenqueed
  44. @parser.reset!
  45. @inflight -= @parser.pending.size
  46. @parser.pending.clear
  47. end
  48. super
  49. end
  50. 10 private
  51. 10 def handle_transition(nextstate)
  52. 2518 return super unless @options.proxy && @options.proxy.uri.scheme == "http"
  53. 1308 case nextstate
  54. when :connecting
  55. 328 return unless @state == :idle
  56. 328 @io.connect
  57. 328 return unless @io.connected?
  58. 164 @parser || begin
  59. 156 @parser = parser_type(@io.protocol).new(@write_buffer, @options.merge(max_concurrent_requests: 1))
  60. 156 parser = @parser
  61. 156 parser.extend(ProxyParser)
  62. 156 parser.on(:response, &method(:__http_on_connect))
  63. 156 parser.on(:close) do
  64. 69 next unless @parser
  65. 8 reset
  66. 8 disconnect
  67. end
  68. 156 parser.on(:reset) do
  69. 16 if parser.empty?
  70. 8 reset
  71. else
  72. 8 enqueue_pending_requests_from_parser(parser)
  73. 8 initial_state = @state
  74. 8 reset
  75. 8 if @pending.empty?
  76. @parser = nil
  77. next
  78. end
  79. # keep parser state around due to proxy auth protocol;
  80. # intermediate authenticated request is already inside
  81. # the parser
  82. 8 parser = nil
  83. 8 if initial_state == :connecting
  84. 8 parser = @parser
  85. 8 @parser.reset
  86. end
  87. 8 idling
  88. 8 @parser = parser
  89. 8 transition(:connecting)
  90. end
  91. end
  92. 156 __http_proxy_connect(parser)
  93. end
  94. 164 return if @state == :connected
  95. when :connected
  96. 148 return unless @state == :idle || @state == :connecting
  97. 148 case @state
  98. when :connecting
  99. 61 parser = @parser
  100. 61 @parser = nil
  101. 61 parser.close
  102. when :idle
  103. 87 @parser.callbacks.clear
  104. 87 set_parser_callbacks(@parser)
  105. end
  106. end
  107. 1057 super
  108. end
  109. 10 def __http_proxy_connect(parser)
  110. 156 req = @pending.first
  111. 156 if req && req.uri.scheme == "https"
  112. # if the first request after CONNECT is to an https address, it is assumed that
  113. # all requests in the queue are not only ALL HTTPS, but they also share the certificate,
  114. # and therefore, will share the connection.
  115. #
  116. 69 connect_request = ConnectRequest.new(req.uri, @options)
  117. 69 @inflight += 1
  118. 69 parser.send(connect_request)
  119. else
  120. 87 handle_transition(:connected)
  121. end
  122. end
  123. 10 def __http_on_connect(request, response)
  124. 77 @inflight -= 1
  125. 77 if response.is_a?(Response) && response.status == 200
  126. 61 req = @pending.first
  127. 61 request_uri = req.uri
  128. 61 @io = ProxySSL.new(@io, request_uri, @options)
  129. 61 transition(:connected)
  130. 61 throw(:called)
  131. 14 elsif response.is_a?(Response) &&
  132. response.status == 407 &&
  133. !request.headers.key?("proxy-authorization") &&
  134. @options.proxy.can_authenticate?(response.headers["proxy-authenticate"])
  135. 8 request.transition(:idle)
  136. 8 request.headers["proxy-authorization"] = @options.proxy.authenticate(request, response.headers["proxy-authenticate"])
  137. 8 @parser.send(request)
  138. 8 @inflight += 1
  139. else
  140. 8 pending = @pending + @parser.pending
  141. 20 while (req = pending.shift)
  142. 8 response.finish!
  143. 8 req.response = response
  144. 8 req.emit(:response, response)
  145. end
  146. 8 reset
  147. end
  148. end
  149. end
  150. 10 module ProxyParser
  151. 10 def join_headline(request)
  152. 156 return super if request.verb == "CONNECT"
  153. 79 "#{request.verb} #{request.uri} HTTP/#{@version.join(".")}"
  154. end
  155. 10 def set_protocol_headers(request)
  156. 164 extra_headers = super
  157. 164 proxy_params = @options.proxy
  158. 164 if proxy_params.scheme == "basic"
  159. # opt for basic auth
  160. 95 extra_headers["proxy-authorization"] = proxy_params.authenticate(extra_headers)
  161. end
  162. 164 extra_headers["proxy-connection"] = extra_headers.delete("connection") if extra_headers.key?("connection")
  163. 164 extra_headers
  164. end
  165. end
  166. 10 class ConnectRequest < Request
  167. 10 def initialize(uri, options)
  168. 69 super("CONNECT", uri, options)
  169. 69 @headers.delete("accept")
  170. end
  171. 10 def path
  172. 85 "#{@uri.hostname}:#{@uri.port}"
  173. end
  174. end
  175. end
  176. end
  177. 10 register_plugin :"proxy/http", Proxy::HTTP
  178. end
  179. 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. 10 require "resolv"
  3. 10 require "ipaddr"
  4. 10 module HTTPX
  5. 10 class Socks4Error < ProxyError; end
  6. 10 module Plugins
  7. 10 module Proxy
  8. 10 module Socks4
  9. 10 VERSION = 4
  10. 10 CONNECT = 1
  11. 10 GRANTED = 0x5A
  12. 10 PROTOCOLS = %w[socks4 socks4a].freeze
  13. 10 Error = Socks4Error
  14. 10 class << self
  15. 10 def extra_options(options)
  16. 343 options.merge(supported_proxy_protocols: options.supported_proxy_protocols + PROTOCOLS)
  17. end
  18. end
  19. 10 module ConnectionMethods
  20. 10 def interests
  21. 4052 if @state == :connecting
  22. return @write_buffer.empty? ? :r : :w
  23. end
  24. 4052 super
  25. end
  26. 10 private
  27. 10 def handle_transition(nextstate)
  28. 2582 return super unless @options.proxy && PROTOCOLS.include?(@options.proxy.uri.scheme)
  29. 437 case nextstate
  30. when :connecting
  31. 128 return unless @state == :idle
  32. 128 @io.connect
  33. 128 return unless @io.connected?
  34. 64 req = @pending.first
  35. 64 return unless req
  36. 64 request_uri = req.uri
  37. 64 @write_buffer << Packet.connect(@options.proxy, request_uri)
  38. 64 __socks4_proxy_connect
  39. when :connected
  40. 48 return unless @state == :connecting
  41. 48 @parser = nil
  42. end
  43. 373 log(level: 1) { "SOCKS4: #{nextstate}: #{@write_buffer.to_s.inspect}" } unless nextstate == :open
  44. 373 super
  45. end
  46. 10 def __socks4_proxy_connect
  47. 64 @parser = SocksParser.new(@write_buffer, @options)
  48. 64 @parser.once(:packet, &method(:__socks4_on_packet))
  49. end
  50. 10 def __socks4_on_packet(packet)
  51. 64 _version, status, _port, _ip = packet.unpack("CCnN")
  52. 64 if status == GRANTED
  53. 48 req = @pending.first
  54. 48 request_uri = req.uri
  55. 48 @io = ProxySSL.new(@io, request_uri, @options) if request_uri.scheme == "https"
  56. 48 transition(:connected)
  57. 48 throw(:called)
  58. else
  59. 16 on_socks4_error("socks error: #{status}")
  60. end
  61. end
  62. 10 def on_socks4_error(message)
  63. 16 ex = Error.new(message)
  64. 16 ex.set_backtrace(caller)
  65. 16 on_error(ex)
  66. 16 throw(:called)
  67. end
  68. end
  69. 10 class SocksParser
  70. 10 include HTTPX::Callbacks
  71. 10 def initialize(buffer, options)
  72. 64 @buffer = buffer
  73. 64 @options = options
  74. end
  75. 10 def close; end
  76. 10 def consume(*); end
  77. 10 def empty?
  78. true
  79. end
  80. 10 def <<(packet)
  81. 64 emit(:packet, packet)
  82. end
  83. end
  84. 10 module Packet
  85. 10 module_function
  86. 10 def connect(parameters, uri)
  87. 64 packet = [VERSION, CONNECT, uri.port].pack("CCn")
  88. 64 case parameters.uri.scheme
  89. when "socks4"
  90. 48 socks_host = uri.host
  91. 10 begin
  92. 96 ip = IPAddr.new(socks_host)
  93. 48 packet << ip.hton
  94. rescue IPAddr::InvalidAddressError
  95. 48 socks_host = Resolv.getaddress(socks_host)
  96. 48 retry
  97. end
  98. 48 packet << [parameters.username].pack("Z*")
  99. when "socks4a"
  100. 16 packet << "\x0\x0\x0\x1" << [parameters.username].pack("Z*") << uri.host << "\x0"
  101. end
  102. 64 packet
  103. end
  104. end
  105. end
  106. end
  107. 10 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. 10 module HTTPX
  3. 10 class Socks5Error < ProxyError; end
  4. 10 module Plugins
  5. 10 module Proxy
  6. 10 module Socks5
  7. 10 VERSION = 5
  8. 10 NOAUTH = 0
  9. 10 PASSWD = 2
  10. 10 NONE = 0xff
  11. 10 CONNECT = 1
  12. 10 IPV4 = 1
  13. 10 DOMAIN = 3
  14. 10 IPV6 = 4
  15. 10 SUCCESS = 0
  16. 10 Error = Socks5Error
  17. 10 class << self
  18. 10 def load_dependencies(*)
  19. 343 require_relative "../auth/socks5"
  20. end
  21. 10 def extra_options(options)
  22. 343 options.merge(supported_proxy_protocols: options.supported_proxy_protocols + %w[socks5])
  23. end
  24. end
  25. 10 module ConnectionMethods
  26. 10 def call
  27. 1336 super
  28. 1336 return unless @options.proxy && @options.proxy.uri.scheme == "socks5"
  29. 356 case @state
  30. when :connecting,
  31. :negotiating,
  32. :authenticating
  33. 157 consume
  34. end
  35. end
  36. 10 def connecting?
  37. 5399 super || @state == :authenticating || @state == :negotiating
  38. end
  39. 10 def interests
  40. 6771 if @state == :connecting || @state == :authenticating || @state == :negotiating
  41. 2719 return @write_buffer.empty? ? :r : :w
  42. end
  43. 4052 super
  44. end
  45. 10 private
  46. 10 def handle_transition(nextstate)
  47. 2870 return super unless @options.proxy && @options.proxy.uri.scheme == "socks5"
  48. 1038 case nextstate
  49. when :connecting
  50. 288 return unless @state == :idle
  51. 288 @io.connect
  52. 288 return unless @io.connected?
  53. 144 @write_buffer << Packet.negotiate(@options.proxy)
  54. 144 __socks5_proxy_connect
  55. when :authenticating
  56. 48 return unless @state == :connecting
  57. 48 @write_buffer << Packet.authenticate(@options.proxy)
  58. when :negotiating
  59. 192 return unless @state == :connecting || @state == :authenticating
  60. 48 req = @pending.first
  61. 48 request_uri = req.uri
  62. 48 @write_buffer << Packet.connect(request_uri)
  63. when :connected
  64. 32 return unless @state == :negotiating
  65. 32 @parser = nil
  66. end
  67. 750 log(level: 1) { "SOCKS5: #{nextstate}: #{@write_buffer.to_s.inspect}" } unless nextstate == :open
  68. 750 super
  69. end
  70. 10 def __socks5_proxy_connect
  71. 144 @parser = SocksParser.new(@write_buffer, @options)
  72. 144 @parser.on(:packet, &method(:__socks5_on_packet))
  73. 144 transition(:negotiating)
  74. end
  75. 10 def __socks5_on_packet(packet)
  76. 240 case @state
  77. when :connecting
  78. 144 version, method = packet.unpack("CC")
  79. 144 __socks5_check_version(version)
  80. 144 case method
  81. when PASSWD
  82. 48 transition(:authenticating)
  83. 18 nil
  84. when NONE
  85. 80 __on_socks5_error("no supported authorization methods")
  86. else
  87. 16 transition(:negotiating)
  88. end
  89. when :authenticating
  90. 48 _, status = packet.unpack("CC")
  91. 48 return transition(:negotiating) if status == SUCCESS
  92. 16 __on_socks5_error("socks authentication error: #{status}")
  93. when :negotiating
  94. 48 version, reply, = packet.unpack("CC")
  95. 48 __socks5_check_version(version)
  96. 48 __on_socks5_error("socks5 negotiation error: #{reply}") unless reply == SUCCESS
  97. 32 req = @pending.first
  98. 32 request_uri = req.uri
  99. 32 @io = ProxySSL.new(@io, request_uri, @options) if request_uri.scheme == "https"
  100. 32 transition(:connected)
  101. 32 throw(:called)
  102. end
  103. end
  104. 10 def __socks5_check_version(version)
  105. 192 __on_socks5_error("invalid SOCKS version (#{version})") if version != 5
  106. end
  107. 10 def __on_socks5_error(message)
  108. 112 ex = Error.new(message)
  109. 112 ex.set_backtrace(caller)
  110. 112 on_error(ex)
  111. 112 throw(:called)
  112. end
  113. end
  114. 10 class SocksParser
  115. 10 include HTTPX::Callbacks
  116. 10 def initialize(buffer, options)
  117. 144 @buffer = buffer
  118. 144 @options = options
  119. end
  120. 10 def close; end
  121. 10 def consume(*); end
  122. 10 def empty?
  123. true
  124. end
  125. 10 def <<(packet)
  126. 240 emit(:packet, packet)
  127. end
  128. end
  129. 10 module Packet
  130. 10 module_function
  131. 10 def negotiate(parameters)
  132. 144 methods = [NOAUTH]
  133. 144 methods << PASSWD if parameters.can_authenticate?
  134. 144 methods.unshift(methods.size)
  135. 144 methods.unshift(VERSION)
  136. 144 methods.pack("C*")
  137. end
  138. 10 def authenticate(parameters)
  139. 48 parameters.authenticate
  140. end
  141. 10 def connect(uri)
  142. 48 packet = [VERSION, CONNECT, 0].pack("C*")
  143. 10 begin
  144. 48 ip = IPAddr.new(uri.host)
  145. 16 ipcode = ip.ipv6? ? IPV6 : IPV4
  146. 16 packet << [ipcode].pack("C") << ip.hton
  147. rescue IPAddr::InvalidAddressError
  148. 32 packet << [DOMAIN, uri.host.bytesize, uri.host].pack("CCA*")
  149. end
  150. 48 packet << [uri.port].pack("n")
  151. 48 packet
  152. end
  153. end
  154. end
  155. end
  156. 10 register_plugin :"proxy/socks5", Proxy::Socks5
  157. end
  158. end

lib/httpx/plugins/proxy/ssh.rb

92.45% lines covered

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

lib/httpx/plugins/query.rb

100.0% lines covered

14 relevant lines. 14 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 8 module HTTPX
  3. 8 module Plugins
  4. #
  5. # This plugin adds support for using the experimental QUERY HTTP method
  6. #
  7. # https://gitlab.com/os85/httpx/wikis/Query
  8. 8 module Query
  9. 8 def self.subplugins
  10. {
  11. 22 retries: QueryRetries,
  12. }
  13. end
  14. 8 module InstanceMethods
  15. 8 def query(*uri, **options)
  16. 16 request("QUERY", uri, **options)
  17. end
  18. end
  19. 8 module QueryRetries
  20. 8 module InstanceMethods
  21. 8 private
  22. 8 def repeatable_request?(request, options)
  23. 24 super || request.verb == "QUERY"
  24. end
  25. end
  26. end
  27. end
  28. 8 register_plugin :query, Query
  29. end
  30. 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. 8 module HTTPX
  3. 8 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. 8 module RateLimiter
  14. 8 class << self
  15. 8 RATE_LIMIT_CODES = [429, 503].freeze
  16. 8 def configure(klass)
  17. 64 klass.plugin(:retries,
  18. retry_change_requests: true,
  19. 14 retry_on: method(:retry_on_rate_limited_response),
  20. retry_after: method(:retry_after_rate_limit))
  21. end
  22. 8 def retry_on_rate_limited_response(response)
  23. 128 return false unless response.is_a?(Response)
  24. 128 status = response.status
  25. 128 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. 8 def retry_after_rate_limit(_, response)
  36. 64 return unless response.is_a?(Response)
  37. 64 retry_after = response.headers["retry-after"]
  38. 64 return unless retry_after
  39. 32 Utils.parse_retry_after(retry_after)
  40. end
  41. end
  42. end
  43. 8 register_plugin :rate_limiter, RateLimiter
  44. end
  45. end

lib/httpx/plugins/response_cache.rb

100.0% lines covered

148 relevant lines. 148 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 8 module HTTPX
  3. 8 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. 8 module ResponseCache
  10. 8 CACHEABLE_VERBS = %w[GET HEAD].freeze
  11. 8 CACHEABLE_STATUS_CODES = [200, 203, 206, 300, 301, 410].freeze
  12. 8 SUPPORTED_VARY_HEADERS = %w[accept accept-encoding accept-language cookie origin].sort.freeze
  13. 8 private_constant :CACHEABLE_VERBS
  14. 8 private_constant :CACHEABLE_STATUS_CODES
  15. 8 class << self
  16. 8 def load_dependencies(*)
  17. 240 require_relative "response_cache/store"
  18. 240 require_relative "response_cache/file_store"
  19. end
  20. # whether the +response+ can be stored in the response cache.
  21. # (i.e. has a cacheable body, does not contain directives prohibiting storage, etc...)
  22. 8 def cacheable_response?(response)
  23. 136 response.is_a?(Response) &&
  24. (
  25. 136 response.cache_control.nil? ||
  26. # TODO: !response.cache_control.include?("private") && is shared cache
  27. !response.cache_control.include?("no-store")
  28. ) &&
  29. CACHEABLE_STATUS_CODES.include?(response.status) &&
  30. # RFC 2616 13.4 - A response received with a status code of 200, 203, 206, 300, 301 or
  31. # 410 MAY be stored by a cache and used in reply to a subsequent
  32. # request, subject to the expiration mechanism, unless a cache-control
  33. # directive prohibits caching. However, a cache that does not support
  34. # the Range and Content-Range headers MUST NOT cache 206 (Partial
  35. # Content) responses.
  36. response.status != 206
  37. end
  38. # whether the +response+
  39. 8 def not_modified?(response)
  40. 168 response.is_a?(Response) && response.status == 304
  41. end
  42. 8 def extra_options(options)
  43. 240 options.merge(
  44. supported_vary_headers: SUPPORTED_VARY_HEADERS,
  45. response_cache_store: :store,
  46. )
  47. end
  48. end
  49. # adds support for the following options:
  50. #
  51. # :supported_vary_headers :: array of header values that will be considered for a "vary" header based cache validation
  52. # (defaults to {SUPPORTED_VARY_HEADERS}).
  53. # :response_cache_store :: object where cached responses are fetch from or stored in; defaults to <tt>:store</tt> (in-memory
  54. # cache), can be set to <tt>:file_store</tt> (file system cache store) as well, or any object which
  55. # abides by the Cache Store Interface
  56. #
  57. # The Cache Store Interface requires implementation of the following methods:
  58. #
  59. # * +#get(request) -> response or nil+
  60. # * +#set(request, response) -> void+
  61. # * +#clear() -> void+)
  62. #
  63. 8 module OptionsMethods
  64. 8 private
  65. 8 def option_response_cache_store(value)
  66. 408 case value
  67. when :store
  68. 256 Store.new
  69. when :file_store
  70. 16 FileStore.new
  71. else
  72. 136 value
  73. end
  74. end
  75. 8 def option_supported_vary_headers(value)
  76. 240 Array(value).sort
  77. end
  78. end
  79. 8 module InstanceMethods
  80. # wipes out all cached responses from the cache store.
  81. 8 def clear_response_cache
  82. 152 @options.response_cache_store.clear
  83. end
  84. 8 def build_request(*)
  85. 496 request = super
  86. 496 return request unless cacheable_request?(request)
  87. 480 prepare_cache(request)
  88. 480 request
  89. end
  90. 8 private
  91. 8 def send_request(request, *)
  92. 168 return request if request.response
  93. 152 super
  94. end
  95. 8 def fetch_response(request, *)
  96. 582 response = super
  97. 582 return unless response
  98. 168 if ResponseCache.not_modified?(response)
  99. 32 log { "returning cached response for #{request.uri}" }
  100. 32 response.copy_from_cached!
  101. 134 elsif request.cacheable_verb? && ResponseCache.cacheable_response?(response)
  102. 112 unless response.cached?
  103. 96 log { "caching response for #{request.uri}..." }
  104. 96 request.options.response_cache_store.set(request, response)
  105. end
  106. end
  107. 168 response
  108. end
  109. # will either assign a still-fresh cached response to +request+, or set up its HTTP
  110. # cache invalidation headers in case it's not fresh anymore.
  111. 8 def prepare_cache(request)
  112. 704 cached_response = request.options.response_cache_store.get(request)
  113. 704 return unless cached_response && match_by_vary?(request, cached_response)
  114. 304 cached_response.body.rewind
  115. 304 if cached_response.fresh?
  116. 64 cached_response = cached_response.dup
  117. 64 cached_response.mark_as_cached!
  118. 64 request.response = cached_response
  119. 64 request.emit(:response, cached_response)
  120. 64 return
  121. end
  122. 240 request.cached_response = cached_response
  123. 240 if !request.headers.key?("if-modified-since") && (last_modified = cached_response.headers["last-modified"])
  124. 32 request.headers.add("if-modified-since", last_modified)
  125. end
  126. 240 if !request.headers.key?("if-none-match") && (etag = cached_response.headers["etag"]) # rubocop:disable Style/GuardClause
  127. 176 request.headers.add("if-none-match", etag)
  128. end
  129. end
  130. 8 def cacheable_request?(request)
  131. 496 request.cacheable_verb? &&
  132. (
  133. 480 !request.headers.key?("cache-control") || !request.headers.get("cache-control").include?("no-store")
  134. )
  135. end
  136. # whether the +response+ complies with the directives set by the +request+ "vary" header
  137. # (true when none is available).
  138. 8 def match_by_vary?(request, response)
  139. 304 vary = response.vary
  140. 304 return true unless vary
  141. 96 original_request = response.original_request
  142. 96 if vary == %w[*]
  143. 32 request.options.supported_vary_headers.each do |field|
  144. 160 return false unless request.headers[field] == original_request.headers[field]
  145. end
  146. 32 return true
  147. end
  148. 64 vary.all? do |field|
  149. 64 !original_request.headers.key?(field) || request.headers[field] == original_request.headers[field]
  150. end
  151. end
  152. end
  153. 8 module RequestMethods
  154. # points to a previously cached Response corresponding to this request.
  155. 8 attr_accessor :cached_response
  156. 8 def initialize(*)
  157. 672 super
  158. 672 @cached_response = nil
  159. end
  160. 8 def merge_headers(*)
  161. 328 super
  162. 328 @response_cache_key = nil
  163. end
  164. # returns whether this request is cacheable as per HTTP caching rules.
  165. 8 def cacheable_verb?
  166. 632 CACHEABLE_VERBS.include?(@verb)
  167. end
  168. # returns a unique cache key as a String identifying this request
  169. 8 def response_cache_key
  170. 1400 @response_cache_key ||= begin
  171. 504 keys = [@verb, @uri.merge(path)]
  172. 504 @options.supported_vary_headers.each do |field|
  173. 2520 value = @headers[field]
  174. 2520 keys << value if value
  175. end
  176. 504 Digest::SHA1.hexdigest("httpx-response-cache-#{keys.join("-")}")
  177. end
  178. end
  179. end
  180. 8 module ResponseMethods
  181. 8 attr_writer :original_request
  182. 8 def initialize(*)
  183. 536 super
  184. 536 @cached = false
  185. end
  186. # a copy of the request this response was originally cached from
  187. 8 def original_request
  188. 96 @original_request || @request
  189. end
  190. # whether this Response was duplicated from a previously {RequestMethods#cached_response}.
  191. 8 def cached?
  192. 613 @cached
  193. end
  194. # sets this Response as being duplicated from a previously cached response.
  195. 8 def mark_as_cached!
  196. 240 @cached = true
  197. end
  198. # eager-copies the response headers and body from {RequestMethods#cached_response}.
  199. 8 def copy_from_cached!
  200. 32 cached_response = @request.cached_response
  201. 32 return unless cached_response
  202. # 304 responses do not have content-type, which are needed for decoding.
  203. 32 @headers = @headers.class.new(cached_response.headers.merge(@headers))
  204. 32 @body = cached_response.body.dup
  205. 32 @body.rewind
  206. end
  207. # A response is fresh if its age has not yet exceeded its freshness lifetime.
  208. # other (#cache_control} directives may influence the outcome, as per the rules
  209. # from the {rfc}[https://www.rfc-editor.org/rfc/rfc7234]
  210. 8 def fresh?
  211. 304 if cache_control
  212. 112 return false if cache_control.include?("no-cache")
  213. 80 return true if cache_control.include?("immutable")
  214. # check age: max-age
  215. 192 max_age = cache_control.find { |directive| directive.start_with?("s-maxage") }
  216. 192 max_age ||= cache_control.find { |directive| directive.start_with?("max-age") }
  217. 80 max_age = max_age[/age=(\d+)/, 1] if max_age
  218. 80 max_age = max_age.to_i if max_age
  219. 80 return max_age > age if max_age
  220. end
  221. # check age: expires
  222. 192 if @headers.key?("expires")
  223. 10 begin
  224. 48 expires = Time.httpdate(@headers["expires"])
  225. rescue ArgumentError
  226. 16 return false
  227. end
  228. 32 return (expires - Time.now).to_i.positive?
  229. end
  230. 144 false
  231. end
  232. # returns the "cache-control" directives as an Array of String(s).
  233. 8 def cache_control
  234. 848 return @cache_control if defined?(@cache_control)
  235. @cache_control = begin
  236. 384 return unless @headers.key?("cache-control")
  237. 112 @headers["cache-control"].split(/ *, */)
  238. end
  239. end
  240. # returns the "vary" header value as an Array of (String) headers.
  241. 8 def vary
  242. 304 return @vary if defined?(@vary)
  243. @vary = begin
  244. 272 return unless @headers.key?("vary")
  245. 64 @headers["vary"].split(/ *, */).map(&:downcase)
  246. end
  247. end
  248. 8 private
  249. # returns the value of the "age" header as an Integer (time since epoch).
  250. # if no "age" of header exists, it returns the number of seconds since {#date}.
  251. 8 def age
  252. 80 return @headers["age"].to_i if @headers.key?("age")
  253. 80 (Time.now - date).to_i
  254. end
  255. # returns the value of the "date" header as a Time object
  256. 8 def date
  257. 80 @date ||= Time.httpdate(@headers["date"])
  258. rescue NoMethodError, ArgumentError
  259. 16 Time.now
  260. end
  261. end
  262. 8 module ResponseBodyMethods
  263. 8 def decode_chunk(chunk)
  264. 501 return chunk if @response.cached?
  265. 313 super
  266. end
  267. end
  268. end
  269. 8 register_plugin :response_cache, ResponseCache
  270. end
  271. end

lib/httpx/plugins/response_cache/file_store.rb

100.0% lines covered

73 relevant lines. 73 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 8 require "pathname"
  3. 8 module HTTPX::Plugins
  4. 8 module ResponseCache
  5. # Implementation of a file system based cache store.
  6. #
  7. # It stores cached responses in a file under a directory pointed by the +dir+
  8. # variable (defaults to the default temp directory from the OS), in a custom
  9. # format (similar but different from HTTP/1.1 request/response framing).
  10. 8 class FileStore
  11. 8 CRLF = HTTPX::Connection::HTTP1::CRLF
  12. 8 attr_reader :dir
  13. 8 def initialize(dir = Dir.tmpdir)
  14. 88 @dir = Pathname.new(dir).join("httpx-response-cache")
  15. 88 FileUtils.mkdir_p(@dir)
  16. end
  17. 8 def clear
  18. 72 FileUtils.rm_rf(@dir)
  19. end
  20. 8 def get(request)
  21. 336 path = file_path(request)
  22. 336 return unless File.exist?(path)
  23. 168 File.open(path, mode: File::RDONLY | File::BINARY) do |f|
  24. 168 f.flock(File::Constants::LOCK_SH)
  25. 168 read_from_file(request, f)
  26. end
  27. end
  28. 8 def set(request, response)
  29. 104 path = file_path(request)
  30. 104 file_exists = File.exist?(path)
  31. 104 mode = file_exists ? File::RDWR : File::CREAT | File::Constants::WRONLY
  32. 104 File.open(path, mode: mode | File::BINARY) do |f|
  33. 104 f.flock(File::Constants::LOCK_EX)
  34. 104 if file_exists
  35. 8 cached_response = read_from_file(request, f)
  36. 8 if cached_response
  37. 8 next if cached_response == request.cached_response
  38. 8 cached_response.close
  39. 8 f.truncate(0)
  40. 8 f.rewind
  41. end
  42. end
  43. # cache the request headers
  44. 104 f << request.verb << CRLF
  45. 104 f << request.uri << CRLF
  46. 104 request.headers.each do |field, value|
  47. 312 f << field << ":" << value << CRLF
  48. end
  49. 104 f << CRLF
  50. # cache the response
  51. 104 f << response.status << CRLF
  52. 104 f << response.version << CRLF
  53. 104 response.headers.each do |field, value|
  54. 296 f << field << ":" << value << CRLF
  55. end
  56. 104 f << CRLF
  57. 104 response.body.rewind
  58. 104 IO.copy_stream(response.body, f)
  59. end
  60. end
  61. 8 private
  62. 8 def file_path(request)
  63. 440 @dir.join(request.response_cache_key)
  64. end
  65. 8 def read_from_file(request, f)
  66. # if it's an empty file
  67. 176 return if f.eof?
  68. # read request data
  69. 176 verb = f.readline.delete_suffix!(CRLF)
  70. 176 uri = f.readline.delete_suffix!(CRLF)
  71. 176 request_headers = {}
  72. 704 while (line = f.readline) != CRLF
  73. 528 line.delete_suffix!(CRLF)
  74. 528 sep_index = line.index(":")
  75. 528 field = line.byteslice(0..(sep_index - 1))
  76. 528 value = line.byteslice((sep_index + 1)..-1)
  77. 528 request_headers[field] = value
  78. end
  79. 176 status = f.readline.delete_suffix!(CRLF)
  80. 176 version = f.readline.delete_suffix!(CRLF)
  81. 176 response_headers = {}
  82. 686 while (line = f.readline) != CRLF
  83. 504 line.delete_suffix!(CRLF)
  84. 504 sep_index = line.index(":")
  85. 504 field = line.byteslice(0..(sep_index - 1))
  86. 504 value = line.byteslice((sep_index + 1)..-1)
  87. 504 response_headers[field] = value
  88. end
  89. 176 original_request = request.options.request_class.new(verb, uri, request.options)
  90. 176 original_request.merge_headers(request_headers)
  91. 176 response = request.options.response_class.new(request, status, version, response_headers)
  92. 176 response.original_request = original_request
  93. 176 response.finish!
  94. 176 response.mark_as_cached!
  95. 176 IO.copy_stream(f, response.body)
  96. 176 response
  97. end
  98. end
  99. end
  100. end

lib/httpx/plugins/response_cache/store.rb

100.0% lines covered

16 relevant lines. 16 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 8 module HTTPX::Plugins
  3. 8 module ResponseCache
  4. # Implementation of a thread-safe in-memory cache store.
  5. 8 class Store
  6. 8 def initialize
  7. 320 @store = {}
  8. 320 @store_mutex = Thread::Mutex.new
  9. end
  10. 8 def clear
  11. 160 @store_mutex.synchronize { @store.clear }
  12. end
  13. 8 def get(request)
  14. 488 @store_mutex.synchronize do
  15. 488 @store[request.response_cache_key]
  16. end
  17. end
  18. 8 def set(request, response)
  19. 208 @store_mutex.synchronize do
  20. 208 cached_response = @store[request.response_cache_key]
  21. 208 cached_response.close if cached_response
  22. 208 @store[request.response_cache_key] = response
  23. end
  24. end
  25. end
  26. end
  27. end

lib/httpx/plugins/retries.rb

96.91% lines covered

97 relevant lines. 94 lines covered and 3 lines missed.
    
  1. # frozen_string_literal: true
  2. 17 module HTTPX
  3. 17 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. 17 module Retries
  15. 17 MAX_RETRIES = 3
  16. # TODO: pass max_retries in a configure/load block
  17. 17 IDEMPOTENT_METHODS = %w[GET OPTIONS HEAD PUT DELETE].freeze
  18. # subset of retryable errors which are safe to retry when reconnecting
  19. 2 RECONNECTABLE_ERRORS = [
  20. 15 IOError,
  21. EOFError,
  22. Errno::ECONNRESET,
  23. Errno::ECONNABORTED,
  24. Errno::EPIPE,
  25. Errno::EINVAL,
  26. Errno::ETIMEDOUT,
  27. ConnectionError,
  28. TLSError,
  29. Connection::HTTP2::Error,
  30. ].freeze
  31. 17 RETRYABLE_ERRORS = (RECONNECTABLE_ERRORS + [
  32. Parser::Error,
  33. TimeoutError,
  34. ]).freeze
  35. 17 DEFAULT_JITTER = ->(interval) { interval * ((rand + 1) * 0.5) }
  36. 17 if ENV.key?("HTTPX_NO_JITTER")
  37. 16 def self.extra_options(options)
  38. 790 options.merge(max_retries: MAX_RETRIES)
  39. end
  40. else
  41. 1 def self.extra_options(options)
  42. 7 options.merge(max_retries: MAX_RETRIES, retry_jitter: DEFAULT_JITTER)
  43. end
  44. end
  45. # adds support for the following options:
  46. #
  47. # :max_retries :: max number of times a request will be retried (defaults to <tt>3</tt>).
  48. # :retry_change_requests :: whether idempotent requests are retried (defaults to <tt>false</tt>).
  49. # :retry_after:: seconds after which a request is retried; can also be a callable object (i.e. <tt>->(req, res) { ... } </tt>)
  50. # :retry_jitter :: number of seconds applied to *:retry_after* (must be a callable, i.e. <tt>->(retry_after) { ... } </tt>).
  51. # :retry_on :: callable which alternatively defines a different rule for when a response is to be retried
  52. # (i.e. <tt>->(res) { ... }</tt>).
  53. 17 module OptionsMethods
  54. 17 private
  55. 17 def option_retry_after(value)
  56. # return early if callable
  57. 208 unless value.respond_to?(:call)
  58. 96 value = Float(value)
  59. 96 raise TypeError, ":retry_after must be positive" unless value.positive?
  60. end
  61. 208 value
  62. end
  63. 17 def option_retry_jitter(value)
  64. # return early if callable
  65. 62 raise TypeError, ":retry_jitter must be callable" unless value.respond_to?(:call)
  66. 62 value
  67. end
  68. 17 def option_max_retries(value)
  69. 2388 num = Integer(value)
  70. 2388 raise TypeError, ":max_retries must be positive" unless num >= 0
  71. 2388 num
  72. end
  73. 17 def option_retry_change_requests(v)
  74. 128 v
  75. end
  76. 17 def option_retry_on(value)
  77. 286 raise TypeError, ":retry_on must be called with the response" unless value.respond_to?(:call)
  78. 286 value
  79. end
  80. end
  81. 17 module InstanceMethods
  82. # returns a `:retries` plugin enabled session with +n+ maximum retries per request setting.
  83. 17 def max_retries(n)
  84. 96 with(max_retries: n)
  85. end
  86. 17 private
  87. 17 def fetch_response(request, selector, options)
  88. 5418503 response = super
  89. 5418503 if response &&
  90. request.retries.positive? &&
  91. repeatable_request?(request, options) &&
  92. (
  93. (
  94. 284 response.is_a?(ErrorResponse) && retryable_error?(response.error)
  95. ) ||
  96. (
  97. 211 options.retry_on && options.retry_on.call(response)
  98. )
  99. )
  100. 495 try_partial_retry(request, response)
  101. 495 log { "failed to get response, #{request.retries} tries to go..." }
  102. 495 request.retries -= 1 unless request.ping? # do not exhaust retries on connection liveness probes
  103. 495 request.transition(:idle)
  104. 495 retry_after = options.retry_after
  105. 495 retry_after = retry_after.call(request, response) if retry_after.respond_to?(:call)
  106. 495 if retry_after
  107. # apply jitter
  108. 96 if (jitter = request.options.retry_jitter)
  109. 16 retry_after = jitter.call(retry_after)
  110. end
  111. 96 retry_start = Utils.now
  112. 96 log { "retrying after #{retry_after} secs..." }
  113. 96 selector.after(retry_after) do
  114. 96 if (response = request.response)
  115. response.finish!
  116. # request has terminated abruptly meanwhile
  117. request.emit(:response, response)
  118. else
  119. 96 log { "retrying (elapsed time: #{Utils.elapsed_time(retry_start)})!!" }
  120. 96 send_request(request, selector, options)
  121. end
  122. end
  123. else
  124. 399 send_request(request, selector, options)
  125. end
  126. 495 return
  127. end
  128. 5418008 response
  129. end
  130. # returns whether +request+ can be retried.
  131. 17 def repeatable_request?(request, options)
  132. 1152 IDEMPOTENT_METHODS.include?(request.verb) || options.retry_change_requests
  133. end
  134. # returns whether the +ex+ exception happend for a retriable request.
  135. 17 def retryable_error?(ex)
  136. 3679 RETRYABLE_ERRORS.any? { |klass| ex.is_a?(klass) }
  137. end
  138. 17 def proxy_error?(request, response, _)
  139. 64 super && !request.retries.positive?
  140. end
  141. #
  142. # Attempt to set the request to perform a partial range request.
  143. # This happens if the peer server accepts byte-range requests, and
  144. # the last response contains some body payload.
  145. #
  146. 17 def try_partial_retry(request, response)
  147. 495 response = response.response if response.is_a?(ErrorResponse)
  148. 495 return unless response
  149. 234 unless response.headers.key?("accept-ranges") &&
  150. response.headers["accept-ranges"] == "bytes" && # there's nothing else supported though...
  151. 16 (original_body = response.body)
  152. 218 response.body.close
  153. 218 return
  154. end
  155. 16 request.partial_response = response
  156. 16 size = original_body.bytesize
  157. 16 request.headers["range"] = "bytes=#{size}-"
  158. end
  159. end
  160. 17 module RequestMethods
  161. # number of retries left.
  162. 17 attr_accessor :retries
  163. # a response partially received before.
  164. 17 attr_writer :partial_response
  165. # initializes the request instance, sets the number of retries for the request.
  166. 17 def initialize(*args)
  167. 875 super
  168. 875 @retries = @options.max_retries
  169. end
  170. 17 def response=(response)
  171. 1386 if @partial_response
  172. 16 if response.is_a?(Response) && response.status == 206
  173. 16 response.from_partial_response(@partial_response)
  174. else
  175. @partial_response.close
  176. end
  177. 16 @partial_response = nil
  178. end
  179. 1386 super
  180. end
  181. end
  182. 17 module ResponseMethods
  183. 17 def from_partial_response(response)
  184. 16 @status = response.status
  185. 16 @headers = response.headers
  186. 16 @body = response.body
  187. end
  188. end
  189. end
  190. 17 register_plugin :retries, Retries
  191. end
  192. 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. 8 module HTTPX
  3. 8 class ServerSideRequestForgeryError < Error; end
  4. 8 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. 8 module SsrfFilter
  11. 8 module IPAddrExtensions
  12. 8 refine IPAddr do
  13. 8 def prefixlen
  14. 128 mask_addr = @mask_addr
  15. 128 raise "Invalid mask" if mask_addr.zero?
  16. 128 mask_addr >>= 1 while (mask_addr & 0x1).zero?
  17. 128 length = 0
  18. 128 while mask_addr & 0x1 == 0x1
  19. 2024 length += 1
  20. 2024 mask_addr >>= 1
  21. end
  22. 128 length
  23. end
  24. end
  25. end
  26. 8 using IPAddrExtensions
  27. # https://en.wikipedia.org/wiki/Reserved_IP_addresses
  28. 2 IPV4_BLACKLIST = [
  29. 6 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. 4 IPV6_BLACKLIST = ([
  47. 6 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. 128 prefixlen = ipaddr.prefixlen
  60. 128 ipv4_compatible = ipaddr.ipv4_compat.mask(96 + prefixlen)
  61. 128 ipv4_mapped = ipaddr.ipv4_mapped.mask(80 + prefixlen)
  62. 128 [ipv4_compatible, ipv4_mapped]
  63. end).freeze
  64. 8 class << self
  65. 8 def extra_options(options)
  66. 68 options.merge(allowed_schemes: %w[https http])
  67. end
  68. 8 def unsafe_ip_address?(ipaddr)
  69. 76 range = ipaddr.to_range
  70. 76 return true if range.first != range.last
  71. 92 return IPV6_BLACKLIST.any? { |r| r.include?(ipaddr) } if ipaddr.ipv6?
  72. 640 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. 8 module OptionsMethods
  79. 8 private
  80. 8 def option_allowed_schemes(value)
  81. 76 Array(value)
  82. end
  83. end
  84. 8 module InstanceMethods
  85. 8 def send_requests(*requests)
  86. 84 responses = requests.map do |request|
  87. 84 next if @options.allowed_schemes.include?(request.uri.scheme)
  88. 8 error = ServerSideRequestForgeryError.new("#{request.uri} URI scheme not allowed")
  89. 8 error.set_backtrace(caller)
  90. 8 response = ErrorResponse.new(request, error)
  91. 8 request.emit(:response, response)
  92. 8 response
  93. end
  94. 168 allowed_requests = requests.select { |req| responses[requests.index(req)].nil? }
  95. 84 allowed_responses = super(*allowed_requests)
  96. 84 allowed_responses.each_with_index do |res, idx|
  97. 76 req = allowed_requests[idx]
  98. 76 responses[requests.index(req)] = res
  99. end
  100. 84 responses
  101. end
  102. end
  103. 8 module ConnectionMethods
  104. 8 def initialize(*)
  105. begin
  106. 76 super
  107. 8 rescue ServerSideRequestForgeryError => e
  108. # may raise when IPs are passed as options via :addresses
  109. 16 throw(:resolve_error, e)
  110. end
  111. end
  112. 8 def addresses=(addrs)
  113. 76 addrs.reject!(&SsrfFilter.method(:unsafe_ip_address?))
  114. 76 raise ServerSideRequestForgeryError, "#{@origin.host} has no public IP addresses" if addrs.empty?
  115. 16 super
  116. end
  117. end
  118. end
  119. 8 register_plugin :ssrf_filter, SsrfFilter
  120. end
  121. end

lib/httpx/plugins/stream.rb

97.78% lines covered

90 relevant lines. 88 lines covered and 2 lines missed.
    
  1. # frozen_string_literal: true
  2. 14 module HTTPX
  3. 14 class StreamResponse
  4. 14 attr_reader :request
  5. 14 def initialize(request, session)
  6. 186 @request = request
  7. 186 @options = @request.options
  8. 186 @session = session
  9. 186 @response_enum = nil
  10. 186 @buffered_chunks = []
  11. end
  12. 14 def each(&block)
  13. 314 return enum_for(__method__) unless block
  14. 202 if (response_enum = @response_enum)
  15. 16 @response_enum = nil
  16. # streaming already started, let's finish it
  17. 40 while (chunk = @buffered_chunks.shift)
  18. 16 block.call(chunk)
  19. end
  20. # consume enum til the end
  21. 2 begin
  22. 48 while (chunk = response_enum.next)
  23. 24 block.call(chunk)
  24. end
  25. rescue StopIteration
  26. 16 return
  27. end
  28. end
  29. 186 @request.stream = self
  30. 34 begin
  31. 186 @on_chunk = block
  32. 186 response = @session.request(@request)
  33. 170 response.raise_for_status
  34. ensure
  35. 170 @on_chunk = nil
  36. end
  37. end
  38. 14 def each_line
  39. 108 return enum_for(__method__) unless block_given?
  40. 54 line = "".b
  41. 54 each do |chunk|
  42. 50 line << chunk
  43. 132 while (idx = line.index("\n"))
  44. 54 yield line.byteslice(0..idx - 1)
  45. 54 line = line.byteslice(idx + 1..-1)
  46. end
  47. end
  48. 22 yield line unless line.empty?
  49. end
  50. # This is a ghost method. It's to be used ONLY internally, when processing streams
  51. 14 def on_chunk(chunk)
  52. 348 raise NoMethodError unless @on_chunk
  53. 348 @on_chunk.call(chunk)
  54. end
  55. skipped # :nocov:
  56. skipped def inspect
  57. skipped "#<#{self.class}:#{object_id}>"
  58. skipped end
  59. skipped # :nocov:
  60. 14 def to_s
  61. 16 if @request.response
  62. @request.response.to_s
  63. else
  64. 16 @buffered_chunks.join
  65. end
  66. end
  67. 14 private
  68. 14 def response
  69. 356 @request.response || begin
  70. 32 response_enum = each
  71. 56 while (chunk = response_enum.next)
  72. 32 @buffered_chunks << chunk
  73. 32 break if @request.response
  74. end
  75. 32 @response_enum = response_enum
  76. 32 @request.response
  77. end
  78. end
  79. 14 def respond_to_missing?(meth, include_private)
  80. 24 if (response = @request.response)
  81. response.respond_to_missing?(meth, include_private)
  82. else
  83. 24 @options.response_class.method_defined?(meth) || (include_private && @options.response_class.private_method_defined?(meth))
  84. end || super
  85. end
  86. 14 def method_missing(meth, *args, **kwargs, &block)
  87. 178 return super unless response.respond_to?(meth)
  88. 178 response.__send__(meth, *args, **kwargs, &block)
  89. end
  90. end
  91. 14 module Plugins
  92. #
  93. # This plugin adds support for streaming a response (useful for i.e. "text/event-stream" payloads).
  94. #
  95. # https://gitlab.com/os85/httpx/wikis/Stream
  96. #
  97. 14 module Stream
  98. 14 def self.extra_options(options)
  99. 316 options.merge(timeout: { read_timeout: Float::INFINITY, operation_timeout: 60 })
  100. end
  101. 14 module InstanceMethods
  102. 14 def request(*args, stream: false, **options)
  103. 506 return super(*args, **options) unless stream
  104. 202 requests = args.first.is_a?(Request) ? args : build_requests(*args, options)
  105. 202 raise Error, "only 1 response at a time is supported for streaming requests" unless requests.size == 1
  106. 186 request = requests.first
  107. 186 StreamResponse.new(request, self)
  108. end
  109. end
  110. 14 module RequestMethods
  111. 14 attr_accessor :stream
  112. end
  113. 14 module ResponseMethods
  114. 14 def stream
  115. 298 request = @request.root_request if @request.respond_to?(:root_request)
  116. 298 request ||= @request
  117. 298 request.stream
  118. end
  119. end
  120. 14 module ResponseBodyMethods
  121. 14 def initialize(*)
  122. 298 super
  123. 298 @stream = @response.stream
  124. end
  125. 14 def write(chunk)
  126. 483 return super unless @stream
  127. 398 return 0 if chunk.empty?
  128. 348 chunk = decode_chunk(chunk)
  129. 348 @stream.on_chunk(chunk.dup)
  130. 332 chunk.size
  131. end
  132. 14 private
  133. 14 def transition(*)
  134. 109 return if @stream
  135. 109 super
  136. end
  137. end
  138. end
  139. 14 register_plugin :stream, Stream
  140. end
  141. end

lib/httpx/plugins/stream_bidi.rb

97.9% lines covered

143 relevant lines. 140 lines covered and 3 lines missed.
    
  1. # frozen_string_literal: true
  2. 8 module HTTPX
  3. 8 module Plugins
  4. #
  5. # This plugin adds support for bidirectional HTTP/2 streams.
  6. #
  7. # https://gitlab.com/os85/httpx/wikis/StreamBidi
  8. #
  9. # It is required that the request body allows chunk to be buffered, (i.e., responds to +#<<(chunk)+).
  10. 8 module StreamBidi
  11. # Extension of the Connection::HTTP2 class, which adds functionality to
  12. # deal with a request that can't be drained and must be interleaved with
  13. # the response streams.
  14. #
  15. # The streams keeps send DATA frames while there's data; when they're ain't,
  16. # the stream is kept open; it must be explicitly closed by the end user.
  17. #
  18. 8 module HTTP2Methods
  19. 8 def initialize(*)
  20. 16 super
  21. 16 @lock = Thread::Mutex.new
  22. end
  23. 8 %i[close empty? exhausted? send <<].each do |lock_meth|
  24. 40 class_eval(<<-METH, __FILE__, __LINE__ + 1)
  25. # lock.aware version of +#{lock_meth}+
  26. def #{lock_meth}(*) # def close(*)
  27. return super if @lock.owned?
  28. # small race condition between
  29. # checking for ownership and
  30. # acquiring lock.
  31. # TODO: fix this at the parser.
  32. @lock.synchronize { super }
  33. end
  34. METH
  35. end
  36. 8 private
  37. 8 %i[join_headers join_trailers join_body].each do |lock_meth|
  38. 24 class_eval(<<-METH, __FILE__, __LINE__ + 1)
  39. # lock.aware version of +#{lock_meth}+
  40. private def #{lock_meth}(*) # private def join_headers(*)
  41. return super if @lock.owned?
  42. # small race condition between
  43. # checking for ownership and
  44. # acquiring lock.
  45. # TODO: fix this at the parser.
  46. @lock.synchronize { super }
  47. end
  48. METH
  49. end
  50. 8 def handle_stream(stream, request)
  51. 16 request.on(:body) do
  52. 96 next unless request.headers_sent
  53. 80 handle(request, stream)
  54. 80 emit(:flush_buffer)
  55. end
  56. 16 super
  57. end
  58. # when there ain't more chunks, it makes the buffer as full.
  59. 8 def send_chunk(request, stream, chunk, next_chunk)
  60. 96 super
  61. 96 return if next_chunk
  62. 96 request.transition(:waiting_for_chunk)
  63. 96 throw(:buffer_full)
  64. end
  65. # sets end-stream flag when the request is closed.
  66. 8 def end_stream?(request, next_chunk)
  67. 96 request.closed? && next_chunk.nil?
  68. end
  69. end
  70. # BidiBuffer is a Buffer which can be receive data from threads othr
  71. # than the thread of the corresponding Connection/Session.
  72. #
  73. # It synchronizes access to a secondary internal +@oob_buffer+, which periodically
  74. # is reconciled to the main internal +@buffer+.
  75. 8 class BidiBuffer < Buffer
  76. 8 def initialize(*)
  77. 16 super
  78. 16 @parent_thread = Thread.current
  79. 16 @oob_mutex = Thread::Mutex.new
  80. 16 @oob_buffer = "".b
  81. end
  82. # buffers the +chunk+ to be sent
  83. 8 def <<(chunk)
  84. 176 return super if Thread.current == @parent_thread
  85. 80 @oob_mutex.synchronize { @oob_buffer << chunk }
  86. end
  87. # reconciles the main and secondary buffer (which receives data from other threads).
  88. 8 def rebuffer
  89. 1009 raise Error, "can only rebuffer while waiting on a response" unless Thread.current == @parent_thread
  90. 1009 @oob_mutex.synchronize do
  91. 1009 @buffer << @oob_buffer
  92. 1009 @oob_buffer.clear
  93. end
  94. end
  95. end
  96. # Proxy to wake up the session main loop when one
  97. # of the connections has buffered data to write. It abides by the HTTPX::_Selectable API,
  98. # which allows it to be registered in the selector alongside actual HTTP-based
  99. # HTTPX::Connection objects.
  100. 8 class Signal
  101. 8 attr_reader :error
  102. 8 def initialize
  103. 16 @closed = false
  104. 16 @error = nil
  105. 16 @pipe_read, @pipe_write = IO.pipe
  106. end
  107. 8 def state
  108. 241 @closed ? :closed : :open
  109. end
  110. # noop
  111. 8 def log(**, &_); end
  112. 8 def to_io
  113. 482 @pipe_read.to_io
  114. end
  115. 8 def wakeup
  116. 80 return if @closed
  117. 80 @pipe_write.write("\0")
  118. end
  119. 8 def call
  120. 77 return if @closed
  121. 77 @pipe_read.readpartial(1)
  122. end
  123. 8 def interests
  124. 241 return if @closed
  125. 241 :r
  126. end
  127. 8 def timeout; end
  128. 8 def terminate
  129. 16 @pipe_write.close
  130. 16 @pipe_read.close
  131. 16 @closed = true
  132. end
  133. 8 def on_error(error)
  134. @error = error
  135. terminate
  136. end
  137. # noop (the owner connection will take of it)
  138. 8 def handle_socket_timeout(interval); end
  139. end
  140. 8 class << self
  141. 8 def load_dependencies(klass)
  142. 16 klass.plugin(:stream)
  143. end
  144. 8 def extra_options(options)
  145. 16 options.merge(fallback_protocol: "h2")
  146. end
  147. end
  148. 8 module InstanceMethods
  149. 8 def initialize(*)
  150. 16 @signal = Signal.new
  151. 16 super
  152. end
  153. 8 def close(selector = Selector.new)
  154. 16 @signal.terminate
  155. 16 selector.deregister(@signal)
  156. 16 super(selector)
  157. end
  158. 8 def select_connection(connection, selector)
  159. 32 super
  160. 32 selector.register(@signal)
  161. 32 connection.signal = @signal
  162. end
  163. 8 def deselect_connection(connection, *)
  164. 16 super
  165. 16 connection.signal = nil
  166. end
  167. end
  168. # Adds synchronization to request operations which may buffer payloads from different
  169. # threads.
  170. 8 module RequestMethods
  171. 8 attr_accessor :headers_sent
  172. 8 def initialize(*)
  173. 16 super
  174. 16 @headers_sent = false
  175. 16 @closed = false
  176. 16 @mutex = Thread::Mutex.new
  177. end
  178. 8 def closed?
  179. 96 @closed
  180. end
  181. 8 def can_buffer?
  182. 215 super && @state != :waiting_for_chunk
  183. end
  184. # overrides state management transitions to introduce an intermediate
  185. # +:waiting_for_chunk+ state, which the request transitions to once payload
  186. # is buffered.
  187. 8 def transition(nextstate)
  188. 368 headers_sent = @headers_sent
  189. 368 case nextstate
  190. when :waiting_for_chunk
  191. 96 return unless @state == :body
  192. when :body
  193. 176 case @state
  194. when :headers
  195. 16 headers_sent = true
  196. when :waiting_for_chunk
  197. # HACK: to allow super to pass through
  198. 80 @state = :headers
  199. end
  200. end
  201. 368 super.tap do
  202. # delay setting this up until after the first transition to :body
  203. 368 @headers_sent = headers_sent
  204. end
  205. end
  206. 8 def <<(chunk)
  207. 80 @mutex.synchronize do
  208. 80 if @drainer
  209. 80 @body.clear if @body.respond_to?(:clear)
  210. 80 @drainer = nil
  211. end
  212. 80 @body << chunk
  213. 80 transition(:body)
  214. end
  215. end
  216. 8 def close
  217. 16 @mutex.synchronize do
  218. 16 return if @closed
  219. 16 @closed = true
  220. end
  221. # last chunk to send which ends the stream
  222. 16 self << ""
  223. end
  224. end
  225. 8 module RequestBodyMethods
  226. 8 def initialize(*, **)
  227. 16 super
  228. 16 @headers.delete("content-length")
  229. end
  230. 8 def empty?
  231. 112 false
  232. end
  233. end
  234. # overrides the declaration of +@write_buffer+, which is now a thread-safe buffer
  235. # responding to the same API.
  236. 8 module ConnectionMethods
  237. 8 attr_writer :signal
  238. 8 def initialize(*)
  239. 16 super
  240. 16 @write_buffer = BidiBuffer.new(@options.buffer_size)
  241. end
  242. # rebuffers the +@write_buffer+ before calculating interests.
  243. 8 def interests
  244. 1009 @write_buffer.rebuffer
  245. 1009 super
  246. end
  247. 8 def call
  248. 173 return super unless (error = @signal.error)
  249. on_error(error)
  250. end
  251. 8 private
  252. 8 def set_parser_callbacks(parser)
  253. 16 super
  254. 16 parser.on(:flush_buffer) do
  255. 80 @signal.wakeup if @signal
  256. end
  257. end
  258. end
  259. end
  260. 8 register_plugin :stream_bidi, StreamBidi
  261. end
  262. end

lib/httpx/plugins/upgrade.rb

100.0% lines covered

38 relevant lines. 38 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 8 module HTTPX
  3. 8 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. 8 module Upgrade
  11. 8 class << self
  12. 8 def configure(klass)
  13. 30 klass.plugin(:"upgrade/h2")
  14. end
  15. 8 def extra_options(options)
  16. 30 options.merge(upgrade_handlers: {})
  17. end
  18. end
  19. 8 module OptionsMethods
  20. 8 private
  21. 8 def option_upgrade_handlers(value)
  22. 100 raise TypeError, ":upgrade_handlers must be a Hash" unless value.is_a?(Hash)
  23. 100 value
  24. end
  25. end
  26. 8 module InstanceMethods
  27. 8 def fetch_response(request, selector, options)
  28. 251 response = super
  29. 251 if response
  30. 93 return response unless response.is_a?(Response)
  31. 93 return response unless response.headers.key?("upgrade")
  32. 39 upgrade_protocol = response.headers["upgrade"].split(/ *, */).first
  33. 39 return response unless upgrade_protocol && options.upgrade_handlers.key?(upgrade_protocol)
  34. 39 protocol_handler = options.upgrade_handlers[upgrade_protocol]
  35. 39 return response unless protocol_handler
  36. 39 log { "upgrading to #{upgrade_protocol}..." }
  37. 39 connection = find_connection(request.uri, selector, options)
  38. # do not upgrade already upgraded connections
  39. 39 return if connection.upgrade_protocol == upgrade_protocol
  40. 30 protocol_handler.call(connection, request, response)
  41. # keep in the loop if the server is switching, unless
  42. # the connection has been hijacked, in which case you want
  43. # to terminante immediately
  44. 30 return if response.status == 101 && !connection.hijacked
  45. end
  46. 172 response
  47. end
  48. end
  49. 8 module ConnectionMethods
  50. 8 attr_reader :upgrade_protocol, :hijacked
  51. 8 def initialize(*)
  52. 38 super
  53. 38 @upgrade_protocol = nil
  54. end
  55. 8 def hijack_io
  56. 8 @hijacked = true
  57. # connection is taken away from selector and not given back to the pool.
  58. 8 @current_session.deselect_connection(self, @current_selector, true)
  59. end
  60. end
  61. end
  62. 8 register_plugin(:upgrade, Upgrade)
  63. end
  64. end

lib/httpx/plugins/upgrade/h2.rb

89.66% lines covered

29 relevant lines. 26 lines covered and 3 lines missed.
    
  1. # frozen_string_literal: true
  2. 8 module HTTPX
  3. 8 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. 8 module H2
  11. 8 class << self
  12. 8 def extra_options(options)
  13. 30 options.merge(upgrade_handlers: options.upgrade_handlers.merge("h2" => self))
  14. end
  15. 8 def call(connection, _request, _response)
  16. 6 connection.upgrade_to_h2
  17. end
  18. end
  19. 8 module ConnectionMethods
  20. 8 using URIExtensions
  21. 8 def interests
  22. 788 return super unless connecting? && @parser
  23. 12 connect
  24. 12 return @io.interests if connecting?
  25. super
  26. end
  27. 8 def upgrade_to_h2
  28. 6 prev_parser = @parser
  29. 6 if prev_parser
  30. 6 prev_parser.reset
  31. 6 @inflight -= prev_parser.requests.size
  32. end
  33. 6 @parser = @options.http2_class.new(@write_buffer, @options)
  34. 6 set_parser_callbacks(@parser)
  35. 6 @upgrade_protocol = "h2"
  36. # what's happening here:
  37. # a deviation from the state machine is done to perform the actions when a
  38. # connection is closed, without transitioning, so the connection is kept in the pool.
  39. # the state is reset to initial, so that the socket reconnect works out of the box,
  40. # while the parser is already here.
  41. 6 purge_after_closed
  42. 6 transition(:idle)
  43. 6 prev_parser.requests.each do |req|
  44. req.transition(:idle)
  45. send(req)
  46. end
  47. end
  48. end
  49. end
  50. 8 register_plugin(:"upgrade/h2", H2)
  51. end
  52. 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. 8 module HTTPX
  3. 8 module Plugins
  4. #
  5. # This plugin implements convenience methods for performing WEBDAV requests.
  6. #
  7. # https://gitlab.com/os85/httpx/wikis/WebDav
  8. #
  9. 8 module WebDav
  10. 8 def self.configure(klass)
  11. 96 klass.plugin(:xml)
  12. end
  13. 8 module InstanceMethods
  14. 8 def copy(src, dest)
  15. 16 request("COPY", src, headers: { "destination" => @options.origin.merge(dest) })
  16. end
  17. 8 def move(src, dest)
  18. 16 request("MOVE", src, headers: { "destination" => @options.origin.merge(dest) })
  19. end
  20. 8 def lock(path, timeout: nil, &blk)
  21. 48 headers = {}
  22. 48 headers["timeout"] = if timeout && timeout.positive?
  23. 16 "Second-#{timeout}"
  24. else
  25. 32 "Infinite, Second-4100000000"
  26. end
  27. 48 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. 48 response = request("LOCK", path, headers: headers, xml: xml)
  34. 48 return response unless response.is_a?(Response)
  35. 48 return response unless blk && response.status == 200
  36. 16 lock_token = response.headers["lock-token"]
  37. 2 begin
  38. 16 blk.call(response)
  39. ensure
  40. 16 unlock(path, lock_token)
  41. end
  42. 16 response
  43. end
  44. 8 def unlock(path, lock_token)
  45. 32 request("UNLOCK", path, headers: { "lock-token" => lock_token })
  46. end
  47. 8 def mkcol(dir)
  48. 16 request("MKCOL", dir)
  49. end
  50. 8 def propfind(path, xml = nil)
  51. 64 body = case xml
  52. when :acl
  53. 16 '<?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. 32 '<?xml version="1.0" encoding="utf-8"?><DAV:propfind xmlns:DAV="DAV:"><DAV:allprop/></DAV:propfind>'
  57. else
  58. 16 xml
  59. end
  60. 64 request("PROPFIND", path, headers: { "depth" => "1" }, xml: body)
  61. end
  62. 8 def proppatch(path, xml)
  63. 6 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. 16 request("PROPPATCH", path, xml: body)
  66. end
  67. # %i[ orderpatch acl report search]
  68. end
  69. end
  70. 8 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. 8 module HTTPX
  3. 8 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. 8 module XML
  10. 8 MIME_TYPES = %r{\b(application|text)/(.+\+)?xml\b}.freeze
  11. 8 module Transcoder
  12. 8 module_function
  13. 8 class Encoder
  14. 8 def initialize(xml)
  15. 160 @raw = xml
  16. end
  17. 8 def content_type
  18. 160 charset = @raw.respond_to?(:encoding) && @raw.encoding ? @raw.encoding.to_s.downcase : "utf-8"
  19. 160 "application/xml; charset=#{charset}"
  20. end
  21. 8 def bytesize
  22. 512 @raw.to_s.bytesize
  23. end
  24. 8 def to_s
  25. 160 @raw.to_s
  26. end
  27. end
  28. 8 def encode(xml)
  29. 160 Encoder.new(xml)
  30. end
  31. 8 def decode(response)
  32. 24 content_type = response.content_type.mime_type
  33. 24 raise HTTPX::Error, "invalid form mime type (#{content_type})" unless MIME_TYPES.match?(content_type)
  34. 24 Nokogiri::XML.method(:parse)
  35. end
  36. end
  37. 8 class << self
  38. 8 def load_dependencies(*)
  39. 144 require "nokogiri"
  40. end
  41. end
  42. 8 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. 8 def xml
  46. 16 decode(Transcoder)
  47. end
  48. end
  49. 8 module RequestBodyClassMethods
  50. # ..., xml: Nokogiri::XML::Node #=> xml encoder
  51. 8 def initialize_body(params)
  52. 592 if (xml = params.delete(:xml))
  53. # @type var xml: Nokogiri::XML::Node | String
  54. 160 return Transcoder.encode(xml)
  55. end
  56. 432 super
  57. end
  58. end
  59. end
  60. 8 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. 27 module HTTPX
  3. 27 module ResponsePatternMatchExtensions
  4. 27 def deconstruct
  5. 35 [@status, @headers, @body]
  6. end
  7. 27 def deconstruct_keys(_keys)
  8. 70 { status: @status, headers: @headers, body: @body }
  9. end
  10. end
  11. 27 module ErrorResponsePatternMatchExtensions
  12. 27 def deconstruct
  13. 7 [@error]
  14. end
  15. 27 def deconstruct_keys(_keys)
  16. 35 { error: @error }
  17. end
  18. end
  19. 27 module HeadersPatternMatchExtensions
  20. 27 def deconstruct
  21. 7 to_a
  22. end
  23. end
  24. 27 Headers.include HeadersPatternMatchExtensions
  25. 27 Response.include ResponsePatternMatchExtensions
  26. 27 ErrorResponse.include ErrorResponsePatternMatchExtensions
  27. end

lib/httpx/pool.rb

100.0% lines covered

103 relevant lines. 103 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 27 require "httpx/selector"
  3. 27 require "httpx/connection"
  4. 27 require "httpx/connection/http2"
  5. 27 require "httpx/connection/http1"
  6. 27 require "httpx/resolver"
  7. 27 module HTTPX
  8. 27 class Pool
  9. 27 using URIExtensions
  10. 27 POOL_TIMEOUT = 5
  11. # Sets up the connection pool with the given +options+, which can be the following:
  12. #
  13. # :max_connections:: the maximum number of connections held in the pool.
  14. # :max_connections_per_origin :: the maximum number of connections held in the pool pointing to a given origin.
  15. # :pool_timeout :: the number of seconds to wait for a connection to a given origin (before raising HTTPX::PoolTimeoutError)
  16. #
  17. 27 def initialize(options)
  18. 11835 @max_connections = options.fetch(:max_connections, Float::INFINITY)
  19. 11835 @max_connections_per_origin = options.fetch(:max_connections_per_origin, Float::INFINITY)
  20. 11835 @pool_timeout = options.fetch(:pool_timeout, POOL_TIMEOUT)
  21. 19181 @resolvers = Hash.new { |hs, resolver_type| hs[resolver_type] = [] }
  22. 11835 @resolver_mtx = Thread::Mutex.new
  23. 11835 @connections = []
  24. 11835 @connection_mtx = Thread::Mutex.new
  25. 11835 @connections_counter = 0
  26. 11835 @max_connections_cond = ConditionVariable.new
  27. 11835 @origin_counters = Hash.new(0)
  28. 18425 @origin_conds = Hash.new { |hs, orig| hs[orig] = ConditionVariable.new }
  29. end
  30. # connections returned by this function are not expected to return to the connection pool.
  31. 27 def pop_connection
  32. 12489 @connection_mtx.synchronize do
  33. 12489 drop_connection
  34. end
  35. end
  36. # opens a connection to the IP reachable through +uri+.
  37. # Many hostnames are reachable through the same IP, so we try to
  38. # maximize pipelining by opening as few connections as possible.
  39. #
  40. 27 def checkout_connection(uri, options)
  41. 8490 return checkout_new_connection(uri, options) if options.io
  42. 8426 @connection_mtx.synchronize do
  43. 8426 acquire_connection(uri, options) || begin
  44. 7758 if @connections_counter == @max_connections
  45. # this takes precedence over per-origin
  46. 16 expires_at = Utils.now + @pool_timeout
  47. 16 loop do
  48. 17 @max_connections_cond.wait(@connection_mtx, @pool_timeout)
  49. 17 if (conn = acquire_connection(uri, options))
  50. 3 return conn
  51. end
  52. # if one can afford to create a new connection, do it
  53. 14 break unless @connections_counter == @max_connections
  54. # if no matching usable connection was found, the pool will make room and drop a closed connection.
  55. 16 if (conn = @connections.find { |c| c.state == :closed })
  56. 3 drop_connection(conn)
  57. 3 break
  58. end
  59. # happens when a condition was signalled, but another thread snatched the available connection before
  60. # context was passed back here.
  61. 9 next if Utils.now < expires_at
  62. 8 raise PoolTimeoutError.new(@pool_timeout,
  63. "Timed out after #{@pool_timeout} seconds while waiting for a connection")
  64. end
  65. end
  66. 7747 if @origin_counters[uri.origin] == @max_connections_per_origin
  67. 16 expires_at = Utils.now + @pool_timeout
  68. 16 loop do
  69. 17 @origin_conds[uri.origin].wait(@connection_mtx, @pool_timeout)
  70. 17 if (conn = acquire_connection(uri, options))
  71. 8 return conn
  72. end
  73. # happens when a condition was signalled, but another thread snatched the available connection before
  74. # context was passed back here.
  75. 9 next if Utils.now < expires_at
  76. 8 raise(PoolTimeoutError.new(@pool_timeout,
  77. "Timed out after #{@pool_timeout} seconds while waiting for a connection to #{uri.origin}"))
  78. end
  79. end
  80. 7731 @connections_counter += 1
  81. 7731 @origin_counters[uri.origin] += 1
  82. 7731 checkout_new_connection(uri, options)
  83. end
  84. end
  85. end
  86. 27 def checkin_connection(connection)
  87. 8647 return if connection.options.io
  88. 8583 @connection_mtx.synchronize do
  89. 8583 if connection.coalesced? || connection.state == :idle
  90. # when connections coalesce
  91. 49 drop_connection(connection)
  92. 49 return
  93. end
  94. 8534 @connections << connection
  95. 8534 @max_connections_cond.signal
  96. 8534 @origin_conds[connection.origin.to_s].signal
  97. end
  98. end
  99. 27 def checkout_mergeable_connection(connection)
  100. 7686 return if connection.options.io
  101. 7686 @connection_mtx.synchronize do
  102. 7686 idx = @connections.find_index do |ch|
  103. 278 ch != connection && ch.mergeable?(connection)
  104. end
  105. 7686 @connections.delete_at(idx) if idx
  106. end
  107. end
  108. 27 def reset_resolvers
  109. 15052 @resolver_mtx.synchronize { @resolvers.clear }
  110. end
  111. 27 def checkout_resolver(options)
  112. 7574 resolver_type = options.resolver_class
  113. 7574 resolver_type = Resolver.resolver_for(resolver_type, options)
  114. 7574 @resolver_mtx.synchronize do
  115. 7574 resolvers = @resolvers[resolver_type]
  116. 7574 idx = resolvers.find_index do |res|
  117. 28 res.options == options
  118. end
  119. 7574 resolvers.delete_at(idx) if idx
  120. end || checkout_new_resolver(resolver_type, options)
  121. end
  122. 27 def checkin_resolver(resolver)
  123. 533 resolver_class = resolver.class
  124. 533 resolver = resolver.multi
  125. # a multi requires all sub-resolvers being closed in order to be
  126. # correctly checked back in.
  127. 533 return unless resolver.closed?
  128. 513 @resolver_mtx.synchronize do
  129. 513 resolvers = @resolvers[resolver_class]
  130. 513 resolvers << resolver unless resolvers.include?(resolver)
  131. end
  132. end
  133. skipped # :nocov:
  134. skipped def inspect
  135. skipped "#<#{self.class}:#{object_id} " \
  136. skipped "@max_connections=#{@max_connections} " \
  137. skipped "@max_connections_per_origin=#{@max_connections_per_origin} " \
  138. skipped "@pool_timeout=#{@pool_timeout} " \
  139. skipped "@connections=#{@connections.size}>"
  140. skipped end
  141. skipped # :nocov:
  142. 27 private
  143. 27 def acquire_connection(uri, options)
  144. 8460 idx = @connections.find_index do |connection|
  145. 934 connection.match?(uri, options)
  146. end
  147. 8460 return unless idx
  148. 679 @connections.delete_at(idx)
  149. end
  150. 27 def checkout_new_connection(uri, options)
  151. 7803 options.connection_class.new(uri, options)
  152. end
  153. 27 def checkout_new_resolver(resolver_type, options)
  154. 7549 if resolver_type.multi?
  155. 7396 Resolver::Multi.new(resolver_type, options)
  156. else
  157. 153 resolver_type.new(options)
  158. end
  159. end
  160. # drops and returns the +connection+ from the connection pool; if +connection+ is <tt>nil</tt> (default),
  161. # the first available connection from the pool will be dropped.
  162. 27 def drop_connection(connection = nil)
  163. 12541 if connection
  164. 52 @connections.delete(connection)
  165. else
  166. 12489 connection = @connections.shift
  167. 12489 return unless connection
  168. end
  169. 5015 @connections_counter -= 1
  170. 5015 @origin_conds.delete(connection.origin) if (@origin_counters[connection.origin.to_s] -= 1).zero?
  171. 5015 connection
  172. end
  173. end
  174. 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. 27 module HTTPX
  3. 27 module Punycode
  4. 27 module_function
  5. begin
  6. 27 require "idnx"
  7. 26 def encode_hostname(hostname)
  8. 32 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

133 relevant lines. 133 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 27 require "delegate"
  3. 27 require "forwardable"
  4. 27 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. 27 class Request
  8. 27 extend Forwardable
  9. 27 include Loggable
  10. 27 include Callbacks
  11. 27 using URIExtensions
  12. 27 ALLOWED_URI_SCHEMES = %w[https http].freeze
  13. # the upcased string HTTP verb for this request.
  14. 27 attr_reader :verb
  15. # the absolute URI object for this request.
  16. 27 attr_reader :uri
  17. # an HTTPX::Headers object containing the request HTTP headers.
  18. 27 attr_reader :headers
  19. # an HTTPX::Request::Body object containing the request body payload (or +nil+, whenn there is none).
  20. 27 attr_reader :body
  21. # a symbol describing which frame is currently being flushed.
  22. 27 attr_reader :state
  23. # an HTTPX::Options object containing request options.
  24. 27 attr_reader :options
  25. # the corresponding HTTPX::Response object, when there is one.
  26. 27 attr_reader :response
  27. # Exception raised during enumerable body writes.
  28. 27 attr_reader :drain_error
  29. # The IP address from the peer server.
  30. 27 attr_accessor :peer_address
  31. 27 attr_writer :persistent
  32. 27 attr_reader :active_timeouts
  33. # will be +true+ when request body has been completely flushed.
  34. 27 def_delegator :@body, :empty?
  35. # closes the body
  36. 27 def_delegator :@body, :close
  37. # initializes the instance with the given +verb+ (an upppercase String, ex. 'GEt'),
  38. # an absolute or relative +uri+ (either as String or URI::HTTP object), the
  39. # request +options+ (instance of HTTPX::Options) and an optional Hash of +params+.
  40. #
  41. # Besides any of the options documented in HTTPX::Options (which would override or merge with what
  42. # +options+ sets), it accepts also the following:
  43. #
  44. # :params :: hash or array of key-values which will be encoded and set in the query string of request uris.
  45. # :body :: to be encoded in the request body payload. can be a String, an IO object (i.e. a File), or an Enumerable.
  46. # :form :: hash of array of key-values which will be form-urlencoded- or multipart-encoded in requests body payload.
  47. # :json :: hash of array of key-values which will be JSON-encoded in requests body payload.
  48. # :xml :: Nokogiri XML nodes which will be encoded in requests body payload.
  49. #
  50. # :body, :form, :json and :xml are all mutually exclusive, i.e. only one of them gets picked up.
  51. 27 def initialize(verb, uri, options, params = EMPTY_HASH)
  52. 10762 @verb = verb.to_s.upcase
  53. 10762 @uri = Utils.to_uri(uri)
  54. 10761 @headers = options.headers.dup
  55. 10761 merge_headers(params.delete(:headers)) if params.key?(:headers)
  56. 10761 @query_params = params.delete(:params) if params.key?(:params)
  57. 10761 @body = options.request_body_class.new(@headers, options, **params)
  58. 10753 @options = @body.options
  59. 10753 if @uri.relative? || @uri.host.nil?
  60. 600 origin = @options.origin
  61. 600 raise(Error, "invalid URI: #{@uri}") unless origin
  62. 576 base_path = @options.base_path
  63. 576 @uri = origin.merge("#{base_path}#{@uri}")
  64. end
  65. 10729 raise UnsupportedSchemeError, "#{@uri}: #{@uri.scheme}: unsupported URI scheme" unless ALLOWED_URI_SCHEMES.include?(@uri.scheme)
  66. 10717 @state = :idle
  67. 10717 @response = @peer_address = @context = @informational_status = nil
  68. 10717 @ping = false
  69. 10717 @persistent = @options.persistent
  70. 10717 @active_timeouts = []
  71. end
  72. 27 def complete!(response = @response)
  73. 8538 emit(:complete, response)
  74. end
  75. # whether request has been buffered with a ping
  76. 27 def ping?
  77. 495 @ping
  78. end
  79. # marks the request as having been buffered with a ping
  80. 27 def ping!
  81. 46 @ping = true
  82. end
  83. # the read timeout defined for this request.
  84. 27 def read_timeout
  85. 19506 @options.timeout[:read_timeout]
  86. end
  87. # the write timeout defined for this request.
  88. 27 def write_timeout
  89. 19506 @options.timeout[:write_timeout]
  90. end
  91. # the request timeout defined for this request.
  92. 27 def request_timeout
  93. 19222 @options.timeout[:request_timeout]
  94. end
  95. 27 def persistent?
  96. 5099 @persistent
  97. end
  98. # if the request contains trailer headers
  99. 27 def trailers?
  100. 3242 defined?(@trailers)
  101. end
  102. # returns an instance of HTTPX::Headers containing the trailer headers
  103. 27 def trailers
  104. 88 @trailers ||= @options.headers_class.new
  105. end
  106. # returns +:r+ or +:w+, depending on whether the request is waiting for a response or flushing.
  107. 27 def interests
  108. 43704 return :r if @state == :done || @state == :expect
  109. 5334 :w
  110. end
  111. 27 def can_buffer?
  112. 27455 @state != :done
  113. end
  114. # merges +h+ into the instance of HTTPX::Headers of the request.
  115. 27 def merge_headers(h)
  116. 1179 @headers = @headers.merge(h)
  117. 1179 return unless @headers.key?("range")
  118. 16 @headers.delete("accept-encoding")
  119. end
  120. # the URI scheme of the request +uri+.
  121. 27 def scheme
  122. 3851 @uri.scheme
  123. end
  124. # sets the +response+ on this request.
  125. 27 def response=(response)
  126. 9922 return unless response
  127. 9922 case response
  128. when Response
  129. 8781 if response.status < 200
  130. # deal with informational responses
  131. 160 if response.status == 100 && @headers.key?("expect")
  132. 136 @informational_status = response.status
  133. 136 return
  134. end
  135. # 103 Early Hints advertises resources in document to browsers.
  136. # not very relevant for an HTTP client, discard.
  137. 24 return if response.status >= 103
  138. end
  139. when ErrorResponse
  140. 1141 response.error.connection = nil if response.error.respond_to?(:connection=)
  141. end
  142. 9786 @response = response
  143. 9786 emit(:response_started, response)
  144. end
  145. # returnns the URI path of the request +uri+.
  146. 27 def path
  147. 9592 path = uri.path.dup
  148. 9592 path = +"" if path.nil?
  149. 9592 path << "/" if path.empty?
  150. 9592 path << "?#{query}" unless query.empty?
  151. 9592 path
  152. end
  153. # returs the URI authority of the request.
  154. #
  155. # session.build_request("GET", "https://google.com/query").authority #=> "google.com"
  156. # session.build_request("GET", "http://internal:3182/a").authority #=> "internal:3182"
  157. 27 def authority
  158. 9190 @uri.authority
  159. end
  160. # returs the URI origin of the request.
  161. #
  162. # session.build_request("GET", "https://google.com/query").authority #=> "https://google.com"
  163. # session.build_request("GET", "http://internal:3182/a").authority #=> "http://internal:3182"
  164. 27 def origin
  165. 4195 @uri.origin
  166. end
  167. # returs the URI query string of the request (when available).
  168. #
  169. # session.build_request("GET", "https://search.com").query #=> ""
  170. # session.build_request("GET", "https://search.com?q=a").query #=> "q=a"
  171. # session.build_request("GET", "https://search.com", params: { q: "a"}).query #=> "q=a"
  172. # session.build_request("GET", "https://search.com?q=a", params: { foo: "bar"}).query #=> "q=a&foo&bar"
  173. 27 def query
  174. 10571 return @query if defined?(@query)
  175. 8789 query = []
  176. 8789 if (q = @query_params) && !q.empty?
  177. 168 query << Transcoder::Form.encode(q)
  178. end
  179. 8789 query << @uri.query if @uri.query
  180. 8789 @query = query.join("&")
  181. end
  182. # consumes and returns the next available chunk of request body that can be sent
  183. 27 def drain_body
  184. 9664 return nil if @body.nil?
  185. 9664 @drainer ||= @body.each
  186. 9664 chunk = @drainer.next.dup
  187. 6363 emit(:body_chunk, chunk)
  188. 6363 chunk
  189. rescue StopIteration
  190. 3277 nil
  191. rescue StandardError => e
  192. 24 @drain_error = e
  193. 24 nil
  194. end
  195. skipped # :nocov:
  196. skipped def inspect
  197. skipped "#<#{self.class}:#{object_id} " \
  198. skipped "#{@verb} " \
  199. skipped "#{uri} " \
  200. skipped "@headers=#{@headers} " \
  201. skipped "@body=#{@body}>"
  202. skipped end
  203. skipped # :nocov:
  204. # moves on to the +nextstate+ of the request state machine (when all preconditions are met)
  205. 27 def transition(nextstate)
  206. 43419 case nextstate
  207. when :idle
  208. 807 @body.rewind
  209. 807 @ping = false
  210. 807 @response = nil
  211. 807 @drainer = nil
  212. 807 @active_timeouts.clear
  213. when :headers
  214. 11774 return unless @state == :idle
  215. when :body
  216. 11838 return unless @state == :headers ||
  217. @state == :expect
  218. 9543 if @headers.key?("expect")
  219. 506 if @informational_status && @informational_status == 100
  220. # check for 100 Continue response, and deallocate the var
  221. # if @informational_status == 100
  222. # @response = nil
  223. # end
  224. else
  225. 379 return if @state == :expect # do not re-set it
  226. 144 nextstate = :expect
  227. end
  228. end
  229. when :trailers
  230. 9448 return unless @state == :body
  231. when :done
  232. 9456 return if @state == :expect
  233. end
  234. 37696 log(level: 3) { "#{@state}] -> #{nextstate}" }
  235. 37440 @state = nextstate
  236. 37440 emit(@state, self)
  237. 15080 nil
  238. end
  239. # whether the request supports the 100-continue handshake and already processed the 100 response.
  240. 27 def expects?
  241. 8545 @headers["expect"] == "100-continue" && @informational_status == 100 && !@response
  242. end
  243. 27 def set_timeout_callback(event, &callback)
  244. 97921 clb = once(event, &callback)
  245. # reset timeout callbacks when requests get rerouted to a different connection
  246. 97921 once(:idle) do
  247. 3875 callbacks(event).delete(clb)
  248. end
  249. end
  250. end
  251. end
  252. 27 require_relative "request/body"

lib/httpx/request/body.rb

100.0% lines covered

68 relevant lines. 68 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 27 module HTTPX
  3. # Implementation of the HTTP Request body as a delegator which iterates (responds to +each+) payload chunks.
  4. 27 class Request::Body < SimpleDelegator
  5. 27 class << self
  6. 27 def new(_, options, body: nil, **params)
  7. 10769 if body.is_a?(self)
  8. # request derives its options from body
  9. 16 body.options = options.merge(params)
  10. 16 return body
  11. end
  12. 10753 super
  13. end
  14. end
  15. 27 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. 27 def initialize(h, options, **params)
  25. 10753 @headers = h
  26. 10753 @body = self.class.initialize_body(params)
  27. 10753 @options = options.merge(params)
  28. 10753 if @body
  29. 3358 if @options.compress_request_body && @headers.key?("content-encoding")
  30. 96 @headers.get("content-encoding").each do |encoding|
  31. 96 @body = self.class.initialize_deflater_body(@body, encoding)
  32. end
  33. end
  34. 3358 @headers["content-type"] ||= @body.content_type
  35. 3358 @headers["content-length"] = @body.bytesize unless unbounded_body?
  36. end
  37. 10745 super(@body)
  38. end
  39. # consumes and yields the request payload in chunks.
  40. 27 def each(&block)
  41. 6850 return enum_for(__method__) unless block
  42. 3429 return if @body.nil?
  43. 3357 body = stream(@body)
  44. 3357 if body.respond_to?(:read)
  45. 5295 while (chunk = body.read(16_384))
  46. 3555 block.call(chunk)
  47. end
  48. # TODO: use copy_stream once bug is resolved: https://bugs.ruby-lang.org/issues/21131
  49. # IO.copy_stream(body, ProcIO.new(block))
  50. 1902 elsif body.respond_to?(:each)
  51. 534 body.each(&block)
  52. else
  53. 1370 block[body.to_s]
  54. end
  55. end
  56. 27 def close
  57. 476 @body.close if @body.respond_to?(:close)
  58. end
  59. # if the +@body+ is rewindable, it rewinnds it.
  60. 27 def rewind
  61. 871 return if empty?
  62. 172 @body.rewind if @body.respond_to?(:rewind)
  63. end
  64. # return +true+ if the +body+ has been fully drained (or does nnot exist).
  65. 27 def empty?
  66. 20378 return true if @body.nil?
  67. 9085 return false if chunked?
  68. 8989 @body.bytesize.zero?
  69. end
  70. # returns the +@body+ payload size in bytes.
  71. 27 def bytesize
  72. 3615 return 0 if @body.nil?
  73. 128 @body.bytesize
  74. end
  75. # sets the body to yield using chunked trannsfer encoding format.
  76. 27 def stream(body)
  77. 3357 return body unless chunked?
  78. 96 Transcoder::Chunker.encode(body.enum_for(:each))
  79. end
  80. # returns whether the body yields infinitely.
  81. 27 def unbounded_body?
  82. 3882 return @unbounded_body if defined?(@unbounded_body)
  83. 3430 @unbounded_body = !@body.nil? && (chunked? || @body.bytesize == Float::INFINITY)
  84. end
  85. # returns whether the chunked transfer encoding header is set.
  86. 27 def chunked?
  87. 21116 @headers["transfer-encoding"] == "chunked"
  88. end
  89. # sets the chunked transfer encoding header.
  90. 27 def chunk!
  91. 32 @headers.add("transfer-encoding", "chunked")
  92. end
  93. skipped # :nocov:
  94. skipped def inspect
  95. skipped "#<#{self.class}:#{object_id} " \
  96. skipped "#{unbounded_body? ? "stream" : "@bytesize=#{bytesize}"}>"
  97. skipped end
  98. skipped # :nocov:
  99. 27 class << self
  100. 27 def initialize_body(params)
  101. 10593 if (body = params.delete(:body))
  102. # @type var body: bodyIO
  103. 1514 Transcoder::Body.encode(body)
  104. 9079 elsif (form = params.delete(:form))
  105. 1601 if Transcoder::Multipart.multipart?(form)
  106. # @type var form: Transcoder::multipart_input
  107. 1015 Transcoder::Multipart.encode(form)
  108. else
  109. # @type var form: Transcoder::urlencoded_input
  110. 586 Transcoder::Form.encode(form)
  111. end
  112. 7478 elsif (json = params.delete(:json))
  113. # @type var body: _ToJson
  114. 83 Transcoder::JSON.encode(json)
  115. end
  116. end
  117. # returns the +body+ wrapped with the correct deflater accordinng to the given +encodisng+.
  118. 27 def initialize_deflater_body(body, encoding)
  119. 96 case encoding
  120. when "gzip"
  121. 48 Transcoder::GZIP.encode(body)
  122. when "deflate"
  123. 24 Transcoder::Deflate.encode(body)
  124. when "identity"
  125. 16 body
  126. else
  127. 8 body
  128. end
  129. end
  130. end
  131. end
  132. end

lib/httpx/resolver.rb

98.95% lines covered

95 relevant lines. 94 lines covered and 1 lines missed.
    
  1. # frozen_string_literal: true
  2. 27 require "socket"
  3. 27 require "resolv"
  4. 27 module HTTPX
  5. 27 module Resolver
  6. 27 RESOLVE_TIMEOUT = [2, 3].freeze
  7. 27 require "httpx/resolver/entry"
  8. 27 require "httpx/resolver/resolver"
  9. 27 require "httpx/resolver/system"
  10. 27 require "httpx/resolver/native"
  11. 27 require "httpx/resolver/https"
  12. 27 require "httpx/resolver/multi"
  13. 27 @lookup_mutex = Thread::Mutex.new
  14. 356 @lookups = Hash.new { |h, k| h[k] = [] }
  15. 27 @identifier_mutex = Thread::Mutex.new
  16. 27 @identifier = 1
  17. 27 @hosts_resolver = Resolv::Hosts.new
  18. 27 module_function
  19. 27 def supported_ip_families
  20. 7850 @supported_ip_families ||= begin
  21. # https://github.com/ruby/resolv/blob/095f1c003f6073730500f02acbdbc55f83d70987/lib/resolv.rb#L408
  22. 33 list = Socket.ip_address_list
  23. 133 if list.any? { |a| a.ipv6? && !a.ipv6_loopback? && !a.ipv6_linklocal? }
  24. 1 [Socket::AF_INET6, Socket::AF_INET]
  25. else
  26. 32 [Socket::AF_INET]
  27. end
  28. rescue NotImplementedError
  29. [Socket::AF_INET]
  30. end.freeze
  31. end
  32. 27 def resolver_for(resolver_type, options)
  33. 7621 case resolver_type
  34. when Symbol
  35. 7540 meth = :"resolver_#{resolver_type}_class"
  36. 7540 return options.__send__(meth) if options.respond_to?(meth)
  37. when Class
  38. 81 return resolver_type if resolver_type < Resolver
  39. end
  40. 15 raise Error, "unsupported resolver type (#{resolver_type})"
  41. end
  42. 27 def nolookup_resolve(hostname)
  43. 7284 ip_resolve(hostname) || cached_lookup(hostname) || hosts_resolve(hostname)
  44. end
  45. # tries to convert +hostname+ into an IPAddr, returns <tt>nil</tt> otherwise.
  46. 27 def ip_resolve(hostname)
  47. 7284 [Entry.new(hostname)]
  48. rescue ArgumentError
  49. end
  50. # matches +hostname+ to entries in the hosts file, returns <tt>nil</nil> if none is
  51. # found, or there is no hosts file.
  52. 27 def hosts_resolve(hostname)
  53. 636 ips = @hosts_resolver.getaddresses(hostname)
  54. 636 return if ips.empty?
  55. 819 ips.map { |ip| Entry.new(ip) }
  56. rescue IOError
  57. end
  58. 27 def cached_lookup(hostname)
  59. 6665 now = Utils.now
  60. 6665 lookup_synchronize do |lookups|
  61. 6665 lookup(hostname, lookups, now)
  62. end
  63. end
  64. 27 def cached_lookup_set(hostname, family, entries)
  65. 236 lookup_synchronize do |lookups|
  66. 236 case family
  67. when Socket::AF_INET6
  68. 48 lookups[hostname].concat(entries)
  69. when Socket::AF_INET
  70. 188 lookups[hostname].unshift(*entries)
  71. end
  72. 236 entries.each do |entry|
  73. 308 next unless entry["name"] != hostname
  74. 214 case family
  75. when Socket::AF_INET6
  76. 22 lookups[entry["name"]] << entry
  77. when Socket::AF_INET
  78. 192 lookups[entry["name"]].unshift(entry)
  79. end
  80. end
  81. end
  82. end
  83. 27 def cached_lookup_evict(hostname, ip)
  84. 34 ip = ip.to_s
  85. 34 lookup_synchronize do |lookups|
  86. 34 entries = lookups[hostname]
  87. 34 return unless entries
  88. 450 lookups.delete_if { |entry| entry["data"] == ip }
  89. end
  90. end
  91. # do not use directly!
  92. 27 def lookup(hostname, lookups, ttl)
  93. 6673 return unless lookups.key?(hostname)
  94. 6178 entries = lookups[hostname] = lookups[hostname].select do |address|
  95. 17012 address["TTL"] > ttl
  96. end
  97. 6178 ips = entries.flat_map do |address|
  98. 16998 if (als = address["alias"])
  99. 8 lookup(als, lookups, ttl)
  100. else
  101. 16990 Entry.new(address["data"], address["TTL"])
  102. end
  103. end.compact
  104. 6178 ips unless ips.empty?
  105. end
  106. 27 def generate_id
  107. 1820 id_synchronize { @identifier = (@identifier + 1) & 0xFFFF }
  108. end
  109. 27 def encode_dns_query(hostname, type: Resolv::DNS::Resource::IN::A, message_id: generate_id)
  110. 808 Resolv::DNS::Message.new(message_id).tap do |query|
  111. 910 query.rd = 1
  112. 910 query.add_question(hostname, type)
  113. 202 end.encode
  114. end
  115. 27 def decode_dns_answer(payload)
  116. 100 begin
  117. 707 message = Resolv::DNS::Message.decode(payload)
  118. rescue Resolv::DNS::DecodeError => e
  119. 6 return :decode_error, e
  120. end
  121. # no domain was found
  122. 701 return :no_domain_found if message.rcode == Resolv::DNS::RCode::NXDomain
  123. 311 return :message_truncated if message.tc == 1
  124. 299 return :dns_error, message.rcode if message.rcode != Resolv::DNS::RCode::NoError
  125. 293 addresses = []
  126. 293 now = Utils.now
  127. 293 message.each_answer do |question, _, value|
  128. 1086 case value
  129. when Resolv::DNS::Resource::IN::CNAME
  130. 26 addresses << {
  131. "name" => question.to_s,
  132. 26 "TTL" => (now + value.ttl),
  133. "alias" => value.name.to_s,
  134. }
  135. when Resolv::DNS::Resource::IN::A,
  136. Resolv::DNS::Resource::IN::AAAA
  137. 1060 addresses << {
  138. 24 "name" => question.to_s,
  139. 1060 "TTL" => (now + value.ttl),
  140. "data" => value.address.to_s,
  141. }
  142. end
  143. end
  144. 293 [:ok, addresses]
  145. end
  146. 27 def lookup_synchronize
  147. 13872 @lookup_mutex.synchronize { yield(@lookups) }
  148. end
  149. 27 def id_synchronize(&block)
  150. 910 @identifier_mutex.synchronize(&block)
  151. end
  152. end
  153. end

lib/httpx/resolver/entry.rb

100.0% lines covered

16 relevant lines. 16 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 27 require "ipaddr"
  3. 27 module HTTPX
  4. 27 module Resolver
  5. 27 class Entry < SimpleDelegator
  6. 27 attr_reader :address
  7. 27 def self.convert(address)
  8. 46 new(address, rescue_on_convert: true)
  9. end
  10. 27 def initialize(address, expires_in = Float::INFINITY, rescue_on_convert: false)
  11. 26212 @expires_in = expires_in
  12. 26212 @address = address.is_a?(IPAddr) ? address : IPAddr.new(address.to_s)
  13. 19609 super(@address)
  14. rescue IPAddr::InvalidAddressError
  15. 6603 raise unless rescue_on_convert
  16. 18 @address = address.to_s
  17. 18 super(@address)
  18. end
  19. 27 def expired?
  20. 1997 @expires_in < Utils.now
  21. end
  22. end
  23. end
  24. end

lib/httpx/resolver/https.rb

92.26% lines covered

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

lib/httpx/resolver/multi.rb

100.0% lines covered

45 relevant lines. 45 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 27 require "forwardable"
  3. 27 require "resolv"
  4. 27 module HTTPX
  5. 27 class Resolver::Multi
  6. 27 attr_reader :resolvers, :options
  7. 27 def initialize(resolver_type, options)
  8. 7396 @current_selector = @current_session = nil
  9. 7396 @options = options
  10. 7396 @resolver_options = @options.resolver_options
  11. 7396 ip_families = options.ip_families || Resolver.supported_ip_families
  12. 7396 @resolvers = ip_families.map do |ip_family|
  13. 7457 resolver = resolver_type.new(ip_family, options)
  14. 7457 resolver.multi = self
  15. 7457 resolver
  16. end
  17. 7396 @errors = Hash.new { |hs, k| hs[k] = [] }
  18. end
  19. 27 def current_selector=(s)
  20. 7405 @current_selector = s
  21. 14872 @resolvers.each { |r| r.current_selector = s }
  22. end
  23. 27 def current_session=(s)
  24. 7405 @current_session = s
  25. 14872 @resolvers.each { |r| r.current_session = s }
  26. end
  27. 27 def log(*args, **kwargs, &blk)
  28. 14896 @resolvers.each { |r| r.log(*args, **kwargs, &blk) }
  29. end
  30. 27 def closed?
  31. 438 @resolvers.all?(&:closed?)
  32. end
  33. 27 def early_resolve(connection)
  34. 7413 hostname = connection.peer.host
  35. 7413 addresses = @resolver_options[:cache] && (connection.addresses || HTTPX::Resolver.nolookup_resolve(hostname))
  36. 7413 return false unless addresses
  37. 6884 ip_families = connection.options.ip_families
  38. 6884 resolved = false
  39. 7167 addresses.group_by(&:family).sort { |(f1, _), (f2, _)| f2 <=> f1 }.each do |family, addrs|
  40. 7150 next unless ip_families.nil? || ip_families.include?(family)
  41. # try to match the resolver by family. However, there are cases where that's not possible, as when
  42. # the system does not have IPv6 connectivity, but it does support IPv6 via loopback/link-local.
  43. 14323 resolver = @resolvers.find { |r| r.family == family } || @resolvers.first
  44. 7150 next unless resolver # this should ever happen
  45. # it does not matter which resolver it is, as early-resolve code is shared.
  46. 7150 resolver.emit_addresses(connection, family, addrs, true)
  47. 7112 resolved = true
  48. end
  49. 6846 resolved
  50. end
  51. 27 def lazy_resolve(connection)
  52. 530 @resolvers.each do |resolver|
  53. 577 conn_to_resolve = @current_session.try_clone_connection(connection, @current_selector, resolver.family)
  54. 577 resolver << conn_to_resolve
  55. 559 next if resolver.empty?
  56. # both the resolver and the connection it's resolving must be pineed to the session
  57. 451 @current_session.pin(conn_to_resolve, @current_selector)
  58. 451 @current_session.select_resolver(resolver, @current_selector)
  59. end
  60. end
  61. end
  62. end

lib/httpx/resolver/native.rb

93.69% lines covered

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

lib/httpx/resolver/resolver.rb

96.7% lines covered

91 relevant lines. 88 lines covered and 3 lines missed.
    
  1. # frozen_string_literal: true
  2. 27 require "resolv"
  3. 27 module HTTPX
  4. # Base class for all internal internet name resolvers. It handles basic blocks
  5. # from the Selectable API.
  6. #
  7. 27 class Resolver::Resolver
  8. 27 include Loggable
  9. 27 using ArrayExtensions::Intersect
  10. 2 RECORD_TYPES = {
  11. 25 Socket::AF_INET6 => Resolv::DNS::Resource::IN::AAAA,
  12. Socket::AF_INET => Resolv::DNS::Resource::IN::A,
  13. }.freeze
  14. 2 FAMILY_TYPES = {
  15. 25 Resolv::DNS::Resource::IN::AAAA => "AAAA",
  16. Resolv::DNS::Resource::IN::A => "A",
  17. }.freeze
  18. 27 class << self
  19. 27 def multi?
  20. 7396 true
  21. end
  22. end
  23. 27 attr_reader :family, :options
  24. 27 attr_writer :current_selector, :current_session
  25. 27 attr_accessor :multi
  26. 27 def initialize(family, options)
  27. 7610 @family = family
  28. 7610 @record_type = RECORD_TYPES[family]
  29. 7610 @options = options
  30. 7610 @connections = []
  31. end
  32. 27 def each_connection(&block)
  33. 444 enum_for(__method__) unless block
  34. 444 return unless @connections
  35. 444 @connections.each(&block)
  36. end
  37. 27 def close; end
  38. 27 alias_method :terminate, :close
  39. 27 def force_close(*args)
  40. 360 while (connection = @connections.shift)
  41. 120 connection.force_close(*args)
  42. end
  43. end
  44. 27 def closed?
  45. true
  46. end
  47. 27 def empty?
  48. 108 true
  49. end
  50. 27 def inflight?
  51. 104 false
  52. end
  53. 27 def emit_addresses(connection, family, addresses, early_resolve = false)
  54. 26861 addresses.map! { |address| address.is_a?(Resolver::Entry) ? address : Resolver::Entry.new(address) }
  55. # double emission check, but allow early resolution to work
  56. 7582 conn_addrs = connection.addresses
  57. 7582 return if !early_resolve && conn_addrs && (!conn_addrs.empty? && !addresses.intersect?(conn_addrs))
  58. 7582 log do
  59. 88 "resolver #{FAMILY_TYPES[RECORD_TYPES[family]]}: " \
  60. "answer #{connection.peer.host}: #{addresses.inspect} (early resolve: #{early_resolve})"
  61. end
  62. # do not apply resolution delay for non-dns name resolution
  63. 7582 if !early_resolve &&
  64. # just in case...
  65. @current_selector &&
  66. # resolution delay only applies to IPv4
  67. family == Socket::AF_INET &&
  68. # connection already has addresses and initiated/ended handshake
  69. !connection.io &&
  70. # no need to delay if not supporting dual stack / multi-homed IP
  71. 338 (connection.options.ip_families || Resolver.supported_ip_families).size > 1 &&
  72. # connection URL host is already the IP (early resolve included perhaps?)
  73. addresses.first.to_s != connection.peer.host.to_s
  74. 21 log { "resolver #{FAMILY_TYPES[RECORD_TYPES[family]]}: applying resolution delay..." }
  75. 21 @current_selector.after(0.05) do
  76. # double emission check
  77. 19 unless connection.addresses && addresses.intersect?(connection.addresses)
  78. 19 emit_resolved_connection(connection, addresses, early_resolve)
  79. end
  80. end
  81. else
  82. 7561 emit_resolved_connection(connection, addresses, early_resolve)
  83. end
  84. end
  85. 27 def handle_error(error)
  86. 39 if error.respond_to?(:connection) &&
  87. error.respond_to?(:host)
  88. 21 @connections.delete(error.connection)
  89. 21 emit_resolve_error(error.connection, error.host, error)
  90. else
  91. 54 while (connection = @connections.shift)
  92. 18 emit_resolve_error(connection, connection.peer.host, error)
  93. end
  94. end
  95. end
  96. 27 def on_error(error)
  97. 12 handle_error(error)
  98. 12 disconnect
  99. end
  100. 27 def early_resolve(connection, hostname: connection.peer.host)
  101. 4 addresses = @resolver_options[:cache] && (connection.addresses || HTTPX::Resolver.nolookup_resolve(hostname))
  102. 4 return false unless addresses
  103. 14 addresses = addresses.select { |addr| addr.family == @family }
  104. 2 return false if addresses.empty?
  105. emit_addresses(connection, @family, addresses, true)
  106. true
  107. end
  108. 27 private
  109. 27 def emit_resolved_connection(connection, addresses, early_resolve)
  110. begin
  111. 7580 connection.addresses = addresses
  112. 7536 return if connection.state == :closed
  113. 7530 resolve_connection(connection)
  114. 24 rescue StandardError => e
  115. 44 if early_resolve
  116. 38 connection.force_close
  117. 38 throw(:resolve_error, e)
  118. else
  119. 6 emit_connection_error(connection, e)
  120. end
  121. end
  122. end
  123. 27 def emit_resolve_error(connection, hostname = connection.peer.host, ex = nil)
  124. 236 emit_connection_error(connection, resolve_error(hostname, ex))
  125. end
  126. 27 def resolve_error(hostname, ex = nil)
  127. 236 return ex if ex.is_a?(ResolveError) || ex.is_a?(ResolveTimeoutError)
  128. 72 message = ex ? ex.message : "Can't resolve #{hostname}"
  129. 72 error = ResolveError.new(message)
  130. 72 error.set_backtrace(ex ? ex.backtrace : caller)
  131. 72 error
  132. end
  133. 27 def resolve_connection(connection)
  134. 7530 @current_session.__send__(:on_resolver_connection, connection, @current_selector)
  135. end
  136. 27 def emit_connection_error(connection, error)
  137. 242 return connection.handle_connect_error(error) if connection.connecting?
  138. 6 connection.on_error(error)
  139. end
  140. 27 def disconnect
  141. 625 return if closed?
  142. 553 close
  143. 553 @current_session.deselect_resolver(self, @current_selector)
  144. end
  145. end
  146. end

lib/httpx/resolver/system.rb

95.42% lines covered

153 relevant lines. 146 lines covered and 7 lines missed.
    
  1. # frozen_string_literal: true
  2. 27 require "resolv"
  3. 27 module HTTPX
  4. # Implementation of a synchronous name resolver which relies on the system resolver,
  5. # which is lib'c getaddrinfo function (abstracted in ruby via Addrinfo.getaddrinfo).
  6. #
  7. # Its main advantage is relying on the reference implementation for name resolution
  8. # across most/all OSs which deploy ruby (it's what TCPSocket also uses), its main
  9. # disadvantage is the inability to set timeouts / check socket for readiness events,
  10. # hence why it relies on using the Timeout module, which poses a lot of problems for
  11. # the selector loop, specially when network is unstable.
  12. #
  13. 27 class Resolver::System < Resolver::Resolver
  14. 27 using URIExtensions
  15. 27 RESOLV_ERRORS = [Resolv::ResolvError,
  16. Resolv::DNS::Requester::RequestError,
  17. Resolv::DNS::EncodeError,
  18. Resolv::DNS::DecodeError].freeze
  19. 27 DONE = 1
  20. 27 ERROR = 2
  21. 27 class << self
  22. 27 def multi?
  23. 153 false
  24. end
  25. end
  26. 27 attr_reader :state
  27. 27 def initialize(options)
  28. 153 super(0, options)
  29. 153 @resolver_options = @options.resolver_options
  30. 153 resolv_options = @resolver_options.dup
  31. 153 timeouts = resolv_options.delete(:timeouts) || Resolver::RESOLVE_TIMEOUT
  32. 153 @_timeouts = Array(timeouts)
  33. 298 @timeouts = Hash.new { |tims, host| tims[host] = @_timeouts.dup }
  34. 153 resolv_options.delete(:cache)
  35. 153 @queries = []
  36. 153 @ips = []
  37. 153 @pipe_mutex = Thread::Mutex.new
  38. 153 @state = :idle
  39. end
  40. 27 def resolvers
  41. 48 return enum_for(__method__) unless block_given?
  42. 24 yield self
  43. end
  44. 27 def multi
  45. 95 self
  46. end
  47. 27 def empty?
  48. 145 @connections.empty?
  49. end
  50. 27 def close
  51. 145 transition(:closed)
  52. end
  53. 27 def force_close(*)
  54. 48 close
  55. 48 @queries.clear
  56. 48 @timeouts.clear
  57. 48 @ips.clear
  58. 48 super
  59. end
  60. 27 def closed?
  61. 194 @state == :closed
  62. end
  63. 27 def to_io
  64. 400 @pipe_read.to_io
  65. end
  66. 27 def call
  67. 81 case @state
  68. when :open
  69. 81 consume
  70. end
  71. 15 nil
  72. end
  73. 27 def interests
  74. 268 return if @queries.empty?
  75. 268 :r
  76. end
  77. 27 def timeout
  78. 268 _, connection = @queries.first
  79. 268 return unless connection
  80. 268 timeouts = @timeouts[connection.peer.host]
  81. 268 return if timeouts.empty?
  82. 268 log(level: 2) { "resolver #{FAMILY_TYPES[@record_type]}: next timeout #{timeouts.first} secs... (#{timeouts.size - 1} left)" }
  83. 268 timeouts.first
  84. end
  85. 27 def lazy_resolve(connection)
  86. 145 @connections << connection
  87. 145 resolve
  88. 145 return if empty?
  89. 141 @current_session.select_resolver(self, @current_selector)
  90. end
  91. 27 def early_resolve(connection, **); end
  92. 27 def handle_socket_timeout(interval)
  93. 12 error = HTTPX::ResolveTimeoutError.new(interval, "timed out while waiting on select")
  94. 12 error.set_backtrace(caller)
  95. 12 @queries.each do |_, connection| # rubocop:disable Style/HashEachMethods
  96. 12 emit_resolve_error(connection, connection.peer.host, error) if @connections.delete(connection)
  97. end
  98. 24 while (connection = @connections.shift)
  99. emit_resolve_error(connection, connection.peer.host, error)
  100. end
  101. 12 close_or_resolve
  102. end
  103. 27 private
  104. 27 def transition(nextstate)
  105. 290 case nextstate
  106. when :idle
  107. @timeouts.clear
  108. when :open
  109. 145 return unless @state == :idle
  110. 145 @pipe_read, @pipe_write = IO.pipe
  111. when :closed
  112. 145 return unless @state == :open
  113. 145 @pipe_write.close
  114. 145 @pipe_read.close
  115. end
  116. 290 @state = nextstate
  117. end
  118. 27 def consume
  119. 226 return if @connections.empty?
  120. 226 event = @pipe_read.read_nonblock(1, exception: false)
  121. 226 return if event == :wait_readable
  122. 85 raise ResolveError, "socket pipe closed unexpectedly" if event.nil?
  123. 85 case event.unpack1("C")
  124. when DONE
  125. 144 *pair, addrs = @pipe_mutex.synchronize { @ips.pop }
  126. 72 if pair
  127. 72 @queries.delete(pair)
  128. 72 family, connection = pair
  129. 72 @connections.delete(connection)
  130. 144 catch(:coalesced) { emit_addresses(connection, family, addrs) }
  131. end
  132. when ERROR
  133. 26 *pair, error = @pipe_mutex.synchronize { @ips.pop }
  134. 13 if pair && error
  135. 13 @queries.delete(pair)
  136. 13 _, connection = pair
  137. 13 @connections.delete(connection)
  138. 13 emit_resolve_error(connection, connection.peer.host, error)
  139. end
  140. end
  141. 85 return disconnect if @connections.empty?
  142. resolve
  143. rescue StandardError => e
  144. on_error(e)
  145. end
  146. 27 def resolve(connection = nil, hostname = nil)
  147. 145 @connections.shift until @connections.empty? || @connections.first.state != :closed
  148. 145 connection ||= @connections.first
  149. 145 raise Error, "no URI to resolve" unless connection
  150. 145 return unless @queries.empty?
  151. 145 hostname ||= connection.peer.host
  152. 145 scheme = connection.origin.scheme
  153. log do
  154. "resolver: resolve IDN #{connection.peer.non_ascii_hostname} as #{hostname}"
  155. 145 end if connection.peer.non_ascii_hostname
  156. 145 transition(:open)
  157. 145 ip_families = connection.options.ip_families || Resolver.supported_ip_families
  158. 145 ip_families.each do |family|
  159. 175 @queries << [family, connection]
  160. end
  161. 145 async_resolve(connection, hostname, scheme)
  162. 145 consume
  163. end
  164. 27 def async_resolve(connection, hostname, scheme)
  165. 145 families = connection.options.ip_families || Resolver.supported_ip_families
  166. 145 log { "resolver: query for #{hostname}" }
  167. 145 timeouts = @timeouts[connection.peer.host]
  168. 145 resolve_timeout = timeouts.first
  169. 145 Thread.start do
  170. 145 Thread.current.report_on_exception = false
  171. begin
  172. 145 addrs = if resolve_timeout
  173. 145 Timeout.timeout(resolve_timeout) do
  174. 145 __addrinfo_resolve(hostname, scheme)
  175. end
  176. else
  177. __addrinfo_resolve(hostname, scheme)
  178. end
  179. 132 addrs = addrs.sort_by(&:afamily).group_by(&:afamily)
  180. 132 families.each do |family|
  181. 162 addresses = addrs[family]
  182. 162 next unless addresses
  183. 132 addresses.map!(&:ip_address)
  184. 132 addresses.uniq!
  185. 132 @pipe_mutex.synchronize do
  186. 132 @ips.unshift([family, connection, addresses])
  187. 132 @pipe_write.putc(DONE) unless @pipe_write.closed?
  188. end
  189. end
  190. rescue StandardError => e
  191. 13 if e.is_a?(Timeout::Error)
  192. 1 timeouts.shift
  193. 1 retry unless timeouts.empty?
  194. 1 e = ResolveTimeoutError.new(resolve_timeout, e.message)
  195. 1 e.set_backtrace(e.backtrace)
  196. end
  197. 13 @pipe_mutex.synchronize do
  198. 13 families.each do |family|
  199. 13 @ips.unshift([family, connection, e])
  200. 13 @pipe_write.putc(ERROR) unless @pipe_write.closed?
  201. end
  202. end
  203. end
  204. end
  205. 145 Thread.pass
  206. end
  207. 27 def close_or_resolve
  208. # drop already closed connections
  209. 12 @connections.shift until @connections.empty? || @connections.first.state != :closed
  210. 12 if (@connections - @queries.map(&:last)).empty?
  211. 12 disconnect
  212. else
  213. resolve
  214. end
  215. end
  216. 27 def __addrinfo_resolve(host, scheme)
  217. 145 Addrinfo.getaddrinfo(host, scheme, Socket::AF_UNSPEC, Socket::SOCK_STREAM)
  218. end
  219. end
  220. end

lib/httpx/response.rb

100.0% lines covered

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

lib/httpx/response/body.rb

100.0% lines covered

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

lib/httpx/response/buffer.rb

96.67% lines covered

60 relevant lines. 58 lines covered and 2 lines missed.
    
  1. # frozen_string_literal: true
  2. 27 require "delegate"
  3. 27 require "stringio"
  4. 27 require "tempfile"
  5. 27 module HTTPX
  6. # wraps and delegates to an internal buffer, which can be a StringIO or a Tempfile.
  7. 27 class Response::Buffer < SimpleDelegator
  8. 27 attr_reader :buffer
  9. 27 protected :buffer
  10. # initializes buffer with the +threshold_size+ over which the payload gets buffer to a tempfile,
  11. # the initial +bytesize+, and the +encoding+.
  12. 27 def initialize(threshold_size:, bytesize: 0, encoding: Encoding::BINARY)
  13. 7988 @threshold_size = threshold_size
  14. 7988 @bytesize = bytesize
  15. 7988 @encoding = encoding
  16. 7988 @buffer = StringIO.new("".b)
  17. 7988 super(@buffer)
  18. end
  19. 27 def initialize_dup(other)
  20. 112 super
  21. # create new descriptor in READ-ONLY mode
  22. @buffer =
  23. 110 case other.buffer
  24. when StringIO
  25. 104 StringIO.new(other.buffer.string, mode: File::RDONLY)
  26. else
  27. 8 other.buffer.class.new(other.buffer.path, encoding: Encoding::BINARY, mode: File::RDONLY).tap do |temp|
  28. 8 FileUtils.copy_file(other.buffer.path, temp.path)
  29. end
  30. end
  31. end
  32. # size in bytes of the buffered content.
  33. 27 def size
  34. 324 @bytesize
  35. end
  36. # writes the +chunk+ into the buffer.
  37. 27 def write(chunk)
  38. 12831 @bytesize += chunk.bytesize
  39. 12831 try_upgrade_buffer
  40. 12831 @buffer.write(chunk)
  41. end
  42. # returns the buffered content as a string.
  43. 27 def to_s
  44. 4500 case @buffer
  45. when StringIO
  46. 1014 begin
  47. 4428 @buffer.string.force_encoding(@encoding)
  48. rescue ArgumentError
  49. @buffer.string
  50. end
  51. when Tempfile
  52. 72 rewind
  53. 72 content = @buffer.read
  54. 16 begin
  55. 72 content.force_encoding(@encoding)
  56. rescue ArgumentError # ex: unknown encoding name - utf
  57. content
  58. end
  59. end
  60. end
  61. # closes the buffer.
  62. 27 def close
  63. 664 @buffer.close
  64. 664 @buffer.unlink if @buffer.respond_to?(:unlink)
  65. end
  66. 27 def ==(other)
  67. 152 super || begin
  68. 152 return false unless other.is_a?(Response::Buffer)
  69. 152 buffer_pos = @buffer.pos
  70. 152 other_pos = other.buffer.pos
  71. 152 @buffer.rewind
  72. 152 other.buffer.rewind
  73. 36 begin
  74. 152 FileUtils.compare_stream(@buffer, other.buffer)
  75. ensure
  76. 152 @buffer.pos = buffer_pos
  77. 152 other.buffer.pos = other_pos
  78. end
  79. end
  80. end
  81. 27 private
  82. # initializes the buffer into a StringIO, or turns it into a Tempfile when the threshold
  83. # has been reached.
  84. 27 def try_upgrade_buffer
  85. 12831 return unless @bytesize > @threshold_size
  86. 526 return if @buffer.is_a?(Tempfile)
  87. 157 aux = @buffer
  88. 157 @buffer = Tempfile.new("httpx", encoding: Encoding::BINARY, mode: File::RDWR)
  89. 157 if aux
  90. 157 aux.rewind
  91. 157 IO.copy_stream(aux, @buffer)
  92. 157 aux.close
  93. end
  94. 157 __setobj__(@buffer)
  95. end
  96. end
  97. end

lib/httpx/selector.rb

94.24% lines covered

139 relevant lines. 131 lines covered and 8 lines missed.
    
  1. # frozen_string_literal: true
  2. 27 require "io/wait"
  3. 27 module HTTPX
  4. #
  5. # Implements the selector loop, where it registers and monitors "Selectable" objects.
  6. #
  7. # A Selectable object is an object which can calculate the **interests** (<tt>:r</tt>, <tt>:w</tt> or <tt>:rw</tt>,
  8. # respectively "read", "write" or "read-write") it wants to monitor for, and returns (via <tt>to_io</tt> method) an
  9. # IO object which can be passed to functions such as IO.select . More exhaustively, a Selectable **must** implement
  10. # the following methods:
  11. #
  12. # state :: returns the state as a Symbol, must return <tt>:closed</tt> when disposed of resources.
  13. # to_io :: returns the IO object.
  14. # call :: gets called when the IO is ready.
  15. # interests :: returns the current interests to monitor for, as described above.
  16. # timeout :: returns nil or an integer, representing how long to wait for interests.
  17. # handle_socket_timeout(Numeric) :: called when waiting for interest times out.
  18. #
  19. 27 class Selector
  20. 27 extend Forwardable
  21. 27 READABLE = %i[rw r].freeze
  22. 27 WRITABLE = %i[rw w].freeze
  23. 27 private_constant :READABLE
  24. 27 private_constant :WRITABLE
  25. 27 def_delegator :@timers, :after
  26. 27 def_delegator :@selectables, :empty?
  27. 27 def initialize
  28. 8048 @timers = Timers.new
  29. 8048 @selectables = []
  30. 8048 @is_timer_interval = false
  31. end
  32. 27 def each(&blk)
  33. @selectables.each(&blk)
  34. end
  35. 27 def next_tick
  36. 9188728 catch(:jump_tick) do
  37. 9188728 timeout = next_timeout
  38. 9188728 if timeout && timeout.negative?
  39. @timers.fire
  40. throw(:jump_tick)
  41. end
  42. 299415 begin
  43. 9188728 select(timeout) do |c|
  44. 27049 c.log(level: 2) { "[#{c.state}] selected from selector##{object_id} #{" after #{timeout} secs" unless timeout.nil?}..." }
  45. 26887 c.call
  46. end
  47. 9188520 @timers.fire
  48. rescue TimeoutError => e
  49. @timers.fire(e)
  50. end
  51. end
  52. end
  53. 27 def terminate
  54. # array may change during iteration
  55. 7818 selectables = @selectables.reject(&:inflight?)
  56. 7818 selectables.delete_if do |sel|
  57. 3078 sel.terminate
  58. 3070 sel.state == :closed
  59. end
  60. 7810 until selectables.empty?
  61. 2 next_tick
  62. 2 selectables &= @selectables
  63. end
  64. end
  65. 27 def find_resolver(options)
  66. 7558 res = @selectables.find do |c|
  67. 59 c.is_a?(Resolver::Resolver) && options == c.options
  68. end
  69. 7558 res.multi if res
  70. end
  71. 27 def each_connection(&block)
  72. 35982 return enum_for(__method__) unless block
  73. 17991 @selectables.each do |c|
  74. 2583 case c
  75. when Resolver::Resolver
  76. 444 c.each_connection(&block)
  77. when Connection
  78. 2123 yield c
  79. end
  80. end
  81. end
  82. 27 def find_connection(request_uri, options)
  83. 9714 each_connection.find do |connection|
  84. 1306 connection.match?(request_uri, options)
  85. end
  86. end
  87. 27 def find_mergeable_connection(connection)
  88. 7718 each_connection.find do |ch|
  89. 553 ch != connection && ch.mergeable?(connection)
  90. end
  91. end
  92. # deregisters +io+ from selectables.
  93. 27 def deregister(io)
  94. 9257 @selectables.delete(io)
  95. end
  96. # register +io+.
  97. 27 def register(io)
  98. 9505 return if @selectables.include?(io)
  99. 8936 @selectables << io
  100. end
  101. 27 private
  102. 27 def select(interval, &block)
  103. 9188728 has_no_selectables = @selectables.empty?
  104. # do not cause an infinite loop here.
  105. #
  106. # this may happen if timeout calculation actually triggered an error which causes
  107. # the connections to be reaped (such as the total timeout error) before #select
  108. # gets called.
  109. 9188728 return if interval.nil? && has_no_selectables
  110. # @type var r: (selectable | Array[selectable])?
  111. # @type var w: (selectable | Array[selectable])?
  112. 9188728 r, w = nil
  113. 9188728 @selectables.delete_if do |io|
  114. 9189590 interests = io.interests
  115. 9189590 is_closed = io.state == :closed
  116. 9189590 next(is_closed) if is_closed
  117. 9189509 io.log(level: 2) do
  118. 162 "[#{io.state}] registering in selector##{object_id} for select (#{interests})#{" for #{interval} seconds" unless interval.nil?}"
  119. end
  120. 9189509 if READABLE.include?(interests)
  121. 17946 r = r.nil? ? io : (Array(r) << io)
  122. end
  123. 9189509 if WRITABLE.include?(interests)
  124. 10561 w = w.nil? ? io : (Array(w) << io)
  125. end
  126. 9189509 is_closed
  127. end
  128. 9188728 case r
  129. when Array
  130. 379 w = Array(w) unless w.nil?
  131. 379 select_many(r, w, interval, &block)
  132. when nil
  133. 9171164 case w
  134. when Array
  135. 53 select_many(r, w, interval, &block)
  136. when nil
  137. 9161270 return unless interval && has_no_selectables
  138. # no selectables
  139. # TODO: replace with sleep?
  140. 174 select_many(r, w, interval, &block)
  141. else
  142. 9841 select_one(w, :w, interval, &block)
  143. end
  144. else
  145. 17185 case w
  146. when Array
  147. 5 select_many(Array(r), w, interval, &block)
  148. when nil
  149. 16617 select_one(r, :r, interval, &block)
  150. else
  151. 563 if r == w
  152. 406 select_one(r, :rw, interval, &block)
  153. else
  154. 157 select_many(Array(r), Array(w), interval, &block)
  155. end
  156. end
  157. end
  158. end
  159. 27 def select_many(r, w, interval, &block)
  160. 131 begin
  161. 768 readers, writers = ::IO.select(r, w, nil, interval)
  162. rescue StandardError => e
  163. (Array(r) + Array(w)).each do |sel|
  164. sel.on_error(e)
  165. end
  166. return
  167. rescue Exception => e # rubocop:disable Lint/RescueException
  168. 36 (Array(r) + Array(w)).each do |sel|
  169. 72 sel.force_close(true)
  170. end
  171. 36 raise e
  172. end
  173. 732 if readers.nil? && writers.nil? && interval
  174. 217 [*r, *w].each { |io| io.handle_socket_timeout(interval) }
  175. 188 return
  176. end
  177. 544 if writers
  178. readers.each do |io|
  179. 405 yield io
  180. # so that we don't yield 2 times
  181. 405 writers.delete(io)
  182. 542 end if readers
  183. 544 writers.each(&block)
  184. else
  185. readers.each(&block) if readers
  186. end
  187. end
  188. 27 def select_one(io, interests, interval)
  189. 4444 begin
  190. 4446 result =
  191. 22418 case interests
  192. 16617 when :r then io.to_io.wait_readable(interval)
  193. 9841 when :w then io.to_io.wait_writable(interval)
  194. 406 when :rw then rw_wait(io, interval)
  195. end
  196. rescue StandardError => e
  197. 12 io.on_error(e)
  198. 12 return
  199. rescue Exception => e # rubocop:disable Lint/RescueException
  200. 36 io.force_close(true)
  201. 36 raise e
  202. end
  203. 26816 unless result || interval.nil?
  204. 543 io.handle_socket_timeout(interval) unless @is_timer_interval
  205. 543 return
  206. end
  207. 26273 yield io
  208. end
  209. 27 def next_timeout
  210. 9188728 @is_timer_interval = false
  211. 9188728 timer_interval = @timers.wait_interval
  212. 9188728 connection_interval = @selectables.filter_map(&:timeout).min
  213. 9188728 return connection_interval unless timer_interval
  214. 9160856 if connection_interval.nil? || timer_interval <= connection_interval
  215. 9160799 @is_timer_interval = true
  216. 9160799 return timer_interval
  217. end
  218. 57 connection_interval
  219. end
  220. 27 if RUBY_ENGINE == "jruby"
  221. 2 def rw_wait(io, interval)
  222. 102 io.to_io.wait(interval, :read_write)
  223. end
  224. 25 elsif IO.const_defined?(:READABLE)
  225. 23 def rw_wait(io, interval)
  226. 265 io.to_io.wait(IO::READABLE | IO::WRITABLE, interval)
  227. end
  228. else
  229. 2 def rw_wait(io, interval)
  230. 39 if interval
  231. 37 io.to_io.wait(interval, :read_write)
  232. else
  233. 2 io.to_io.wait(:read_write)
  234. end
  235. end
  236. end
  237. end
  238. end

lib/httpx/session.rb

94.93% lines covered

296 relevant lines. 281 lines covered and 15 lines missed.
    
  1. # frozen_string_literal: true
  2. 27 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. 27 class Session
  8. 27 include Loggable
  9. 27 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. 27 def initialize(options = EMPTY_HASH, &blk)
  15. 11803 @options = self.class.default_options.merge(options)
  16. 11803 @persistent = @options.persistent
  17. 11803 @pool = @options.pool_class.new(@options.pool_options)
  18. 11803 @wrapped = false
  19. 11803 @closing = false
  20. 11803 INSTANCES[self] = self if @persistent && @options.close_on_fork && INSTANCES
  21. 11803 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. 27 def wrap
  29. 714 prev_wrapped = @wrapped
  30. 714 @wrapped = true
  31. 714 was_initialized = false
  32. 714 current_selector = get_current_selector do
  33. 714 selector = Selector.new
  34. 714 set_current_selector(selector)
  35. 714 was_initialized = true
  36. 714 selector
  37. end
  38. 116 begin
  39. 714 yield self
  40. ensure
  41. 714 unless prev_wrapped
  42. 714 if @persistent
  43. 1 deactivate(current_selector)
  44. else
  45. 713 close(current_selector)
  46. end
  47. end
  48. 714 @wrapped = prev_wrapped
  49. 714 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. 27 def close(selector = Selector.new)
  58. # throw resolvers away from the pool
  59. 7526 @pool.reset_resolvers
  60. # preparing to throw away connections
  61. 17278 while (connection = @pool.pop_connection)
  62. 4963 next if connection.state == :closed
  63. 214 select_connection(connection, selector)
  64. end
  65. 7526 selector_close(selector)
  66. end
  67. # performs one, or multple requests; it accepts:
  68. #
  69. # 1. one or multiple HTTPX::Request objects;
  70. # 2. an HTTP verb, then a sequence of URIs or URI/options tuples;
  71. # 3. one or multiple HTTP verb / uri / (optional) options tuples;
  72. #
  73. # when present, the set of +options+ kwargs is applied to all of the
  74. # sent requests.
  75. #
  76. # respectively returns a single HTTPX::Response response, or all of them in an Array, in the same order.
  77. #
  78. # resp1 = session.request(req1)
  79. # resp1, resp2 = session.request(req1, req2)
  80. # resp1 = session.request("GET", "https://server.org/a")
  81. # resp1, resp2 = session.request("GET", ["https://server.org/a", "https://server.org/b"])
  82. # resp1, resp2 = session.request(["GET", "https://server.org/a"], ["GET", "https://server.org/b"])
  83. # resp1 = session.request("POST", "https://server.org/a", form: { "foo" => "bar" })
  84. # resp1, resp2 = session.request(["POST", "https://server.org/a", form: { "foo" => "bar" }], ["GET", "https://server.org/b"])
  85. # resp1, resp2 = session.request("GET", ["https://server.org/a", "https://server.org/b"], headers: { "x-api-token" => "TOKEN" })
  86. #
  87. 27 def request(*args, **params)
  88. 7869 raise ArgumentError, "must perform at least one request" if args.empty?
  89. 7869 requests = args.first.is_a?(Request) ? args : build_requests(*args, params)
  90. 7832 responses = send_requests(*requests)
  91. 7604 return responses.first if responses.size == 1
  92. 206 responses
  93. end
  94. # returns a HTTP::Request instance built from the HTTP +verb+, the request +uri+, and
  95. # the optional set of request-specific +options+. This request **must** be sent through
  96. # the same session it was built from.
  97. #
  98. # req = session.build_request("GET", "https://server.com")
  99. # resp = session.request(req)
  100. 27 def build_request(verb, uri, params = EMPTY_HASH, options = @options)
  101. 9582 rklass = options.request_class
  102. 9582 request = rklass.new(verb, uri, options, params)
  103. 9545 request.persistent = @persistent
  104. 9545 set_request_callbacks(request)
  105. 9545 request
  106. end
  107. 27 def select_connection(connection, selector)
  108. 9473 pin(connection, selector)
  109. 9473 connection.log(level: 2) do
  110. 88 "registering into selector##{selector.object_id}"
  111. end
  112. 9473 selector.register(connection)
  113. end
  114. 27 def pin(conn_or_resolver, selector)
  115. 25058 conn_or_resolver.current_session = self
  116. 25058 conn_or_resolver.current_selector = selector
  117. end
  118. 27 alias_method :select_resolver, :select_connection
  119. 27 def deselect_connection(connection, selector, cloned = false)
  120. 8688 connection.log(level: 2) do
  121. 76 "deregistering connection##{connection.object_id}(#{connection.state}) from selector##{selector.object_id}"
  122. end
  123. 8688 selector.deregister(connection)
  124. 8688 return if cloned
  125. 8657 return if @closing && connection.state == :closed
  126. 8717 connection.log(level: 2) { "check-in connection##{connection.object_id}(#{connection.state}) in pool##{@pool.object_id}" }
  127. 8641 @pool.checkin_connection(connection)
  128. end
  129. 27 def deselect_resolver(resolver, selector)
  130. 553 resolver.log(level: 2) do
  131. "deregistering resolver##{resolver.object_id}(#{resolver.state}) from selector##{selector.object_id}"
  132. end
  133. 553 selector.deregister(resolver)
  134. 553 return if @closing && resolver.closed?
  135. 533 resolver.log(level: 2) { "check-in resolver##{resolver.object_id}(#{resolver.state}) in pool##{@pool.object_id}" }
  136. 533 @pool.checkin_resolver(resolver)
  137. end
  138. 27 def try_clone_connection(connection, selector, family)
  139. 577 connection.family ||= family
  140. 577 return connection if connection.family == family
  141. 47 new_connection = connection.class.new(connection.origin, connection.options)
  142. 47 new_connection.family = family
  143. 47 connection.sibling = new_connection
  144. 47 do_init_connection(new_connection, selector)
  145. 47 new_connection
  146. end
  147. # returns the HTTPX::Connection through which the +request+ should be sent through.
  148. 27 def find_connection(request_uri, selector, options)
  149. 9714 if (connection = selector.find_connection(request_uri, options))
  150. 1224 connection.idling if connection.state == :closed
  151. 1224 connection.log(level: 2) { "found connection##{connection.object_id}(#{connection.state}) in selector##{selector.object_id}" }
  152. 1224 return connection
  153. end
  154. 8490 connection = @pool.checkout_connection(request_uri, options)
  155. 8534 connection.log(level: 2) { "found connection##{connection.object_id}(#{connection.state}) in pool##{@pool.object_id}" }
  156. 8458 case connection.state
  157. when :idle
  158. 7715 do_init_connection(connection, selector)
  159. when :open
  160. 64 if options.io
  161. 64 select_connection(connection, selector)
  162. else
  163. pin(connection, selector)
  164. end
  165. when :closing, :closed
  166. 653 connection.idling
  167. 653 if connection.addresses?
  168. 646 select_connection(connection, selector)
  169. else
  170. # if addresses expired, resolve again
  171. 7 resolve_connection(connection, selector)
  172. end
  173. else
  174. 26 pin(connection, selector)
  175. end
  176. 8402 connection
  177. end
  178. 27 private
  179. 27 def selector_close(selector)
  180. begin
  181. 7818 @closing = true
  182. 7818 selector.terminate
  183. ensure
  184. 7818 @closing = false
  185. end
  186. end
  187. # tries deactivating connections in the +selector+, deregistering the ones that have been deactivated.
  188. 27 def deactivate(selector)
  189. 559 selector.each_connection.each(&:deactivate)
  190. end
  191. # callback executed when an HTTP/2 promise frame has been received.
  192. 27 def on_promise(_, stream)
  193. 8 log(level: 2) { "#{stream.id}: refusing stream!" }
  194. 8 stream.refuse
  195. end
  196. # returns the corresponding HTTP::Response to the given +request+ if it has been received.
  197. 27 def fetch_response(request, _selector, _options)
  198. 9197264 response = request.response
  199. 9197264 return unless response && response.finished?
  200. 9403 log(level: 2) { "response fetched" }
  201. 9403 response
  202. end
  203. # sends the +request+ to the corresponding HTTPX::Connection
  204. 27 def send_request(request, selector, options = request.options)
  205. 1944 error = begin
  206. 9567 catch(:resolve_error) do
  207. 9567 connection = find_connection(request.uri, selector, options)
  208. 9455 connection.send(request)
  209. end
  210. rescue StandardError => e
  211. 40 e
  212. end
  213. 9561 return unless error && error.is_a?(Exception)
  214. 112 raise error unless error.is_a?(Error)
  215. 106 response = ErrorResponse.new(request, error)
  216. 106 request.response = response
  217. 106 request.emit(:response, response)
  218. end
  219. # returns a set of HTTPX::Request objects built from the given +args+ and +options+.
  220. 27 def build_requests(*args, params)
  221. 7239 requests = if args.size == 1
  222. 78 reqs = args.first
  223. 78 reqs.map do |verb, uri, ps = EMPTY_HASH|
  224. 156 request_params = params
  225. 156 request_params = request_params.merge(ps) unless ps.empty?
  226. 156 build_request(verb, uri, request_params)
  227. end
  228. else
  229. 7161 verb, uris = args
  230. 7161 if uris.respond_to?(:each)
  231. 6921 uris.enum_for(:each).map do |uri, ps = EMPTY_HASH|
  232. 7686 request_params = params
  233. 7686 request_params = request_params.merge(ps) unless ps.empty?
  234. 7686 build_request(verb, uri, request_params)
  235. end
  236. else
  237. 240 [build_request(verb, uris, params)]
  238. end
  239. end
  240. 7202 raise ArgumentError, "wrong number of URIs (given 0, expect 1..+1)" if requests.empty?
  241. 7202 requests
  242. end
  243. 27 def set_request_callbacks(request)
  244. 9418 request.on(:promise, &method(:on_promise))
  245. end
  246. 27 def do_init_connection(connection, selector)
  247. 7762 resolve_connection(connection, selector) unless connection.family
  248. end
  249. # sends an array of HTTPX::Request +requests+, returns the respective array of HTTPX::Response objects.
  250. 27 def send_requests(*requests)
  251. 14963 selector = get_current_selector { Selector.new }
  252. 1706 begin
  253. 7930 _send_requests(requests, selector)
  254. 7918 receive_requests(requests, selector)
  255. ensure
  256. 7914 unless @wrapped
  257. 7070 if @persistent
  258. 558 deactivate(selector)
  259. else
  260. 6512 close(selector)
  261. end
  262. end
  263. end
  264. end
  265. # sends an array of HTTPX::Request objects
  266. 27 def _send_requests(requests, selector)
  267. 7930 requests.each do |request|
  268. 8758 send_request(request, selector)
  269. end
  270. end
  271. # returns the array of HTTPX::Response objects corresponding to the array of HTTPX::Request +requests+.
  272. 27 def receive_requests(requests, selector)
  273. 7918 responses = [] # : Array[response]
  274. # guarantee ordered responses
  275. 7918 loop do
  276. 8754 request = requests.first
  277. 8754 return responses unless request
  278. 9196415 catch(:coalesced) { selector.next_tick } until (response = fetch_response(request, selector, request.options))
  279. 8538 request.complete!(response)
  280. 8538 responses << response
  281. 8538 requests.shift
  282. 8538 break if requests.empty?
  283. 836 next unless selector.empty?
  284. # in some cases, the pool of connections might have been drained because there was some
  285. # handshake error, and the error responses have already been emitted, but there was no
  286. # opportunity to traverse the requests, hence we're returning only a fraction of the errors
  287. # we were supposed to. This effectively fetches the existing responses and return them.
  288. exit_from_loop = true
  289. requests_to_remove = [] # : Array[Request]
  290. requests.each do |req|
  291. response = fetch_response(req, selector, request.options)
  292. if exit_from_loop && response
  293. req.complete!(response)
  294. responses << response
  295. requests_to_remove << req
  296. else
  297. # fetch_response may resend requests. when that happens, we need to go back to the initial
  298. # loop and process the selector. we still do a pass-through on the remainder of requests, so
  299. # that every request that need to be resent, is resent.
  300. exit_from_loop = false
  301. raise Error, "something went wrong, responses not found and requests not resent" if selector.empty?
  302. end
  303. end
  304. break if exit_from_loop
  305. requests -= requests_to_remove
  306. end
  307. 7702 responses
  308. end
  309. 27 def resolve_connection(connection, selector)
  310. 7746 if connection.addresses? || connection.open?
  311. #
  312. # there are two cases in which we want to activate initialization of
  313. # connection immediately:
  314. #
  315. # 1. when the connection already has addresses, i.e. it doesn't need to
  316. # resolve a name (not the same as name being an IP, yet)
  317. # 2. when the connection is initialized with an external already open IO.
  318. #
  319. 188 on_resolver_connection(connection, selector)
  320. 188 return
  321. end
  322. 7558 resolver = find_resolver_for(connection, selector)
  323. 7558 pin(connection, selector)
  324. 7558 resolver.early_resolve(connection) || resolver.lazy_resolve(connection)
  325. end
  326. 27 def on_resolver_connection(connection, selector)
  327. 7718 from_pool = false
  328. 7718 found_connection = selector.find_mergeable_connection(connection) || begin
  329. 7686 from_pool = true
  330. 7686 connection.log(level: 2) do
  331. 88 "try finding a mergeable connection in pool##{@pool.object_id}"
  332. end
  333. 7686 @pool.checkout_mergeable_connection(connection)
  334. end
  335. 7718 return select_connection(connection, selector) unless found_connection
  336. 50 connection.log(level: 2) do
  337. "try coalescing from #{from_pool ? "pool##{@pool.object_id}" : "selector##{selector.object_id}"} " \
  338. "(connection##{found_connection.object_id}[#{found_connection.origin}])"
  339. end
  340. 50 coalesce_connections(found_connection, connection, selector, from_pool)
  341. end
  342. 27 def find_resolver_for(connection, selector)
  343. 7558 if (resolver = selector.find_resolver(connection.options))
  344. 8 resolver.log(level: 2) { "found resolver##{connection.object_id}(#{connection.state}) in selector##{selector.object_id}" }
  345. 8 return resolver
  346. end
  347. 7550 resolver = @pool.checkout_resolver(connection.options)
  348. 7626 resolver.log(level: 2) { "found resolver##{connection.object_id}(#{connection.state}) in pool##{@pool.object_id}" }
  349. 7550 pin(resolver, selector)
  350. 7550 resolver
  351. end
  352. # coalesces +conn2+ into +conn1+. if +conn1+ was loaded from the connection pool
  353. # (it is known via +from_pool+), then it adds its to the +selector+.
  354. 27 def coalesce_connections(conn1, conn2, selector, from_pool)
  355. 50 unless conn1.coalescable?(conn2)
  356. 25 conn2.log(level: 2) { "not coalescing with conn##{conn1.object_id}[#{conn1.origin}])" }
  357. 25 select_connection(conn2, selector)
  358. 25 if from_pool
  359. 6 conn1.log(level: 2) { "check-in connection##{conn1.object_id}(#{conn1.state}) in pool##{@pool.object_id}" }
  360. 6 @pool.checkin_connection(conn1)
  361. end
  362. 25 return false
  363. end
  364. 25 conn2.log(level: 2) { "coalescing with connection##{conn1.object_id}[#{conn1.origin}])" }
  365. 25 select_connection(conn1, selector) if from_pool
  366. 25 conn2.coalesce!(conn1)
  367. 25 conn2.disconnect
  368. 25 true
  369. end
  370. 27 def get_current_selector
  371. 8668 selector_store[self] || (yield if block_given?)
  372. end
  373. 27 def set_current_selector(selector)
  374. 1932 if selector
  375. 1218 selector_store[self] = selector
  376. else
  377. 714 selector_store.delete(self)
  378. end
  379. end
  380. 27 def selector_store
  381. 10600 th_current = Thread.current
  382. 10600 thread_selector_store(th_current) || begin
  383. 179 {}.compare_by_identity.tap do |store|
  384. 179 th_current.thread_variable_set(:httpx_persistent_selector_store, store)
  385. end
  386. end
  387. end
  388. 27 def thread_selector_store(th)
  389. 14866 th.thread_variable_get(:httpx_persistent_selector_store)
  390. end
  391. 27 @default_options = Options.new
  392. 27 @default_options.freeze
  393. 27 @plugins = []
  394. 27 class << self
  395. 27 attr_reader :default_options
  396. 27 def inherited(klass)
  397. 5994 super
  398. 5994 klass.instance_variable_set(:@default_options, @default_options)
  399. 5994 klass.instance_variable_set(:@plugins, @plugins.dup)
  400. 5994 klass.instance_variable_set(:@callbacks, @callbacks.dup)
  401. end
  402. # returns a new HTTPX::Session instance, with the plugin pointed by +pl+ loaded.
  403. #
  404. # session_with_retries = session.plugin(:retries)
  405. # session_with_custom = session.plugin(CustomPlugin)
  406. #
  407. 27 def plugin(pl, options = nil, &block)
  408. 8622 label = pl
  409. # raise Error, "Cannot add a plugin to a frozen config" if frozen?
  410. 8622 pl = Plugins.load_plugin(pl) if pl.is_a?(Symbol)
  411. 8622 raise ArgumentError, "Invalid plugin type: #{pl.class.inspect}" unless pl.is_a?(Module)
  412. 8615 if !@plugins.include?(pl)
  413. 8378 @plugins << pl
  414. 8378 pl.load_dependencies(self, &block) if pl.respond_to?(:load_dependencies)
  415. 8378 @default_options = @default_options.dup
  416. 8378 include(pl::InstanceMethods) if defined?(pl::InstanceMethods)
  417. 8378 extend(pl::ClassMethods) if defined?(pl::ClassMethods)
  418. 8378 opts = @default_options
  419. 8378 opts.extend_with_plugin_classes(pl)
  420. 8378 if defined?(pl::OptionsMethods)
  421. # when a class gets dup'ed, the #initialize_dup callbacks isn't triggered.
  422. # moreover, and because #method_added does not get triggered on mixin include,
  423. # the callback is also forcefully manually called here.
  424. 3093 opts.options_class.instance_variable_set(:@options_names, opts.options_class.options_names.dup)
  425. 3093 (pl::OptionsMethods.instance_methods + pl::OptionsMethods.private_instance_methods - Object.instance_methods).each do |meth|
  426. 9261 opts.options_class.method_added(meth)
  427. end
  428. 3093 @default_options = opts.options_class.new(opts)
  429. end
  430. 8378 @default_options = pl.extra_options(@default_options) if pl.respond_to?(:extra_options)
  431. 8378 @default_options = @default_options.merge(options) if options
  432. 8378 if pl.respond_to?(:subplugins)
  433. 895 pl.subplugins.transform_keys(&Plugins.method(:load_plugin)).each do |main_pl, sub_pl|
  434. # in case the main plugin has already been loaded, then apply subplugin functionality
  435. # immediately
  436. 895 next unless @plugins.include?(main_pl)
  437. 22 plugin(sub_pl, options, &block)
  438. end
  439. end
  440. 8378 pl.configure(self, &block) if pl.respond_to?(:configure)
  441. 8378 if label.is_a?(Symbol)
  442. # in case an already-loaded plugin complements functionality of
  443. # the plugin currently being loaded, loaded it now
  444. 6301 @plugins.each do |registered_pl|
  445. 16811 next if registered_pl == pl
  446. 10510 next unless registered_pl.respond_to?(:subplugins)
  447. 2428 sub_pl = registered_pl.subplugins[label]
  448. 2428 next unless sub_pl
  449. 33 plugin(sub_pl, options, &block)
  450. end
  451. end
  452. 8378 @default_options.freeze
  453. 8378 set_temporary_name("#{superclass}/#{pl}") if respond_to?(:set_temporary_name) # ruby 3.4 only
  454. 235 elsif options
  455. # this can happen when two plugins are loaded, an one of them calls the other under the hood,
  456. # albeit changing some default.
  457. 23 @default_options = pl.extra_options(@default_options) if pl.respond_to?(:extra_options)
  458. 23 @default_options = @default_options.merge(options) if options
  459. 16 @default_options.freeze
  460. end
  461. 8608 self
  462. end
  463. end
  464. # setup of the support for close_on_fork sessions.
  465. # adapted from https://github.com/mperham/connection_pool/blob/main/lib/connection_pool.rb#L48
  466. 27 if Process.respond_to?(:fork)
  467. 25 INSTANCES = ObjectSpace::WeakMap.new
  468. 25 private_constant :INSTANCES
  469. 25 def self.after_fork
  470. 1 INSTANCES.each_value(&:close)
  471. 1 nil
  472. end
  473. 25 if ::Process.respond_to?(:_fork)
  474. 21 module ForkTracker
  475. 21 def _fork
  476. 1 pid = super
  477. 1 Session.after_fork if pid.zero?
  478. 1 pid
  479. end
  480. end
  481. 21 Process.singleton_class.prepend(ForkTracker)
  482. end
  483. else
  484. 2 INSTANCES = nil
  485. 2 private_constant :INSTANCES
  486. 2 def self.after_fork
  487. # noop
  488. end
  489. end
  490. end
  491. # session may be overridden by certain adapters.
  492. 27 S = Session
  493. end

lib/httpx/session_extensions.rb

100.0% lines covered

15 relevant lines. 15 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 27 module HTTPX
  3. 27 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 new_options_class = proxy_session.class.default_options.options_class.dup
  13. 1 const_set(:Options, new_options_class)
  14. 1 options[:options_class] = Class.new(new_options_class).freeze
  15. 1 options.freeze
  16. 1 Options.send(:const_set, :DEFAULT_OPTIONS, options)
  17. 1 Session.instance_variable_set(:@default_options, Options.new(options))
  18. 1 $VERBOSE = original_verbosity
  19. end
  20. skipped # :nocov:
  21. skipped if Session.default_options.debug_level > 2
  22. skipped proxy_session = plugin(:internal_telemetry)
  23. skipped remove_const(:Session)
  24. skipped const_set(:Session, proxy_session.class)
  25. skipped end
  26. skipped # :nocov:
  27. end

lib/httpx/timers.rb

93.94% lines covered

66 relevant lines. 62 lines covered and 4 lines missed.
    
  1. # frozen_string_literal: true
  2. 27 module HTTPX
  3. 27 class Timers
  4. 27 def initialize
  5. 8048 @intervals = []
  6. end
  7. 27 def after(interval_in_secs, cb = nil, &blk)
  8. 39987 callback = cb || blk
  9. 39987 raise Error, "timer must have a callback" unless callback
  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. 71983 unless (interval = @intervals.bsearch { |t| t.interval == interval_in_secs })
  15. 9756 interval = Interval.new(interval_in_secs)
  16. 9756 @intervals << interval
  17. 9756 @intervals.sort!
  18. end
  19. 39987 interval << callback
  20. 39987 @next_interval_at = nil
  21. 39987 Timer.new(interval, callback)
  22. end
  23. 27 def wait_interval
  24. 9188728 drop_elapsed!
  25. 9188728 return if @intervals.empty?
  26. 9160856 @next_interval_at = Utils.now
  27. 9160856 @intervals.first.interval
  28. end
  29. 27 def fire(error = nil)
  30. 9188520 raise error if error && error.timeout != @intervals.first
  31. 9188520 return if @intervals.empty? || !@next_interval_at
  32. 9160254 elapsed_time = Utils.elapsed_time(@next_interval_at)
  33. 9160254 drop_elapsed!(elapsed_time)
  34. 18313044 @intervals = @intervals.drop_while { |interval| interval.elapse(elapsed_time) <= 0 }
  35. 9160254 @next_interval_at = nil if @intervals.empty?
  36. end
  37. 27 private
  38. 27 def drop_elapsed!(elapsed_time = 0)
  39. # check first, if not elapsed, then return
  40. 18348982 first_interval = @intervals.first
  41. 18348982 return unless first_interval && first_interval.elapsed?(elapsed_time)
  42. # TODO: would be nice to have a drop_while!
  43. 16653 @intervals = @intervals.drop_while { |interval| interval.elapse(elapsed_time) <= 0 }
  44. end
  45. 27 class Timer
  46. 27 def initialize(interval, callback)
  47. 39987 @interval = interval
  48. 39987 @callback = callback
  49. end
  50. 27 def cancel
  51. 58653 @interval.delete(@callback)
  52. end
  53. end
  54. 27 class Interval
  55. 27 include Comparable
  56. 27 attr_reader :interval
  57. 27 def initialize(interval)
  58. 9756 @interval = interval
  59. 9756 @callbacks = []
  60. end
  61. 27 def <=>(other)
  62. 908 @interval <=> other.interval
  63. end
  64. 27 def ==(other)
  65. return @interval == other if other.is_a?(Numeric)
  66. @interval == other.to_f # rubocop:disable Lint/FloatComparison
  67. end
  68. 27 def to_f
  69. Float(@interval)
  70. end
  71. 27 def <<(callback)
  72. 39987 @callbacks << callback
  73. end
  74. 27 def delete(callback)
  75. 58653 @callbacks.delete(callback)
  76. end
  77. 27 def no_callbacks?
  78. @callbacks.empty?
  79. end
  80. 27 def elapsed?(elapsed = 0)
  81. 18321527 (@interval - elapsed) <= 0 || @callbacks.empty?
  82. end
  83. 27 def elapse(elapsed)
  84. # same as elapsing
  85. 9161413 return 0 if @callbacks.empty?
  86. 9153584 @interval -= elapsed
  87. 9153584 if @interval <= 0
  88. 658 cb = @callbacks.dup
  89. 658 cb.each(&:call)
  90. end
  91. 9153584 @interval
  92. end
  93. end
  94. 27 private_constant :Interval
  95. end
  96. 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. 27 module HTTPX
  3. 27 module Transcoder
  4. 27 module_function
  5. 27 def normalize_keys(key, value, cond = nil, &block)
  6. 3639 if cond && cond.call(value)
  7. 1129 block.call(key.to_s, value)
  8. 2508 elsif value.respond_to?(:to_ary)
  9. 454 if value.empty?
  10. 128 block.call("#{key}[]")
  11. else
  12. 326 value.to_ary.each do |element|
  13. 524 normalize_keys("#{key}[]", element, cond, &block)
  14. end
  15. end
  16. 2054 elsif value.respond_to?(:to_hash)
  17. 576 value.to_hash.each do |child_key, child_value|
  18. 576 normalize_keys("#{key}[#{child_key}]", child_value, cond, &block)
  19. end
  20. else
  21. 1480 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. 27 def normalize_query(params, name, v, depth)
  26. 184 raise Error, "params depth surpasses what's supported" if depth <= 0
  27. 184 name =~ /\A[\[\]]*([^\[\]]+)\]*/
  28. 184 k = Regexp.last_match(1) || ""
  29. 184 after = Regexp.last_match ? Regexp.last_match.post_match : ""
  30. 184 if k.empty?
  31. 16 return Array(v) if !v.empty? && name == "[]"
  32. 8 return
  33. end
  34. 168 case after
  35. when ""
  36. 56 params[k] = v
  37. when "["
  38. 8 params[name] = v
  39. when "[]"
  40. 16 params[k] ||= []
  41. 16 raise Error, "expected Array (got #{params[k].class}) for param '#{k}'" unless params[k].is_a?(Array)
  42. 16 params[k] << v
  43. when /^\[\]\[([^\[\]]+)\]$/, /^\[\](.+)$/
  44. 32 child_key = Regexp.last_match(1)
  45. 32 params[k] ||= []
  46. 32 raise Error, "expected Array (got #{params[k].class}) for param '#{k}'" unless params[k].is_a?(Array)
  47. 32 if params[k].last.is_a?(Hash) && !params_hash_has_key?(params[k].last, child_key)
  48. 8 normalize_query(params[k].last, child_key, v, depth - 1)
  49. else
  50. 24 params[k] << normalize_query({}, child_key, v, depth - 1)
  51. end
  52. else
  53. 56 params[k] ||= {}
  54. 56 raise Error, "expected Hash (got #{params[k].class}) for param '#{k}'" unless params[k].is_a?(Hash)
  55. 56 params[k] = normalize_query(params[k], after, v, depth - 1)
  56. end
  57. 168 params
  58. end
  59. 27 def params_hash_has_key?(hash, key)
  60. 16 return false if key.include?("[]")
  61. 16 key.split(/[\[\]]+/).inject(hash) do |h, part|
  62. 16 next h if part == ""
  63. 16 return false unless h.is_a?(Hash) && h.key?(part)
  64. 8 h[part]
  65. end
  66. 8 true
  67. end
  68. end
  69. end
  70. 27 require "httpx/transcoder/body"
  71. 27 require "httpx/transcoder/form"
  72. 27 require "httpx/transcoder/json"
  73. 27 require "httpx/transcoder/chunker"
  74. 27 require "httpx/transcoder/deflate"
  75. 27 require "httpx/transcoder/gzip"

lib/httpx/transcoder/body.rb

100.0% lines covered

26 relevant lines. 26 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 27 require "delegate"
  3. 27 module HTTPX::Transcoder
  4. 27 module Body
  5. 27 class Error < HTTPX::Error; end
  6. 27 module_function
  7. 27 class Encoder < SimpleDelegator
  8. 27 def initialize(body)
  9. 1514 body = body.open(File::RDONLY, encoding: Encoding::BINARY) if Object.const_defined?(:Pathname) && body.is_a?(Pathname)
  10. 1514 @body = body
  11. 1514 super(body)
  12. end
  13. 27 def bytesize
  14. 5908 if @body.respond_to?(:bytesize)
  15. 2550 @body.bytesize
  16. 3356 elsif @body.respond_to?(:to_ary)
  17. 1202 @body.sum(&:bytesize)
  18. 2154 elsif @body.respond_to?(:size)
  19. 1500 @body.size || Float::INFINITY
  20. 654 elsif @body.respond_to?(:length)
  21. 360 @body.length || Float::INFINITY
  22. 294 elsif @body.respond_to?(:each)
  23. 288 Float::INFINITY
  24. else
  25. 8 raise Error, "cannot determine size of body: #{@body.inspect}"
  26. end
  27. end
  28. 27 def content_type
  29. 1436 "application/octet-stream"
  30. end
  31. end
  32. 27 def encode(body)
  33. 1514 Encoder.new(body)
  34. end
  35. end
  36. 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. 27 require "forwardable"
  3. 27 module HTTPX::Transcoder
  4. 27 module Chunker
  5. 27 class Error < HTTPX::Error; end
  6. 27 CRLF = "\r\n".b
  7. 27 class Encoder
  8. 27 extend Forwardable
  9. 27 def initialize(body)
  10. 96 @raw = body
  11. end
  12. 27 def each
  13. 96 return enum_for(__method__) unless block_given?
  14. 96 @raw.each do |chunk|
  15. 448 yield "#{chunk.bytesize.to_s(16)}#{CRLF}#{chunk}#{CRLF}"
  16. end
  17. 96 yield "0#{CRLF}"
  18. end
  19. 27 def respond_to_missing?(meth, *args)
  20. 108 @raw.respond_to?(meth, *args) || super
  21. end
  22. end
  23. 27 class Decoder
  24. 27 extend Forwardable
  25. 27 def_delegator :@buffer, :empty?
  26. 27 def_delegator :@buffer, :<<
  27. 27 def_delegator :@buffer, :clear
  28. 27 def initialize(buffer, trailers = false)
  29. 115 @buffer = buffer
  30. 115 @chunk_buffer = "".b
  31. 115 @finished = false
  32. 115 @state = :length
  33. 115 @trailers = trailers
  34. end
  35. 27 def to_s
  36. 107 @buffer
  37. end
  38. 27 def each
  39. 156 loop do
  40. 1146 case @state
  41. when :length
  42. 334 index = @buffer.index(CRLF)
  43. 334 return unless index && index.positive?
  44. # Read hex-length
  45. 334 hexlen = @buffer.byteslice(0, index)
  46. 334 @buffer = @buffer.byteslice(index..-1) || "".b
  47. 334 hexlen[/\h/] || raise(Error, "wrong chunk size line: #{hexlen}")
  48. 334 @chunk_length = hexlen.hex
  49. # check if is last chunk
  50. 334 @finished = @chunk_length.zero?
  51. 334 nextstate(:crlf)
  52. when :crlf
  53. 553 crlf_size = @finished && !@trailers ? 4 : 2
  54. # consume CRLF
  55. 553 return if @buffer.bytesize < crlf_size
  56. 553 raise Error, "wrong chunked encoding format" unless @buffer.start_with?(CRLF * (crlf_size / 2))
  57. 553 @buffer = @buffer.byteslice(crlf_size..-1)
  58. 553 if @chunk_length.nil?
  59. 219 nextstate(:length)
  60. else
  61. 334 return if @finished
  62. 227 nextstate(:data)
  63. end
  64. when :data
  65. 259 chunk = @buffer.byteslice(0, @chunk_length)
  66. 259 @buffer = @buffer.byteslice(@chunk_length..-1) || "".b
  67. 259 @chunk_buffer << chunk
  68. 259 @chunk_length -= chunk.bytesize
  69. 259 if @chunk_length.zero?
  70. 227 yield @chunk_buffer unless @chunk_buffer.empty?
  71. 219 @chunk_buffer.clear
  72. 219 @chunk_length = nil
  73. 219 nextstate(:crlf)
  74. end
  75. end
  76. 1031 break if @buffer.empty?
  77. end
  78. end
  79. 27 def finished?
  80. 148 @finished
  81. end
  82. 27 private
  83. 27 def nextstate(state)
  84. 999 @state = state
  85. end
  86. end
  87. 27 module_function
  88. 27 def encode(chunks)
  89. 96 Encoder.new(chunks)
  90. end
  91. end
  92. end

lib/httpx/transcoder/deflate.rb

100.0% lines covered

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

lib/httpx/transcoder/form.rb

100.0% lines covered

35 relevant lines. 35 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 27 require "forwardable"
  3. 27 require "uri"
  4. 27 require_relative "multipart"
  5. 27 module HTTPX
  6. 27 module Transcoder
  7. 27 module Form
  8. 27 module_function
  9. 27 PARAM_DEPTH_LIMIT = 32
  10. 27 class Encoder
  11. 27 extend Forwardable
  12. 27 def_delegator :@raw, :to_s
  13. 27 def_delegator :@raw, :to_str
  14. 27 def_delegator :@raw, :bytesize
  15. 27 def_delegator :@raw, :==
  16. 27 def initialize(form)
  17. 754 @raw = form.each_with_object("".b) do |(key, val), buf|
  18. 1282 HTTPX::Transcoder.normalize_keys(key, val) do |k, v|
  19. 1480 buf << "&" unless buf.empty?
  20. 1480 buf << URI.encode_www_form_component(k)
  21. 1480 buf << "=#{URI.encode_www_form_component(v.to_s)}" unless v.nil?
  22. end
  23. end
  24. end
  25. 27 def content_type
  26. 586 "application/x-www-form-urlencoded"
  27. end
  28. end
  29. 27 module Decoder
  30. 27 module_function
  31. 27 def call(response, *)
  32. 40 URI.decode_www_form(response.to_s).each_with_object({}) do |(field, value), params|
  33. 96 HTTPX::Transcoder.normalize_query(params, field, value, PARAM_DEPTH_LIMIT)
  34. end
  35. end
  36. end
  37. 27 def encode(form)
  38. 754 Encoder.new(form)
  39. end
  40. 27 def decode(response)
  41. 64 content_type = response.content_type.mime_type
  42. 64 case content_type
  43. when "application/x-www-form-urlencoded"
  44. 40 Decoder
  45. when "multipart/form-data"
  46. 16 Multipart::Decoder.new(response)
  47. else
  48. 8 raise Error, "invalid form mime type (#{content_type})"
  49. end
  50. end
  51. end
  52. end
  53. 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. 27 require "zlib"
  3. 27 module HTTPX
  4. 27 module Transcoder
  5. 27 module GZIP
  6. 27 class Deflater < Transcoder::Deflater
  7. 27 def initialize(body)
  8. 48 @compressed_chunk = "".b
  9. 48 super
  10. end
  11. 27 def deflate(chunk)
  12. 96 @deflater ||= Zlib::GzipWriter.new(self)
  13. 96 if chunk.nil?
  14. 48 unless @deflater.closed?
  15. 48 @deflater.flush
  16. 48 @deflater.close
  17. 48 compressed_chunk
  18. end
  19. else
  20. 48 @deflater.write(chunk)
  21. 48 compressed_chunk
  22. end
  23. end
  24. 27 private
  25. 27 def write(chunk)
  26. 144 @compressed_chunk << chunk
  27. end
  28. 27 def compressed_chunk
  29. 96 @compressed_chunk.dup
  30. ensure
  31. 96 @compressed_chunk.clear
  32. end
  33. end
  34. 27 class Inflater
  35. 27 def initialize(bytesize)
  36. 183 @inflater = Zlib::Inflate.new(Zlib::MAX_WBITS + 32)
  37. 183 @bytesize = bytesize
  38. end
  39. 27 def call(chunk)
  40. 557 buffer = @inflater.inflate(chunk)
  41. 557 @bytesize -= chunk.bytesize
  42. 557 if @bytesize <= 0
  43. 119 buffer << @inflater.finish
  44. 119 @inflater.close
  45. end
  46. 557 buffer
  47. end
  48. end
  49. 27 module_function
  50. 27 def encode(body)
  51. 48 Deflater.new(body)
  52. end
  53. 27 def decode(response, bytesize: nil)
  54. 167 bytesize ||= response.headers.key?("content-length") ? response.headers["content-length"].to_i : Float::INFINITY
  55. 167 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. 27 require "forwardable"
  3. 27 module HTTPX::Transcoder
  4. 27 module JSON
  5. 27 module_function
  6. 27 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. 27 class Encoder
  20. 27 extend Forwardable
  21. 27 def_delegator :@raw, :to_s
  22. 27 def_delegator :@raw, :bytesize
  23. 27 def_delegator :@raw, :==
  24. 27 def initialize(json)
  25. 83 @raw = JSON.json_dump(json)
  26. 83 @charset = @raw.encoding.name.downcase
  27. end
  28. 27 def content_type
  29. 83 "application/json; charset=#{@charset}"
  30. end
  31. end
  32. 27 def encode(json)
  33. 83 Encoder.new(json)
  34. end
  35. 27 def decode(response)
  36. 161 content_type = response.content_type.mime_type
  37. 161 raise HTTPX::Error, "invalid json mime type (#{content_type})" unless JSON_REGEX.match?(content_type)
  38. 145 method(:json_load)
  39. end
  40. # rubocop:disable Style/SingleLineMethods
  41. 27 if defined?(MultiJson)
  42. 4 def json_load(*args); MultiJson.load(*args); end
  43. 2 def json_dump(*args); MultiJson.dump(*args); end
  44. 24 elsif defined?(Oj)
  45. 5 def json_load(response, *args); Oj.load(response.to_s, *args); end
  46. 3 def json_dump(obj, options = {}); Oj.dump(obj, { mode: :compat }.merge(options)); end
  47. 22 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. 23 require "json"
  52. 159 def json_load(*args); ::JSON.parse(*args); end
  53. 103 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

18 relevant lines. 18 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 27 require_relative "multipart/encoder"
  3. 27 require_relative "multipart/decoder"
  4. 27 require_relative "multipart/part"
  5. 27 require_relative "multipart/mime_type_detector"
  6. 27 module HTTPX::Transcoder
  7. 27 module Multipart
  8. 27 MULTIPART_VALUE_COND = lambda do |value|
  9. 4730 value.respond_to?(:read) ||
  10. 3358 (value.respond_to?(:to_hash) &&
  11. value.key?(:body) &&
  12. 772 (value.key?(:filename) || value.key?(:content_type)))
  13. end
  14. 27 module_function
  15. 27 def multipart?(form_data)
  16. 1601 form_data.any? do |_, v|
  17. 2065 Multipart::MULTIPART_VALUE_COND.call(v) ||
  18. 1562 (v.respond_to?(:to_ary) && v.to_ary.any?(&Multipart::MULTIPART_VALUE_COND)) ||
  19. 1946 (v.respond_to?(:to_hash) && v.to_hash.any? { |_, e| Multipart::MULTIPART_VALUE_COND.call(e) })
  20. end
  21. end
  22. 27 def encode(form_data)
  23. 1015 Encoder.new(form_data)
  24. end
  25. end
  26. end

lib/httpx/transcoder/multipart/decoder.rb

93.9% lines covered

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

lib/httpx/transcoder/multipart/encoder.rb

100.0% lines covered

70 relevant lines. 70 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 27 module HTTPX
  3. 27 module Transcoder::Multipart
  4. 27 class Encoder
  5. 27 attr_reader :bytesize
  6. 27 def initialize(form)
  7. 1015 @boundary = ("-" * 21) << SecureRandom.hex(21)
  8. 1015 @part_index = 0
  9. 1015 @buffer = "".b
  10. 1015 @form = form
  11. 1015 @bytesize = 0
  12. 1015 @parts = to_parts(form)
  13. end
  14. 27 def content_type
  15. 1015 "multipart/form-data; boundary=#{@boundary}"
  16. end
  17. 27 def to_s
  18. 18 read || ""
  19. ensure
  20. 18 rewind
  21. end
  22. 27 def read(length = nil, outbuf = nil)
  23. 3774 data = String(outbuf).clear.force_encoding(Encoding::BINARY) if outbuf
  24. 3774 data ||= "".b
  25. 3774 read_chunks(data, length)
  26. 3774 data unless length && data.empty?
  27. end
  28. 27 def rewind
  29. 50 form = @form.each_with_object([]) do |(key, val), aux|
  30. 50 if val.respond_to?(:path) && val.respond_to?(:reopen) && val.respond_to?(:closed?) && val.closed?
  31. # @type var val: File
  32. 50 val = val.reopen(val.path, File::RDONLY)
  33. end
  34. 50 val.rewind if val.respond_to?(:rewind)
  35. 50 aux << [key, val]
  36. end
  37. 50 @form = form
  38. 50 @bytesize = 0
  39. 50 @parts = to_parts(form)
  40. 50 @part_index = 0
  41. end
  42. 27 private
  43. 27 def to_parts(form)
  44. 1065 params = form.each_with_object([]) do |(key, val), aux|
  45. 1257 Transcoder.normalize_keys(key, val, MULTIPART_VALUE_COND) do |k, v|
  46. 1257 next if v.nil?
  47. 1257 value, content_type, filename = Part.call(v)
  48. 1257 header = header_part(k, content_type, filename)
  49. 1257 @bytesize += header.size
  50. 1257 aux << header
  51. 1257 @bytesize += value.size
  52. 1257 aux << value
  53. 1257 delimiter = StringIO.new("\r\n")
  54. 1257 @bytesize += delimiter.size
  55. 1257 aux << delimiter
  56. end
  57. end
  58. 1065 final_delimiter = StringIO.new("--#{@boundary}--\r\n")
  59. 1065 @bytesize += final_delimiter.size
  60. 1065 params << final_delimiter
  61. 1065 params
  62. end
  63. 27 def header_part(key, content_type, filename)
  64. 1257 header = "--#{@boundary}\r\n".b
  65. 1257 header << "Content-Disposition: form-data; name=#{key.inspect}".b
  66. 1257 header << "; filename=#{filename.inspect}" if filename
  67. 1257 header << "\r\nContent-Type: #{content_type}\r\n\r\n"
  68. 1257 StringIO.new(header)
  69. end
  70. 27 def read_chunks(buffer, length = nil)
  71. 3774 while @part_index < @parts.size
  72. 11224 chunk = read_from_part(length)
  73. 11224 next unless chunk
  74. 6436 buffer << chunk.force_encoding(Encoding::BINARY)
  75. 6436 next unless length
  76. 6370 length -= chunk.bytesize
  77. 6370 break if length.zero?
  78. end
  79. end
  80. # if there's a current part to read from, tries to read a chunk.
  81. 27 def read_from_part(max_length = nil)
  82. 11224 part = @parts[@part_index]
  83. 11224 chunk = part.read(max_length, @buffer)
  84. 11224 return chunk if chunk && !chunk.empty?
  85. 4788 part.close if part.respond_to?(:close)
  86. 4788 @part_index += 1
  87. 1788 nil
  88. end
  89. end
  90. end
  91. 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. 27 module HTTPX
  3. 27 module Transcoder::Multipart
  4. 27 module MimeTypeDetector
  5. 27 module_function
  6. 27 DEFAULT_MIMETYPE = "application/octet-stream"
  7. # inspired by https://github.com/shrinerb/shrine/blob/master/lib/shrine/plugins/determine_mime_type.rb
  8. 27 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. 24 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. 23 elsif defined?(MimeMagic)
  24. 1 def call(file, _)
  25. 1 mime = MimeMagic.by_magic(file)
  26. 1 mime.type if mime
  27. end
  28. 22 elsif system("which file", out: File::NULL)
  29. 24 require "open3"
  30. 24 def call(file, _)
  31. 741 return if file.eof? # file command returns "application/x-empty" for empty files
  32. 697 Open3.popen3(*%w[file --mime-type --brief -]) do |stdin, stdout, stderr, thread|
  33. 166 begin
  34. 697 IO.copy_stream(file, stdin.binmode)
  35. rescue Errno::EPIPE
  36. end
  37. 697 file.rewind
  38. 697 stdin.close
  39. 697 status = thread.value
  40. # call to file command failed
  41. 697 if status.nil? || !status.success?
  42. $stderr.print(stderr.read)
  43. else
  44. 697 output = stdout.read.strip
  45. 697 if output.include?("cannot open")
  46. $stderr.print(output)
  47. else
  48. 697 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. 27 module HTTPX
  3. 27 module Transcoder::Multipart
  4. 27 module Part
  5. 27 module_function
  6. 27 def call(value)
  7. # take out specialized objects of the way
  8. 1257 if value.respond_to?(:filename) && value.respond_to?(:content_type) && value.respond_to?(:read)
  9. 128 return value, value.content_type, value.filename
  10. end
  11. 1129 content_type = filename = nil
  12. 1129 if value.is_a?(Hash)
  13. 386 content_type = value[:content_type]
  14. 386 filename = value[:filename]
  15. 386 value = value[:body]
  16. end
  17. 1129 value = value.open(File::RDONLY, encoding: Encoding::BINARY) if Object.const_defined?(:Pathname) && value.is_a?(Pathname)
  18. 1129 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. 745 filename ||= File.basename(value.path)
  21. 745 content_type ||= MimeTypeDetector.call(value, filename) || "application/octet-stream"
  22. 745 [value, content_type, filename]
  23. else
  24. 384 [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

95.83% lines covered

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

lib/httpx/transcoder/utils/deflater.rb

100.0% lines covered

36 relevant lines. 36 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 27 require_relative "body_reader"
  3. 27 module HTTPX
  4. 27 module Transcoder
  5. 27 class Deflater
  6. 27 attr_reader :content_type
  7. 27 def initialize(body)
  8. 84 @content_type = body.content_type
  9. 84 @body = BodyReader.new(body)
  10. 84 @closed = false
  11. end
  12. 27 def bytesize
  13. 324 buffer_deflate!
  14. 324 @buffer.size
  15. end
  16. 27 def read(length = nil, outbuf = nil)
  17. 410 return @buffer.read(length, outbuf) if @buffer
  18. 234 return if @closed
  19. 186 chunk = @body.read(length)
  20. 186 compressed_chunk = deflate(chunk)
  21. 186 return unless compressed_chunk
  22. 150 if outbuf
  23. 132 outbuf.replace(compressed_chunk)
  24. else
  25. 18 compressed_chunk
  26. end
  27. end
  28. 27 def close
  29. 48 return if @closed
  30. 48 @buffer.close if @buffer
  31. 48 @body.close
  32. 48 @closed = true
  33. end
  34. 27 def rewind
  35. 28 return unless @buffer
  36. 16 @buffer.rewind
  37. end
  38. 27 private
  39. # rubocop:disable Naming/MemoizedInstanceVariableName
  40. 27 def buffer_deflate!
  41. 324 return @buffer if defined?(@buffer)
  42. 84 buffer = Response::Buffer.new(
  43. threshold_size: Options::MAX_BODY_THRESHOLD_SIZE
  44. )
  45. 84 IO.copy_stream(self, buffer)
  46. 84 buffer.rewind if buffer.respond_to?(:rewind)
  47. 84 @buffer = buffer
  48. end
  49. # rubocop:enable Naming/MemoizedInstanceVariableName
  50. end
  51. end
  52. 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. 27 module HTTPX
  3. 27 module Utils
  4. 27 using URIExtensions
  5. 27 TOKEN = %r{[^\s()<>,;:\\"/\[\]?=]+}.freeze
  6. 27 VALUE = /"(?:\\"|[^"])*"|#{TOKEN}/.freeze
  7. 27 FILENAME_REGEX = /\s*filename=(#{VALUE})/.freeze
  8. 27 FILENAME_EXTENSION_REGEX = /\s*filename\*=(#{VALUE})/.freeze
  9. 27 module_function
  10. 27 def now
  11. 9187520 Process.clock_gettime(Process::CLOCK_MONOTONIC)
  12. end
  13. 27 def elapsed_time(monotonic_timestamp)
  14. 9160752 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. 27 def parse_retry_after(retry_after)
  19. # first: bet on it being an integer
  20. 63 Integer(retry_after)
  21. rescue ArgumentError
  22. # Then it's a datetime
  23. 16 time = Time.httpdate(retry_after)
  24. 16 time - Time.now
  25. end
  26. 27 def get_filename(header, _prefix_regex = nil)
  27. 88 filename = nil
  28. 88 case header
  29. when FILENAME_REGEX
  30. 56 filename = Regexp.last_match(1)
  31. 56 filename = Regexp.last_match(1) if filename =~ /^"(.*)"$/
  32. when FILENAME_EXTENSION_REGEX
  33. 16 filename = Regexp.last_match(1)
  34. 16 encoding, _, filename = filename.split("'", 3)
  35. end
  36. 88 return unless filename
  37. 136 filename = URI::DEFAULT_PARSER.unescape(filename) if filename.scan(/%.?.?/).all? { |s| /%[0-9a-fA-F]{2}/.match?(s) }
  38. 72 filename.scrub!
  39. 72 filename = filename.gsub(/\\(.)/, '\1') unless /\\[^\\"]/.match?(filename)
  40. 72 filename.force_encoding ::Encoding.find(encoding) if encoding
  41. 72 filename
  42. end
  43. 27 URIParser = URI::RFC2396_Parser.new
  44. 27 def to_uri(uri)
  45. 18925 return URI(uri) unless uri.is_a?(String) && !uri.ascii_only?
  46. 33 uri = URI(URIParser.escape(uri))
  47. 33 non_ascii_hostname = URIParser.unescape(uri.host)
  48. 33 non_ascii_hostname.force_encoding(Encoding::UTF_8)
  49. 33 idna_hostname = Punycode.encode_hostname(non_ascii_hostname)
  50. 33 uri.host = idna_hostname
  51. 32 uri.non_ascii_hostname = non_ascii_hostname
  52. 32 uri
  53. end
  54. end
  55. end