loading
Generated 2026-06-19T17:30:53+00:00

All Files ( 96.31% covered at 3815.41 hits/line )

115 files in total.
9090 relevant lines, 8755 lines covered and 335 lines missed. ( 96.31% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line
lib/httpx.rb 100.00 % 66 39 39 0 1689.90
lib/httpx/adapters/datadog.rb 84.34 % 334 166 140 26 35.77
lib/httpx/adapters/faraday.rb 100.00 % 293 157 157 0 158.11
lib/httpx/adapters/sentry.rb 100.00 % 121 62 62 0 51.55
lib/httpx/adapters/webmock.rb 100.00 % 183 95 95 0 140.91
lib/httpx/altsvc.rb 96.47 % 167 85 82 3 311.01
lib/httpx/buffer.rb 100.00 % 61 27 27 0 6678.33
lib/httpx/callbacks.rb 100.00 % 35 19 19 0 168150.84
lib/httpx/chainable.rb 95.65 % 106 46 44 2 1515.61
lib/httpx/connection.rb 95.27 % 1130 550 524 26 8722.04
lib/httpx/connection/http1.rb 89.83 % 422 236 212 24 5012.88
lib/httpx/connection/http2.rb 92.59 % 522 297 275 22 5094.73
lib/httpx/domain_name.rb 95.56 % 145 45 43 2 277.76
lib/httpx/errors.rb 97.78 % 118 45 44 1 137.89
lib/httpx/extensions.rb 95.24 % 45 21 20 1 698.10
lib/httpx/headers.rb 100.00 % 176 71 71 0 21545.99
lib/httpx/io.rb 100.00 % 11 5 5 0 30.00
lib/httpx/io/ssl.rb 97.73 % 175 88 86 2 3462.68
lib/httpx/io/tcp.rb 91.67 % 255 132 121 11 9059.18
lib/httpx/io/udp.rb 100.00 % 62 35 35 0 429.94
lib/httpx/io/unix.rb 97.30 % 75 37 36 1 22.76
lib/httpx/loggable.rb 100.00 % 68 29 29 0 61232.17
lib/httpx/options.rb 96.39 % 607 249 240 9 30801.22
lib/httpx/parser/http1.rb 100.00 % 202 116 116 0 10454.41
lib/httpx/plugins/auth.rb 98.08 % 215 104 102 2 383.66
lib/httpx/plugins/auth/basic.rb 100.00 % 20 10 10 0 133.40
lib/httpx/plugins/auth/digest.rb 100.00 % 149 88 88 0 204.81
lib/httpx/plugins/auth/ntlm.rb 100.00 % 35 19 19 0 10.32
lib/httpx/plugins/auth/socks5.rb 100.00 % 22 11 11 0 34.91
lib/httpx/plugins/aws_sdk_authentication.rb 100.00 % 110 44 44 0 23.00
lib/httpx/plugins/aws_sigv4.rb 100.00 % 239 122 122 0 116.42
lib/httpx/plugins/basic_auth.rb 100.00 % 29 12 12 0 50.25
lib/httpx/plugins/brotli.rb 100.00 % 78 41 41 0 23.34
lib/httpx/plugins/cache.rb 100.00 % 221 94 94 0 210.65
lib/httpx/plugins/cache/file_store.rb 100.00 % 141 73 73 0 196.41
lib/httpx/plugins/cache/store.rb 100.00 % 33 16 16 0 254.56
lib/httpx/plugins/callbacks.rb 92.65 % 141 68 63 5 139.34
lib/httpx/plugins/circuit_breaker.rb 100.00 % 147 67 67 0 87.97
lib/httpx/plugins/circuit_breaker/circuit.rb 100.00 % 101 48 48 0 68.21
lib/httpx/plugins/circuit_breaker/circuit_store.rb 100.00 % 53 23 23 0 116.30
lib/httpx/plugins/content_digest.rb 100.00 % 204 103 103 0 87.39
lib/httpx/plugins/cookies.rb 100.00 % 111 54 54 0 149.06
lib/httpx/plugins/cookies/cookie.rb 97.65 % 197 85 83 2 340.27
lib/httpx/plugins/cookies/jar.rb 100.00 % 170 73 73 0 299.64
lib/httpx/plugins/cookies/set_cookie_parser.rb 100.00 % 143 72 72 0 179.13
lib/httpx/plugins/digest_auth.rb 100.00 % 68 31 31 0 132.90
lib/httpx/plugins/expect.rb 100.00 % 150 71 71 0 80.06
lib/httpx/plugins/fiber_concurrency.rb 88.89 % 255 117 104 13 716.94
lib/httpx/plugins/follow_redirects.rb 100.00 % 251 117 117 0 294.94
lib/httpx/plugins/grpc.rb 100.00 % 282 134 134 0 146.63
lib/httpx/plugins/grpc/call.rb 90.91 % 63 33 30 3 52.82
lib/httpx/plugins/grpc/grpc_encoding.rb 97.87 % 90 47 46 1 94.43
lib/httpx/plugins/grpc/message.rb 95.83 % 55 24 23 1 51.33
lib/httpx/plugins/h2c.rb 94.74 % 117 57 54 3 23.63
lib/httpx/plugins/ntlm_auth.rb 100.00 % 64 33 33 0 9.76
lib/httpx/plugins/ntlm_v2_auth.rb 96.00 % 92 50 48 2 21.36
lib/httpx/plugins/oauth.rb 91.36 % 334 162 148 14 92.07
lib/httpx/plugins/persistent.rb 100.00 % 80 30 30 0 564.60
lib/httpx/plugins/proxy.rb 94.94 % 364 178 169 9 369.23
lib/httpx/plugins/proxy/http.rb 94.87 % 215 117 111 6 144.96
lib/httpx/plugins/proxy/socks4.rb 97.47 % 135 79 77 2 221.89
lib/httpx/plugins/proxy/socks5.rb 99.12 % 194 113 112 1 359.09
lib/httpx/plugins/proxy/ssh.rb 92.45 % 94 53 49 4 12.64
lib/httpx/plugins/push_promise.rb 100.00 % 81 41 41 0 16.59
lib/httpx/plugins/query.rb 100.00 % 35 15 15 0 21.60
lib/httpx/plugins/rate_limiter.rb 100.00 % 60 21 21 0 50.24
lib/httpx/plugins/response_cache.rb 99.12 % 268 113 112 1 230.91
lib/httpx/plugins/retries.rb 95.80 % 282 119 114 5 552.50
lib/httpx/plugins/server_sent_events.rb 97.37 % 158 76 74 2 72.88
lib/httpx/plugins/ssrf_filter.rb 100.00 % 161 69 69 0 286.62
lib/httpx/plugins/stream.rb 97.37 % 238 114 111 3 272.75
lib/httpx/plugins/stream_bidi.rb 93.99 % 408 183 172 11 279.51
lib/httpx/plugins/tracing.rb 95.00 % 143 60 57 3 155.92
lib/httpx/plugins/upgrade.rb 100.00 % 86 38 38 0 46.71
lib/httpx/plugins/upgrade/h2.rb 95.65 % 54 23 22 1 56.65
lib/httpx/plugins/webdav.rb 100.00 % 86 39 39 0 30.67
lib/httpx/plugins/xml.rb 100.00 % 76 34 34 0 103.21
lib/httpx/pmatch_extensions.rb 100.00 % 33 17 17 0 32.12
lib/httpx/pool.rb 100.00 % 231 110 110 0 6289.02
lib/httpx/punycode.rb 100.00 % 22 9 9 0 20.89
lib/httpx/request.rb 100.00 % 369 157 157 0 8701.22
lib/httpx/request/body.rb 100.00 % 158 68 68 0 4374.06
lib/httpx/resolver.rb 91.67 % 111 60 55 5 585.08
lib/httpx/resolver/cache.rb 100.00 % 18 4 4 0 30.00
lib/httpx/resolver/cache/base.rb 98.41 % 136 63 62 1 4437.51
lib/httpx/resolver/cache/file.rb 100.00 % 56 30 30 0 1248.20
lib/httpx/resolver/cache/memory.rb 100.00 % 42 22 22 0 3394.23
lib/httpx/resolver/entry.rb 100.00 % 30 16 16 0 6178.00
lib/httpx/resolver/https.rb 84.49 % 326 187 158 29 62.35
lib/httpx/resolver/multi.rb 100.00 % 84 45 45 0 6817.56
lib/httpx/resolver/native.rb 90.24 % 592 338 305 33 891.29
lib/httpx/resolver/resolver.rb 91.18 % 209 102 93 9 1748.69
lib/httpx/resolver/system.rb 95.45 % 281 154 147 7 113.12
lib/httpx/response.rb 99.24 % 332 131 130 1 3098.92
lib/httpx/response/body.rb 100.00 % 246 110 110 0 3704.52
lib/httpx/response/buffer.rb 96.72 % 115 61 59 2 2022.61
lib/httpx/selector.rb 93.92 % 322 148 139 9 9752.06
lib/httpx/session.rb 99.32 % 596 296 294 2 6463.93
lib/httpx/session_extensions.rb 100.00 % 30 15 15 0 6.87
lib/httpx/timers.rb 93.94 % 131 66 62 4 10504.09
lib/httpx/transcoder.rb 100.00 % 89 50 50 0 244.62
lib/httpx/transcoder/body.rb 100.00 % 43 26 26 0 1149.62
lib/httpx/transcoder/chunker.rb 100.00 % 115 66 66 0 298.17
lib/httpx/transcoder/deflate.rb 100.00 % 42 21 21 0 33.71
lib/httpx/transcoder/form.rb 100.00 % 68 35 35 0 288.00
lib/httpx/transcoder/gzip.rb 100.00 % 76 44 44 0 120.52
lib/httpx/transcoder/json.rb 100.00 % 71 33 33 0 59.48
lib/httpx/transcoder/multipart.rb 100.00 % 39 22 22 0 1101.32
lib/httpx/transcoder/multipart/decoder.rb 94.05 % 141 84 79 5 32.54
lib/httpx/transcoder/multipart/encoder.rb 100.00 % 120 70 70 0 2330.80
lib/httpx/transcoder/multipart/mime_type_detector.rb 92.11 % 78 38 35 3 218.71
lib/httpx/transcoder/multipart/part.rb 100.00 % 35 18 18 0 612.67
lib/httpx/transcoder/utils/body_reader.rb 95.83 % 45 24 23 1 120.63
lib/httpx/transcoder/utils/deflater.rb 100.00 % 74 36 36 0 118.86
lib/httpx/utils.rb 100.00 % 88 44 44 0 2707.41

lib/httpx.rb

100.0% lines covered

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

lib/httpx/adapters/datadog.rb

84.34% lines covered

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

lib/httpx/adapters/faraday.rb

100.0% lines covered

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

lib/httpx/adapters/webmock.rb

100.0% lines covered

95 relevant lines. 95 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 9 module WebMock
  3. 9 module HttpLibAdapters
  4. 9 require "net/http/status"
  5. 9 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. 9 module Plugin
  12. 9 class << self
  13. 9 def build_webmock_request_signature(request)
  14. 291 uri = WebMock::Util::URI.heuristic_parse(request.uri)
  15. 291 uri.query = request.query
  16. 291 uri.path = uri.normalized_path.gsub("[^:]//", "/")
  17. 291 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. 9 def build_webmock_response(_request, response)
  25. 7 webmock_response = WebMock::Response.new
  26. 7 webmock_response.status = [response.status, HTTP_REASONS[response.status]]
  27. 7 webmock_response.body = response.body.to_s
  28. 7 webmock_response.headers = response.headers.to_h
  29. 7 webmock_response
  30. end
  31. 9 def build_from_webmock_response(request, webmock_response)
  32. 256 return build_error_response(request, HTTPX::TimeoutError.new(1, "Timed out")) if webmock_response.should_timeout
  33. 235 return build_error_response(request, webmock_response.exception) if webmock_response.exception
  34. 227 request
  35. .options
  36. .response_class
  37. .new(
  38. request,
  39. webmock_response.status[0],
  40. "2.0",
  41. webmock_response.headers
  42. ).tap(&:mock!)
  43. end
  44. 9 def build_error_response(request, exception)
  45. 29 HTTPX::ErrorResponse.new(request, exception)
  46. end
  47. end
  48. 9 module InstanceMethods
  49. 9 private
  50. 9 def do_init_connection(connection, selector)
  51. 256 super
  52. 256 connection.once(:unmock_connection) do
  53. 28 next unless connection.current_session == self
  54. 28 unless connection.addresses?
  55. # reset Happy Eyeballs, fail early
  56. 28 connection.sibling = nil
  57. 28 deselect_connection(connection, selector)
  58. end
  59. 28 resolve_connection(connection, selector)
  60. end
  61. end
  62. end
  63. 9 module ResponseMethods
  64. 9 def initialize(*)
  65. 255 super
  66. 255 @mocked = false
  67. end
  68. 9 def mock!
  69. 227 @mocked = true
  70. 227 @body.mock!
  71. end
  72. 9 def mocked?
  73. 56 @mocked
  74. end
  75. end
  76. 9 module ResponseBodyMethods
  77. 9 def mock!
  78. 227 @inflaters = nil
  79. end
  80. end
  81. 9 module ConnectionMethods
  82. 9 def initialize(*)
  83. 256 super
  84. 256 @mocked = true
  85. end
  86. 9 def open?
  87. 284 return true if @mocked
  88. 28 super
  89. end
  90. 9 def interests
  91. 302 return if @mocked
  92. 288 super
  93. end
  94. 9 def terminate
  95. 227 force_reset
  96. end
  97. 9 def send(request)
  98. 291 request_signature = Plugin.build_webmock_request_signature(request)
  99. 291 WebMock::RequestRegistry.instance.requested_signatures.put(request_signature)
  100. 291 if (mock_response = WebMock::StubRegistry.instance.response_for_request(request_signature))
  101. 256 response = Plugin.build_from_webmock_response(request, mock_response)
  102. 256 WebMock::CallbackRegistry.invoke_callbacks({ lib: :httpx }, request_signature, mock_response)
  103. 256 log { "mocking #{request.uri} with #{mock_response.inspect}" }
  104. 256 request.transition(:headers)
  105. 256 request.transition(:body)
  106. 256 request.transition(:trailers)
  107. 256 request.transition(:done)
  108. 256 response.finish!
  109. 256 request.response = response
  110. 256 request.emit_response(response)
  111. 256 request_signature.headers = request.headers.to_h
  112. 256 response << mock_response.body.dup unless response.is_a?(HTTPX::ErrorResponse)
  113. 35 elsif WebMock.net_connect_allowed?(request_signature.uri)
  114. 28 if WebMock::CallbackRegistry.any_callbacks?
  115. 7 request.on(:response) do |resp|
  116. 7 unless resp.is_a?(HTTPX::ErrorResponse)
  117. 7 webmock_response = Plugin.build_webmock_response(request, resp)
  118. 7 WebMock::CallbackRegistry.invoke_callbacks(
  119. { lib: :httpx, real_request: true }, request_signature,
  120. webmock_response
  121. )
  122. end
  123. end
  124. end
  125. 28 @mocked = false
  126. 28 emit(:unmock_connection, self)
  127. 28 super
  128. else
  129. 7 raise WebMock::NetConnectNotAllowedError, request_signature
  130. end
  131. end
  132. end
  133. end
  134. 9 class HttpxAdapter < HttpLibAdapter
  135. 9 adapter_for :httpx
  136. 9 class << self
  137. 9 def enable!
  138. 503 @original_session ||= HTTPX::Session
  139. 503 webmock_session = HTTPX.plugin(Plugin)
  140. 503 HTTPX.send(:remove_const, :Session)
  141. 503 HTTPX.send(:const_set, :Session, webmock_session.class)
  142. end
  143. 9 def disable!
  144. 503 return unless @original_session
  145. 494 HTTPX.send(:remove_const, :Session)
  146. 494 HTTPX.send(:const_set, :Session, @original_session)
  147. end
  148. end
  149. end
  150. end
  151. end

lib/httpx/altsvc.rb

96.47% lines covered

85 relevant lines. 82 lines covered and 3 lines missed.
    
  1. # frozen_string_literal: true
  2. 30 require "strscan"
  3. 30 module HTTPX
  4. 30 module AltSvc
  5. # makes connections able to accept requests destined to primary service.
  6. 30 module ConnectionMixin
  7. 30 using URIExtensions
  8. 30 H2_ALTSVC_SCHEMES = %w[https h2].freeze
  9. 30 ALTSVC_IGNORE_IVARS = %i[@ssl].freeze
  10. 30 def send(request)
  11. 9 request.headers["alt-used"] = @origin.authority if @parser && !@write_buffer.full? && match_altsvcs?(request.uri)
  12. 9 super
  13. end
  14. 30 def match?(uri, options)
  15. 9 return false if !used? && (@state == :closing || @state == :closed)
  16. 9 match_altsvcs?(uri) && match_altsvc_options?(uri, options)
  17. end
  18. 30 private
  19. # checks if this is connection is an alternative service of
  20. # +uri+
  21. 30 def match_altsvcs?(uri)
  22. 51 @origins.any? { |origin| altsvc_match?(uri, origin) } ||
  23. AltSvc.cached_altsvc(@origin).any? do |altsvc|
  24. origin = altsvc["origin"]
  25. altsvc_match?(origin, uri.origin)
  26. end
  27. end
  28. 30 def match_altsvc_options?(uri, options)
  29. 9 return @options.connection_options_match?(options) unless @options.ssl.all? do |k, v|
  30. 9 v == (k == :hostname ? uri.host : options.ssl[k])
  31. end
  32. 9 @options.connection_options_match?(options, ALTSVC_IGNORE_IVARS)
  33. end
  34. 30 def altsvc_match?(uri, other_uri)
  35. 34 other_uri = URI(other_uri) #: http_uri
  36. 34 uri.origin == other_uri.origin || begin
  37. 15 case uri.scheme
  38. when "h2"
  39. H2_ALTSVC_SCHEMES.include?(other_uri.scheme) &&
  40. uri.host == other_uri.host &&
  41. uri.port == other_uri.port
  42. else
  43. 17 false
  44. end
  45. end
  46. end
  47. end
  48. 30 @altsvc_mutex = Thread::Mutex.new
  49. 54 @altsvcs = Hash.new { |h, k| h[k] = [] }
  50. 30 module_function
  51. 30 def cached_altsvc(origin)
  52. 45 now = Utils.now
  53. 45 @altsvc_mutex.synchronize do
  54. 45 lookup(origin, now)
  55. end
  56. end
  57. 30 def cached_altsvc_set(origin, entry)
  58. 27 now = Utils.now
  59. 27 @altsvc_mutex.synchronize do
  60. 27 return if @altsvcs[origin].any? { |altsvc| altsvc["origin"] == entry["origin"] }
  61. 27 entry["TTL"] = Integer(entry["ma"]) + now if entry.key?("ma")
  62. 27 @altsvcs[origin] << entry
  63. 27 entry
  64. end
  65. end
  66. 30 def lookup(origin, ttl)
  67. 45 return [] unless @altsvcs.key?(origin)
  68. 32 @altsvcs[origin] = @altsvcs[origin].select do |entry|
  69. 27 !entry.key?("TTL") || entry["TTL"] > ttl
  70. end
  71. 54 @altsvcs[origin].reject { |entry| entry["noop"] }
  72. end
  73. 30 def emit(request, response)
  74. 10442 return unless response.respond_to?(:headers)
  75. # Alt-Svc
  76. 10380 return unless response.headers.key?("alt-svc")
  77. 120 origin = request.origin
  78. 120 host = request.uri.host
  79. 120 altsvc = response.headers["alt-svc"]
  80. # https://datatracker.ietf.org/doc/html/rfc7838#section-3
  81. # A field value containing the special value "clear" indicates that the
  82. # origin requests all alternatives for that origin to be invalidated
  83. # (including those specified in the same response, in case of an
  84. # invalid reply containing both "clear" and alternative services).
  85. 120 if altsvc == "clear"
  86. 9 @altsvc_mutex.synchronize do
  87. 9 @altsvcs[origin].clear
  88. end
  89. 8 return
  90. end
  91. 111 parse(altsvc) do |alt_origin, alt_params|
  92. 18 alt_origin.host ||= host
  93. 18 yield(alt_origin, origin, alt_params)
  94. end
  95. end
  96. 30 def parse(altsvc)
  97. 237 return enum_for(__method__, altsvc) unless block_given?
  98. 174 scanner = StringScanner.new(altsvc)
  99. 181 until scanner.eos?
  100. 174 alt_service = scanner.scan(/[^=]+=("[^"]+"|[^;,]+)/)
  101. 174 alt_params = []
  102. 174 loop do
  103. 201 alt_param = scanner.scan(/[^=]+=("[^"]+"|[^;,]+)/)
  104. 201 alt_params << alt_param.strip if alt_param
  105. 201 scanner.skip(/;/)
  106. 201 break if scanner.eos? || scanner.scan(/ *, */)
  107. end
  108. 339 alt_params = Hash[alt_params.map { |field| field.split("=", 2) }]
  109. 174 alt_proto, alt_authority = alt_service.split("=", 2)
  110. 174 alt_origin = parse_altsvc_origin(alt_proto, alt_authority)
  111. 174 return unless alt_origin
  112. 63 yield(alt_origin, alt_params.merge("proto" => alt_proto))
  113. end
  114. end
  115. 30 def parse_altsvc_scheme(alt_proto)
  116. 181 case alt_proto
  117. when "h2c"
  118. 9 "http"
  119. when "h2"
  120. 72 "https"
  121. end
  122. end
  123. 30 def parse_altsvc_origin(alt_proto, alt_origin)
  124. 174 alt_scheme = parse_altsvc_scheme(alt_proto)
  125. 174 return unless alt_scheme
  126. 63 alt_origin = alt_origin[1..-2] if alt_origin.start_with?("\"") && alt_origin.end_with?("\"")
  127. 63 URI.parse("#{alt_scheme}://#{alt_origin}")
  128. end
  129. end
  130. 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. 30 require "forwardable"
  3. 30 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. 30 class Buffer
  13. 30 extend Forwardable
  14. 30 def_delegator :@buffer, :to_s
  15. 30 def_delegator :@buffer, :to_str
  16. 30 def_delegator :@buffer, :empty?
  17. 30 def_delegator :@buffer, :bytesize
  18. 30 def_delegator :@buffer, :clear
  19. 30 def_delegator :@buffer, :replace
  20. 30 attr_reader :limit
  21. 30 if RUBY_VERSION >= "3.4.0"
  22. 18 def initialize(limit)
  23. 6773 @buffer = String.new("", encoding: Encoding::BINARY, capacity: limit)
  24. 6773 @limit = limit
  25. end
  26. 18 def <<(chunk)
  27. 23476 @buffer.append_as_bytes(chunk)
  28. end
  29. else
  30. 12 def initialize(limit)
  31. 22281 @buffer = "".b
  32. 22281 @limit = limit
  33. end
  34. 12 def_delegator :@buffer, :<<
  35. end
  36. 30 def full?
  37. 72311 @buffer.bytesize >= @limit
  38. end
  39. 30 def capacity
  40. 14 @limit - @buffer.bytesize
  41. end
  42. 30 def shift!(fin)
  43. 25896 @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. 30 module HTTPX
  3. 30 module Callbacks
  4. 30 def on(type, &action)
  5. 319883 callbacks(type) << action
  6. 319883 action
  7. end
  8. 30 def once(type, &block)
  9. 164815 on(type) do |*args, &callback|
  10. 88242 block.call(*args, &callback)
  11. 88170 :delete
  12. end
  13. end
  14. 30 def emit(type, *args)
  15. 161650 log { "emit #{type.inspect} callbacks" } if respond_to?(:log)
  16. 283226 callbacks(type).delete_if { |pr| :delete == pr.call(*args) } # rubocop:disable Style/YodaCondition
  17. end
  18. 30 def callbacks_for?(type)
  19. 3897 @callbacks && @callbacks.key?(type) && @callbacks[type].any?
  20. end
  21. 30 protected
  22. 30 def callbacks(type = nil)
  23. 509889 return @callbacks unless type
  24. 745189 @callbacks ||= Hash.new { |h, k| h[k] = [] }
  25. 509782 @callbacks[type]
  26. end
  27. end
  28. end

lib/httpx/chainable.rb

95.65% lines covered

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

lib/httpx/connection.rb

95.27% lines covered

550 relevant lines. 524 lines covered and 26 lines missed.
    
  1. # frozen_string_literal: true
  2. 30 require "resolv"
  3. 30 require "forwardable"
  4. 30 require "httpx/io"
  5. 30 require "httpx/buffer"
  6. 30 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. 30 class Connection
  29. 30 extend Forwardable
  30. 30 include Loggable
  31. 30 include Callbacks
  32. 30 using URIExtensions
  33. 30 def_delegator :@write_buffer, :empty?
  34. 30 attr_reader :type, :io, :origin, :origins, :state, :pending, :options, :ssl_session, :sibling
  35. 30 attr_writer :current_selector
  36. 30 attr_accessor :current_session, :family
  37. 30 protected :ssl_session, :sibling
  38. 30 def initialize(uri, options)
  39. 1904 @current_session = @current_selector = @max_concurrent_requests =
  40. @parser = @sibling = @coalesced_connection = @altsvc_connection =
  41. @ping_timer = @family = @io = @ssl_session =
  42. 8060 @timeout = @connected_at = @response_received_at = nil
  43. 9964 @exhausted = @cloned = @main_sibling = false
  44. 9964 @options = Options.new(options)
  45. 9964 @type = initialize_type(uri, @options)
  46. 9964 @origins = [uri.origin]
  47. 9964 @origin = Utils.to_uri(uri.origin)
  48. 9964 @window_size = @options.window_size
  49. 9964 @read_buffer = Buffer.new(@options.buffer_size)
  50. 9964 @write_buffer = Buffer.new(@options.buffer_size)
  51. 9964 @pending = []
  52. 9964 @inflight = 0
  53. 9964 @keep_alive_timeout = @options.timeout[:keep_alive_timeout]
  54. 9964 @no_more_requests_counter = 0
  55. 9964 if @options.io
  56. # if there's an already open IO, get its
  57. # peer address, and force-initiate the parser
  58. 73 transition(:already_open)
  59. 73 @io = build_socket
  60. 73 parser
  61. else
  62. 9891 transition(:idle)
  63. end
  64. 9964 self.addresses = @options.addresses if @options.addresses
  65. end
  66. 30 def peer
  67. 20871 @origin
  68. end
  69. # this is a semi-private method, to be used by the resolver
  70. # to initiate the io object.
  71. 30 def addresses=(addrs)
  72. 9749 if @io
  73. 583 @io.add_addresses(addrs)
  74. else
  75. 9166 @io = build_socket(addrs)
  76. end
  77. end
  78. 30 def addresses
  79. 19289 @io && @io.addresses
  80. end
  81. 30 def addresses?
  82. 11103 @io && @io.addresses?
  83. end
  84. 30 def match?(uri, options)
  85. 3423 return false if !used? && (@state == :closing || @state == :closed)
  86. 3259 @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. 3025 (@origins.size == 1 || @origin == uri.origin || (@io.is_a?(SSL) && @io.verify_hostname(uri.host))) &&
  92. @options.connection_options_match?(options)
  93. end
  94. 30 def mergeable?(connection)
  95. 512 return false if @state == :closing || @state == :closed || !@io
  96. 130 return false unless connection.addresses
  97. 4 (
  98. 130 (open? && @origin == connection.origin) ||
  99. 130 !(@io.addresses & (connection.addresses || [])).empty?
  100. ) && @options.connection_options_match?(connection.options)
  101. end
  102. # coalesces +self+ into +connection+.
  103. 30 def coalesce!(connection)
  104. 29 @coalesced_connection = connection
  105. 29 close_sibling
  106. 29 connection.merge(self)
  107. end
  108. 30 def coalesced?
  109. 11078 @coalesced_connection
  110. end
  111. # coalescable connections need to be mergeable!
  112. # but internally, #mergeable? is called before #coalescable?
  113. 30 def coalescable?(connection)
  114. 57 if @io.protocol == "h2" &&
  115. @origin.scheme == "https" &&
  116. connection.origin.scheme == "https" &&
  117. @io.can_verify_peer?
  118. 29 @io.verify_hostname(connection.origin.host)
  119. else
  120. 28 @origin == connection.origin
  121. end
  122. end
  123. 30 def merge(connection)
  124. 71 @origins |= connection.instance_variable_get(:@origins)
  125. 75 if @ssl_session.nil? && connection.ssl_session
  126. 24 @ssl_session = connection.ssl_session
  127. 3 @io.session_new_cb do |sess|
  128. 48 @ssl_session = sess
  129. 24 end if @io
  130. end
  131. 75 connection.purge_pending do |req|
  132. 36 req.transition(:idle)
  133. 36 send(req)
  134. 36 true
  135. end
  136. end
  137. 30 def purge_pending(&block)
  138. 75 pendings = []
  139. 75 if @parser
  140. 36 pending = @parser.pending
  141. 32 @inflight -= pending.size
  142. 36 pendings << pending
  143. end
  144. 75 pendings << @pending
  145. 75 pendings.each do |pending|
  146. 111 pending.reject!(&block)
  147. end
  148. end
  149. 30 def io_connected?
  150. 14 return @coalesced_connection.io_connected? if @coalesced_connection
  151. 14 @io && @io.state == :connected
  152. end
  153. 30 def connecting?
  154. 233677 @state == :idle
  155. end
  156. 30 def inflight?
  157. 3943 @parser && (
  158. # parser may be dealing with other requests (possibly started from a different fiber)
  159. 3320 !@parser.empty? ||
  160. # connection may be doing connection termination handshake
  161. !@write_buffer.empty?
  162. )
  163. end
  164. 30 def interests
  165. # connecting
  166. 192026 if connecting?
  167. 16006 connect
  168. 16004 return @io.interests if connecting?
  169. end
  170. 176832 return @parser.interests if @parser
  171. 81 nil
  172. rescue StandardError => e
  173. on_error(e)
  174. nil
  175. end
  176. 30 def to_io
  177. 37002 @io.to_io
  178. end
  179. 30 def call
  180. 31696 case @state
  181. when :idle
  182. 14911 connect
  183. # when opening the tcp or ssl socket fails
  184. 14895 return if @state == :closed
  185. 14868 consume
  186. when :closed
  187. 14 return if @pending.empty?
  188. # there are pending requests to send, restart the state machine.
  189. 14 idling
  190. # @fiber-switch-guard
  191. # fiber may have switch after ensuring that @io is closed.
  192. 14 return unless @state == :idle
  193. 14 call
  194. when :closing
  195. 14 consume
  196. 14 transition(:closed)
  197. # @fiber-switch-guard
  198. # fiber may have switch while closing @io.
  199. 14 return if @state == :closed &&
  200. # only remain here if there are pending requests.
  201. @pending.empty?
  202. 14 call
  203. when :open
  204. 19091 consume
  205. end
  206. 15026 nil
  207. rescue IOError => e
  208. @write_buffer.clear
  209. on_io_error(e)
  210. rescue StandardError => e
  211. 136 @write_buffer.clear
  212. 136 on_error(e)
  213. rescue Exception => e # rubocop:disable Lint/RescueException
  214. 115 force_close(true)
  215. 106 raise e
  216. end
  217. 30 def close
  218. 3675 transition(:active) if @state == :inactive
  219. 3675 @parser.close if @parser
  220. end
  221. 30 def terminate
  222. 3248 case @state
  223. when :idle
  224. 18 purge_after_closed
  225. # @fiber-switch-guard
  226. 18 if @io.can_disconnect? && @pending.empty?
  227. disconnect
  228. return
  229. end
  230. when :closed
  231. 33 @connected_at = nil
  232. end
  233. 3630 close
  234. end
  235. # bypasses state machine rules while setting the connection in the
  236. # :closed state.
  237. 30 def force_close(delete_pending = false)
  238. 488 force_purge
  239. 488 return unless @state == :closed
  240. 488 if delete_pending
  241. 283 @pending.clear
  242. 205 elsif (parser = @parser)
  243. enqueue_pending_requests_from_parser(parser)
  244. end
  245. 488 return unless @pending.empty?
  246. 488 disconnect
  247. 479 emit(:force_closed, delete_pending)
  248. end
  249. # bypasses the state machine to force closing of connections still connecting.
  250. # **only** used for Happy Eyeballs v2.
  251. 30 def force_reset(cloned = false)
  252. 241 @state = :closing
  253. 241 @cloned = cloned
  254. 241 transition(:closed)
  255. end
  256. 30 def reset
  257. 10695 return if @state == :closing || @state == :closed
  258. # do not reset a connection which may have restarted back to :idle, such when the parser resets
  259. # (example: HTTP/1 parser disabling pipelining)
  260. 10652 return if @state == :idle && @pending.any?
  261. 10631 if @ping_timer
  262. 26 @ping_timer.cancel
  263. 26 @ping_timer = nil
  264. end
  265. 10631 parser = @parser
  266. 10631 if parser && parser.respond_to?(:max_concurrent_requests)
  267. # if connection being reset has at some downgraded the number of concurrent
  268. # requests, such as in the case where an attempt to use HTTP/1 pipelining failed,
  269. # keep that information around.
  270. 5810 @max_concurrent_requests = parser.max_concurrent_requests
  271. end
  272. 10631 transition(:closing)
  273. 10631 transition(:closed)
  274. end
  275. 30 def send(request)
  276. 12523 return @coalesced_connection.send(request) if @coalesced_connection
  277. 12502 if @parser && !@write_buffer.full?
  278. 677 if @response_received_at && @keep_alive_timeout &&
  279. Utils.elapsed_time(@response_received_at) > @keep_alive_timeout
  280. # when pushing a request into an existing connection, we have to check whether there
  281. # is the possibility that the connection might have extended the keep alive timeout.
  282. # for such cases, we want to ping for availability before deciding to shovel requests.
  283. 85 log(level: 3) { "keep alive timeout expired, pinging connection..." }
  284. 85 @pending << request
  285. 85 transition(:active) if @state == :inactive
  286. 85 request.ping!
  287. 85 ping(request)
  288. 76 return
  289. end
  290. 592 send_request_to_parser(request)
  291. else
  292. 11825 @pending << request
  293. end
  294. end
  295. 30 def timeout
  296. 36052 return if @state == :closed || @state == :inactive
  297. 36050 return @timeout if @timeout
  298. 13577 return @options.timeout[:connect_timeout] if @state == :idle
  299. 13577 @options.timeout[:operation_timeout]
  300. end
  301. 30 def idling
  302. 1540 purge_after_closed
  303. 1540 return unless @state == :closed
  304. 1540 @write_buffer.clear
  305. 1540 transition(:idle)
  306. 1540 return unless @parser
  307. 1540 enqueue_pending_requests_from_parser(parser)
  308. 1540 @parser = nil
  309. end
  310. 30 def used?
  311. 7491 @connected_at
  312. end
  313. 30 def deactivate
  314. 592 transition(:inactive)
  315. end
  316. 30 def open?
  317. 9730 @state == :open || @state == :inactive
  318. end
  319. 30 def handle_socket_timeout(interval)
  320. 50 error = OperationTimeoutError.new(interval, "timed out while waiting on select")
  321. 50 error.set_backtrace(caller)
  322. 50 on_error(error)
  323. end
  324. 30 def sibling=(connection)
  325. 112 @sibling = connection
  326. 112 return unless connection
  327. 84 @main_sibling = connection.sibling.nil?
  328. 84 return unless @main_sibling
  329. 42 connection.sibling = self
  330. end
  331. 30 def handle_connect_error(error)
  332. 416 return on_error(error) unless @sibling && @sibling.connecting?
  333. 7 @sibling.merge(self)
  334. 7 force_reset(true)
  335. end
  336. # disconnects from the current session it's attached to
  337. 30 def disconnect
  338. 11428 return if @exhausted # it'll reset
  339. 11428 return unless (current_session = @current_session) && (current_selector = @current_selector)
  340. 11359 @current_session = @current_selector = nil
  341. 11359 current_session.deselect_connection(self, current_selector, @cloned)
  342. end
  343. 30 def on_connect_error(e)
  344. # connect errors, exit gracefully
  345. 111 error = ConnectionError.new(e.message)
  346. 111 error.set_backtrace(e.backtrace)
  347. 111 handle_connect_error(error) if connecting?
  348. 111 force_close
  349. end
  350. 30 def on_io_error(e)
  351. on_error(e)
  352. force_close(true)
  353. end
  354. 30 def on_error(error, request = nil)
  355. 1607 if error.is_a?(OperationTimeoutError)
  356. # inactive connections do not contribute to the select loop, therefore
  357. # they should not fail due to such errors.
  358. 50 return if @state == :inactive
  359. 50 if @timeout
  360. 39 @timeout -= error.timeout
  361. 43 return unless @timeout <= 0
  362. 43 @timeout = nil
  363. end
  364. 50 error = error.to_connection_error if connecting?
  365. end
  366. 1607 handle_error(error, request)
  367. 1589 reset
  368. end
  369. skipped # :nocov:
  370. skipped def inspect
  371. skipped "#<#{self.class}:#{object_id} " \
  372. skipped "@origin=#{@origin} " \
  373. skipped "@state=#{@state} " \
  374. skipped "@pending=#{@pending.size} " \
  375. skipped "@io=#{@io}>"
  376. skipped end
  377. skipped # :nocov:
  378. 30 private
  379. 30 def connect
  380. 29658 transition(:open)
  381. end
  382. 30 def consume
  383. 38257 return unless @io
  384. 38257 catch(:called) do
  385. 38257 epiped = false
  386. 38257 loop do
  387. # connection may have
  388. 57416 return if @state == :idle
  389. 52550 parser.consume
  390. # we exit if there's no more requests to process
  391. #
  392. # this condition takes into account:
  393. #
  394. # * the number of pending requests
  395. # * the number of inflight requests
  396. # * whether the write buffer has bytes (i.e. for close handshake)
  397. 52532 if @pending.empty? && @inflight.zero? && @write_buffer.empty?
  398. 3721 no_more_requests_loop_check if @parser && @parser.pending.any?
  399. # terminate if an altsvc connection has been established
  400. 3721 terminate if @altsvc_connection
  401. 3721 return
  402. end
  403. 48811 @timeout = @current_timeout
  404. 48811 read_drained = false
  405. 48811 write_drained = nil
  406. #
  407. # tight read loop.
  408. #
  409. # read as much of the socket as possible.
  410. #
  411. # this tight loop reads all the data it can from the socket and pipes it to
  412. # its parser.
  413. #
  414. 12766 loop do
  415. 71353 siz = @io.read(@window_size, @read_buffer)
  416. 71502 log(level: 3, color: :cyan) { "IO READ: #{siz} bytes... (wsize: #{@window_size}, rbuffer: #{@read_buffer.bytesize})" }
  417. 71306 unless siz
  418. 46 @write_buffer.clear
  419. 46 ex = EOFError.new("descriptor closed")
  420. 46 ex.set_backtrace(caller)
  421. 46 on_error(ex)
  422. 46 return
  423. end
  424. # socket has been drained. mark and exit the read loop.
  425. 71260 if siz.zero?
  426. 18728 read_drained = @read_buffer.empty?
  427. 18728 epiped = false
  428. 18728 break
  429. end
  430. 52532 parser << @read_buffer.to_s
  431. # continue reading if possible.
  432. 46715 break if interests == :w && !epiped
  433. # exit the read loop if connection is preparing to be closed
  434. 40476 break if @state == :closing || @state == :closed
  435. # exit #consume altogether if all outstanding requests have been dealt with
  436. 40321 if @pending.empty? && @inflight.zero? && @write_buffer.empty? # rubocop:disable Style/Next
  437. 4178 no_more_requests_loop_check if @parser && @parser.pending.any?
  438. # terminate if an altsvc connection has been established
  439. 4178 terminate if @altsvc_connection
  440. 4178 return
  441. end
  442. 48811 end unless ((ints = interests).nil? || ints == :w || @state == :closing) && !epiped
  443. #
  444. # tight write loop.
  445. #
  446. # flush as many bytes as the sockets allow.
  447. #
  448. 9082 loop do
  449. # buffer has been drained, mark and exit the write loop.
  450. 28501 if @write_buffer.empty?
  451. # we only mark as drained on the first loop
  452. 3422 write_drained = write_drained.nil? && @inflight.positive?
  453. 3422 break
  454. end
  455. 2472 begin
  456. 25079 siz = @io.write(@write_buffer)
  457. rescue Errno::EPIPE
  458. # this can happen if we still have bytes in the buffer to send to the server, but
  459. # the server wants to respond immediately with some message, or an error. An example is
  460. # when one's uploading a big file to an unintended endpoint, and the server stops the
  461. # consumption, and responds immediately with an authorization of even method not allowed error.
  462. # at this point, we have to let the connection switch to read-mode.
  463. 10 log(level: 2) { "pipe broken, could not flush buffer..." }
  464. 10 epiped = true
  465. 10 read_drained = false
  466. 10 break
  467. end
  468. 25176 log(level: 3, color: :cyan) { "IO WRITE: #{siz} bytes..." }
  469. 25068 unless siz
  470. @write_buffer.clear
  471. ex = EOFError.new("descriptor closed")
  472. ex.set_backtrace(caller)
  473. on_error(ex)
  474. return
  475. end
  476. # socket closed for writing. mark and exit the write loop.
  477. 25068 if siz.zero?
  478. 18 write_drained = !@write_buffer.empty?
  479. 18 break
  480. end
  481. # exit write loop if marked to consume from peer, or is closing.
  482. 25050 break if interests == :r || @state == :closing || @state == :closed
  483. 3722 write_drained = false
  484. 38723 end unless (ints = interests) == :r
  485. 38722 send_pending if @state == :open
  486. # return if socket is drained
  487. 38722 next unless (ints != :r || read_drained) && (ints != :w || write_drained)
  488. # gotta go back to the event loop. It happens when:
  489. #
  490. # * the socket is drained of bytes or it's not the interest of the conn to read;
  491. # * theres nothing more to write, or it's not in the interest of the conn to write;
  492. 19648 log(level: 3) { "(#{ints}): WAITING FOR EVENTS..." }
  493. 19563 return
  494. end
  495. end
  496. end
  497. 30 def send_pending
  498. 101561 while !@write_buffer.full? && (request = @pending.shift)
  499. 21963 send_request_to_parser(request)
  500. end
  501. end
  502. 30 def parser
  503. 139764 @parser ||= build_parser
  504. end
  505. 30 def send_request_to_parser(request)
  506. 21393 @inflight += 1
  507. 22555 request.peer_address = @io.ip && @io.ip.address
  508. 22555 set_request_timeouts(request)
  509. 22555 parser.send(request)
  510. 22555 return unless @state == :inactive
  511. 42 transition(:active)
  512. # mark request as ping, as this inactive connection may have been
  513. # closed by the server, and we don't want that to influence retry
  514. # bookkeeping.
  515. 42 request.ping!
  516. end
  517. 30 def enqueue_pending_requests_from_parser(parser)
  518. 6857 parser.reset_requests # move sequential requests back to pending queue.
  519. 6857 parser_pending_requests = parser.pending
  520. 6857 return if parser_pending_requests.empty?
  521. # the connection will be reused, so parser requests must come
  522. # back to the pending list before the parser is reset.
  523. 309 @inflight -= parser_pending_requests.size
  524. 332 @pending.unshift(*parser_pending_requests)
  525. 332 parser.pending.clear
  526. end
  527. 30 def build_parser(protocol = @io.protocol)
  528. 10258 parser = parser_type(protocol).new(@write_buffer, @options)
  529. 10258 set_parser_callbacks(parser)
  530. 10258 parser.max_concurrent_requests = @max_concurrent_requests if @max_concurrent_requests && parser.respond_to?(:max_concurrent_requests=)
  531. 10258 parser
  532. end
  533. 30 def set_parser_callbacks(parser)
  534. 10391 parser.on(:response) do |request, response|
  535. 10433 AltSvc.emit(request, response) do |alt_origin, origin, alt_params|
  536. 18 build_altsvc_connection(alt_origin, origin, alt_params)
  537. end
  538. 10433 @response_received_at = Utils.now
  539. 10433 @no_more_requests_counter = 0
  540. 9433 @inflight -= 1
  541. 10433 response.finish!
  542. 10433 request.emit_response(response)
  543. end
  544. 10391 parser.on(:altsvc) do |alt_origin, origin, alt_params|
  545. build_altsvc_connection(alt_origin, origin, alt_params)
  546. end
  547. 10391 parser.on(:pong, &method(:pong))
  548. 10391 parser.on(:promise) do |request, stream|
  549. 27 request.emit(:promise, parser, stream)
  550. end
  551. 10391 parser.on(:exhausted) do
  552. 9 enqueue_pending_requests_from_parser(parser)
  553. 9 @exhausted = true
  554. 9 parser.close
  555. # @fiber-switch-guard
  556. # fiber may have switched while closing @io, check whether still in the exhausted loop.
  557. 9 next unless @exhausted
  558. 9 idling
  559. 9 @exhausted = false
  560. end
  561. 10391 parser.on(:origin) do |origin|
  562. @origins |= [origin]
  563. end
  564. 10391 parser.on(:close) do
  565. 3770 reset
  566. end
  567. 10391 parser.on(:close_handshake) do
  568. 21 consume unless @state == :closed
  569. end
  570. 10391 parser.on(:reset) do
  571. 5273 enqueue_pending_requests_from_parser(parser)
  572. 5273 reset
  573. 5264 next unless @state == :closed
  574. # :reset event only fired in http/1.1, so this guarantees
  575. # that the connection will be closed here.
  576. 5250 idling unless @pending.empty?
  577. end
  578. 10391 parser.on(:current_timeout) do
  579. 4461 @current_timeout = @timeout = parser.timeout
  580. end
  581. 10391 parser.on(:timeout) do |tout|
  582. 3642 @timeout = tout
  583. end
  584. 10391 parser.on(:error) do |request, error|
  585. 348 case error
  586. when :http_1_1_required
  587. 27 current_session = @current_session
  588. 27 current_selector = @current_selector
  589. 27 parser.close
  590. 27 other_connection = current_session.find_connection(@origin, current_selector,
  591. @options.merge(ssl: { alpn_protocols: %w[http/1.1] }))
  592. 27 other_connection.merge(self)
  593. 27 request.transition(:idle)
  594. 27 other_connection.send(request)
  595. 27 next
  596. when OperationTimeoutError
  597. # request level timeouts should take precedence
  598. next unless request.active_timeouts.empty?
  599. end
  600. 324 @inflight -= 1
  601. 352 response = ErrorResponse.new(request, error)
  602. 352 request.response = response
  603. 352 request.emit_response(response)
  604. end
  605. end
  606. 30 def transition(nextstate)
  607. 65332 handle_transition(nextstate)
  608. rescue Errno::ECONNABORTED,
  609. Errno::ECONNREFUSED,
  610. Errno::ECONNRESET,
  611. Errno::EADDRNOTAVAIL,
  612. Errno::EHOSTUNREACH,
  613. Errno::EINVAL,
  614. Errno::ENETUNREACH,
  615. Errno::EPIPE,
  616. Errno::ENOENT,
  617. SocketError,
  618. IOError => e
  619. 111 on_connect_error(e)
  620. rescue TLSError, ::HTTP2::Error::ProtocolError, ::HTTP2::Error::HandshakeError => e
  621. # connect errors, exit gracefully
  622. 27 handle_error(e)
  623. 27 handle_connect_error(e) if connecting?
  624. 27 force_close
  625. end
  626. 30 def handle_transition(nextstate)
  627. 58788 case nextstate
  628. when :idle
  629. 11439 @timeout = @current_timeout = @options.timeout[:connect_timeout]
  630. 11439 @connected_at = @response_received_at = nil
  631. when :open
  632. 30063 return if @state == :closed
  633. 30063 @io.connect
  634. 29925 close_sibling if @io.state == :connected
  635. 29925 return unless @io.connected?
  636. 10300 @connected_at = Utils.now
  637. 10300 send_pending
  638. 10300 @timeout = @current_timeout = parser.timeout
  639. 10300 emit(:open)
  640. when :inactive
  641. 592 return unless @state == :open
  642. # @type ivar @parser: HTTP1 | HTTP2
  643. # do not deactivate connection in use
  644. 560 return if @inflight.positive? || @parser.waiting_for_ping?
  645. when :closing
  646. 10631 return unless connecting? || @state == :open
  647. when :closed
  648. 10886 return unless @state == :closing
  649. 10886 return unless @write_buffer.empty?
  650. 10886 purge_after_closed
  651. # @fiber-switch-guard
  652. 10886 return unless @state == :closing && (@io.nil? || @io.can_disconnect?)
  653. when :already_open
  654. 73 nextstate = :open
  655. # the first check for given io readiness must still use a timeout.
  656. # connect is the reasonable choice in such a case.
  657. 73 @timeout = @options.timeout[:connect_timeout]
  658. 73 send_pending
  659. when :active
  660. 339 return unless @state == :inactive
  661. 339 nextstate = :open
  662. # activate
  663. 339 @current_session.select_connection(self, @current_selector)
  664. end
  665. 45071 log(level: 3) { "#{@state} -> #{nextstate}" }
  666. 44787 @state = nextstate
  667. # post state change
  668. 40352 case nextstate
  669. when :inactive
  670. 556 disconnect
  671. when :closing
  672. 10631 return if @write_buffer.empty?
  673. # try flushing termination handshakes
  674. 3645 consume
  675. 3645 @write_buffer.clear
  676. when :closed
  677. # TODO: should this raise an error instead?
  678. 10759 return unless @pending.empty?
  679. 10382 disconnect
  680. end
  681. end
  682. 30 def force_purge
  683. 488 return if @state == :closed
  684. 424 @state = :closed
  685. 424 @write_buffer.clear
  686. 22 begin
  687. 424 purge_after_closed
  688. rescue IOError
  689. # may be raised when closing the socket.
  690. # due to connection reuse / fiber scheduling, it may
  691. # have been reopened, to bail out in that case.
  692. end
  693. end
  694. 30 def close_sibling
  695. 14182 sibling = @sibling
  696. 14182 return unless sibling
  697. 14 if sibling.io_connected?
  698. reset
  699. # TODO: transition connection to closed
  700. end
  701. 14 unless sibling.state == :closed
  702. 7 merge(sibling) unless @main_sibling
  703. 7 sibling.force_reset(true)
  704. end
  705. 14 @sibling = nil
  706. end
  707. 30 def purge_after_closed
  708. 12991 if @io
  709. 12318 @io.close
  710. # @fiber-switch-guard
  711. # due to fiber scheduler, multiple fibers may be listening on the same connection
  712. # and moving the state machine forward; in such cases, when the control flow reaches
  713. # this line, the io object may not be closed anymore.
  714. 12318 return unless @io&.can_disconnect?
  715. end
  716. 12707 @read_buffer.clear
  717. 12707 @timeout = nil
  718. end
  719. 30 def initialize_type(uri, options)
  720. 9526 options.transport || begin
  721. 8587 case uri.scheme
  722. when "http"
  723. 5385 "tcp"
  724. when "https"
  725. 4109 "ssl"
  726. else
  727. raise UnsupportedSchemeError, "#{uri}: #{uri.scheme}: unsupported URI scheme"
  728. end
  729. end
  730. end
  731. # returns an HTTPX::Connection for the negotiated Alternative Service (or none).
  732. 30 def build_altsvc_connection(alt_origin, origin, alt_params)
  733. 18 return if @altsvc_connection
  734. # do not allow security downgrades on altsvc negotiation
  735. 9 return if @origin.scheme == "https" && alt_origin.scheme != "https"
  736. 9 altsvc = AltSvc.cached_altsvc_set(origin, alt_params.merge("origin" => alt_origin))
  737. # altsvc already exists, somehow it wasn't advertised, probably noop
  738. 9 return unless altsvc
  739. 9 alt_options = @options.merge(ssl: @options.ssl.merge(hostname: URI(origin).host))
  740. 9 connection = @current_session.find_connection(alt_origin, @current_selector, alt_options)
  741. # advertised altsvc is the same origin being used, ignore
  742. 9 return if connection == self
  743. 9 connection.extend(AltSvc::ConnectionMixin) unless connection.is_a?(AltSvc::ConnectionMixin)
  744. 9 @altsvc_connection = connection
  745. 9 log(level: 1) { "#{origin}: alt-svc connection##{connection.object_id} established to #{alt_origin}" }
  746. 9 connection.merge(self)
  747. rescue UnsupportedSchemeError
  748. altsvc["noop"] = true
  749. nil
  750. end
  751. 30 def build_socket(addrs = nil)
  752. 8304 case @type
  753. when "tcp"
  754. 5336 TCP.new(peer, addrs, @options)
  755. when "ssl"
  756. 3871 SSL.new(peer, addrs, @options) do |sock|
  757. 3847 sock.ssl_session = @ssl_session
  758. 3847 sock.session_new_cb do |sess|
  759. 6829 @ssl_session = sess
  760. 6829 sock.ssl_session = sess
  761. end
  762. end
  763. when "unix"
  764. 32 path = Array(addrs).first
  765. 32 path = String(path) if path
  766. 32 UNIX.new(peer, path, @options)
  767. else
  768. raise Error, "unsupported transport (#{@type})"
  769. end
  770. end
  771. 30 def ping(_request)
  772. 85 return if parser.waiting_for_ping?
  773. 85 parser.ping
  774. 85 ping_timeout = @options.timeout[:ping_timeout]
  775. 85 @ping_timer = @current_selector.after(ping_timeout) do
  776. 22 log(level: 3) { "ping timeout expired..." }
  777. 22 error = PingTimeoutError.new(ping_timeout, "Timed out after #{ping_timeout} seconds")
  778. 22 on_error(error)
  779. end
  780. 85 call
  781. end
  782. 30 def pong
  783. 59 @ping_timer.cancel
  784. 59 @ping_timer = nil
  785. 59 @response_received_at = Utils.now
  786. 59 @no_more_requests_counter = 0
  787. 59 send_pending
  788. end
  789. 30 def no_more_requests_loop_check
  790. log(level: 3) { "NO MORE REQUESTS..." }
  791. @no_more_requests_counter += 1
  792. return if @no_more_requests_counter < 50
  793. raise Error, "connection corrupted, aborted after looping for a while, " \
  794. "please report this https://gitlab.com/os85/httpx/-/work_items " \
  795. "along with debug logs"
  796. end
  797. # recover internal state and emit all relevant error responses when +error+ was raised.
  798. # this takes an optiona +request+ which may have already been handled and can be opted out
  799. # in the state recovery process.
  800. 30 def handle_error(error, request = nil)
  801. 1634 if request
  802. 705 @inflight -= 1
  803. 793 response = ErrorResponse.new(request, error)
  804. 793 request.response = response
  805. 793 request.emit_response(response)
  806. end
  807. 1634 pending = @pending
  808. 1634 if (parser = @parser) && parser.respond_to?(:handle_error)
  809. # parser.handle_error may disconnect the connection
  810. 1007 pending = @pending.dup
  811. 1007 @pending = []
  812. 1007 parser.handle_error(error, request)
  813. end
  814. 3530 while (req = pending.shift)
  815. 618 next if request && req == request
  816. 600 resp = ErrorResponse.new(req, error)
  817. 600 req.response = resp
  818. 582 req.emit_response(resp)
  819. end
  820. end
  821. 30 def set_request_timeouts(request)
  822. 22555 request.connection = self
  823. 22555 set_request_write_timeout(request)
  824. 22555 set_request_read_timeout(request)
  825. 22555 set_request_request_timeout(request)
  826. 22555 set_request_total_request_timeout(request)
  827. end
  828. 30 def set_request_read_timeout(request)
  829. 22555 read_timeout = request.read_timeout
  830. 22555 return if read_timeout.nil? || read_timeout.infinite?
  831. 22002 set_request_timeout(:read_timeout, request, read_timeout, :done, :response) do
  832. 36 read_timeout_callback(request, read_timeout)
  833. end
  834. end
  835. 30 def set_request_write_timeout(request)
  836. 22555 write_timeout = request.write_timeout
  837. 22555 return if write_timeout.nil? || write_timeout.infinite?
  838. 22555 set_request_timeout(:write_timeout, request, write_timeout, :headers, %i[done response]) do
  839. 18 write_timeout_callback(request, write_timeout)
  840. end
  841. end
  842. 30 def set_request_request_timeout(request)
  843. 22182 request_timeout = request.request_timeout
  844. 22182 return if request_timeout.nil? || request_timeout.infinite?
  845. 1417 set_request_timeout(:request_timeout, request, request_timeout, :headers, :complete) do
  846. 802 read_timeout_callback(request, request_timeout, RequestTimeoutError)
  847. end
  848. end
  849. 30 def write_timeout_callback(request, timeout)
  850. 18 return if request.state == :done
  851. 18 @write_buffer.clear
  852. 18 error = WriteTimeoutError.new(request, nil, timeout)
  853. 18 request.handle_error(error)
  854. end
  855. 30 def read_timeout_callback(request, timeout, error_type = ReadTimeoutError)
  856. 875 response = request.response
  857. 875 return if response && response.finished?
  858. 811 @write_buffer.clear
  859. 811 error = error_type.new(request, response, timeout)
  860. 811 request.handle_error(error)
  861. end
  862. 30 def set_request_total_request_timeout(request)
  863. 22555 return if request.started?
  864. 21258 total_request_timeout = request.total_request_timeout
  865. 21258 return if total_request_timeout.nil? || total_request_timeout.infinite?
  866. 90 set_request_timeout(:total_request_timeout, request, total_request_timeout, :headers, :complete) do
  867. 37 read_timeout_callback(request, total_request_timeout, TotalRequestTimeoutError)
  868. end
  869. end
  870. 30 def set_request_timeout(label, request, timeout, start_event, finish_events, &callback)
  871. 46138 request.set_timeout_callback(start_event) do
  872. 24320 unless (selector = @current_selector)
  873. raise Error, "request has been resend to an out-of-session connection, and this " \
  874. "should never happen!!! Please report this error! " \
  875. "(state:#{@state}, " \
  876. "parser?:#{!!@parser}, " \
  877. "bytes in write buffer?:#{!@write_buffer.empty?}, " \
  878. "cloned?:#{@cloned}, " \
  879. "sibling?:#{!!@sibling}, " \
  880. "coalesced?:#{coalesced?})"
  881. end
  882. 24320 timer = selector.after(timeout, callback)
  883. 24320 request.active_timeouts << label
  884. 24320 Array(finish_events).each do |event|
  885. # clean up request timeouts if the connection errors out
  886. 36101 request.set_timeout_callback(event) do
  887. 34788 timer.cancel
  888. 34788 request.active_timeouts.delete(label)
  889. end
  890. end
  891. end
  892. end
  893. 30 def parser_type(protocol)
  894. 9395 case protocol
  895. 4492 when "h2" then @options.http2_class
  896. 5962 when "http/1.1" then @options.http1_class
  897. else
  898. raise Error, "unsupported protocol (##{protocol})"
  899. end
  900. end
  901. end
  902. end

lib/httpx/connection/http1.rb

89.83% lines covered

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

lib/httpx/connection/http2.rb

92.59% lines covered

297 relevant lines. 275 lines covered and 22 lines missed.
    
  1. # frozen_string_literal: true
  2. 30 require "securerandom"
  3. 30 require "http/2"
  4. 30 module HTTPX
  5. 30 class Connection::HTTP2
  6. 30 include Callbacks
  7. 30 include Loggable
  8. 30 MAX_CONCURRENT_REQUESTS = ::HTTP2::DEFAULT_MAX_CONCURRENT_STREAMS
  9. 30 class Error < Error
  10. 30 def initialize(id, error)
  11. 111 super("stream #{id} closed with error: #{error}")
  12. end
  13. end
  14. 30 class PingError < Error
  15. 30 def initialize
  16. super(0, :ping_error)
  17. end
  18. end
  19. 30 class GoawayError < Error
  20. 30 def initialize(code = :no_error)
  21. 83 super(0, code)
  22. end
  23. end
  24. 30 attr_reader :streams, :pending
  25. 30 def initialize(buffer, options)
  26. 4527 @options = options
  27. 4527 @settings = @options.http2_settings
  28. 4527 @pending = []
  29. 4527 @streams = {}
  30. 4527 @drains = {}
  31. 4527 @pings = []
  32. 4527 @buffer = buffer
  33. 4527 @handshake_completed = false
  34. 4527 @wait_for_handshake = @settings.key?(:wait_for_handshake) ? @settings.delete(:wait_for_handshake) : true
  35. 4527 @max_concurrent_requests = @options.max_concurrent_requests || MAX_CONCURRENT_REQUESTS
  36. 4527 @max_requests = @options.max_requests
  37. 4527 init_connection
  38. end
  39. 30 def timeout
  40. 8937 return @options.timeout[:operation_timeout] if @handshake_completed
  41. 4476 @options.timeout[:settings_timeout]
  42. end
  43. 30 def interests
  44. 120788 if @connection.state == :closed
  45. 11287 return unless @handshake_completed
  46. 11193 return if @buffer.empty?
  47. # HTTP/2 GOAWAY frame buffered.
  48. 6522 return :w
  49. end
  50. 109501 unless @connection.state == :connected && @handshake_completed
  51. # HTTP/2 in intermediate state or still completing initialization-
  52. 25194 return @buffer.empty? ? :r : :rw
  53. end
  54. 81429 unless @connection.send_buffer.empty?
  55. # HTTP/2 connection is buffering data chunks and failing to emit DATA frames,
  56. # most likely because the flow control window is exhausted.
  57. 16 return :rw unless @buffer.empty?
  58. # waiting for WINDOW_UPDATE frames
  59. 3 return :r
  60. end
  61. # there are pending bufferable requests
  62. 81413 return :w if !@pending.empty? && can_buffer_more_requests?
  63. # there are pending frames from the last run
  64. 81413 return :w unless @drains.empty?
  65. 76845 if @buffer.empty?
  66. # skip if no more requests or pings to process
  67. 63966 return if @streams.empty? && @pings.empty?
  68. 60022 :r
  69. else
  70. # buffered frames
  71. 12879 :w
  72. end
  73. end
  74. 30 def close
  75. 3651 unless @connection.state == :closed
  76. 3633 @connection.goaway
  77. 3633 emit(:timeout, @options.timeout[:close_handshake_timeout])
  78. end
  79. 3651 emit(:close)
  80. end
  81. 30 def empty?
  82. 3656 @connection.state == :closed || @streams.empty?
  83. end
  84. 30 def exhausted?
  85. 4349 !@max_requests.positive?
  86. end
  87. 30 def <<(data)
  88. 43234 @connection << data
  89. end
  90. 30 def send(request, head = false)
  91. 10043 unless can_buffer_more_requests?
  92. 4829 head ? @pending.unshift(request) : @pending << request
  93. 4829 return false
  94. end
  95. 5214 unless (stream = @streams[request])
  96. 5214 stream = @connection.new_stream(**request.http2_stream_options)
  97. 5206 handle_stream(stream, request)
  98. 4689 @streams[request] = stream
  99. 4689 @max_requests -= 1
  100. end
  101. 5206 handle(request, stream)
  102. 5188 true
  103. rescue ::HTTP2::Error::StreamLimitExceeded
  104. @pending.unshift(request)
  105. false
  106. rescue StandardError => e
  107. 8 emit(:error, request, e)
  108. end
  109. 30 def consume
  110. 30354 @streams.each do |request, stream|
  111. 13963 next unless request.can_buffer?
  112. 1303 handle(request, stream)
  113. end
  114. end
  115. 30 def handle_error(ex, request = nil)
  116. 595 if ex.is_a?(OperationTimeoutError) && !@handshake_completed && @connection.state != :closed
  117. 21 @connection.goaway(:settings_timeout, "closing due to settings timeout")
  118. 21 emit(:close_handshake)
  119. 21 settings_ex = SettingsTimeoutError.new(ex.timeout, ex.message)
  120. 21 settings_ex.set_backtrace(ex.backtrace)
  121. 21 ex = settings_ex
  122. end
  123. 1601 while (req, _ = @streams.shift)
  124. 594 next if request && request == req
  125. 197 emit(:error, req, ex)
  126. end
  127. 1098 while (req = @pending.shift)
  128. 30 next if request && request == req
  129. 30 emit(:error, req, ex)
  130. end
  131. end
  132. 30 def ping
  133. 85 ping = SecureRandom.gen_random(8)
  134. 85 @connection.ping(ping.dup)
  135. ensure
  136. 85 @pings << ping
  137. end
  138. 30 def waiting_for_ping?
  139. 613 @pings.any?
  140. end
  141. 30 def reset_requests; end
  142. 30 private
  143. 30 def can_buffer_more_requests?
  144. 10957 (@handshake_completed || !@wait_for_handshake) &&
  145. @streams.size < @max_concurrent_requests &&
  146. @streams.size < @max_requests
  147. end
  148. 30 def send_pending
  149. 12138 while (request = @pending.shift)
  150. 4650 break unless send(request, true)
  151. end
  152. end
  153. 30 def handle(request, stream)
  154. 6813 catch(:buffer_full) do
  155. 6813 request.transition(:headers)
  156. 6804 join_headers(stream, request) if request.state == :headers
  157. 6804 request.transition(:body)
  158. 6804 join_body(stream, request) if request.state == :body
  159. 5271 request.transition(:trailers)
  160. 5271 join_trailers(stream, request) if request.state == :trailers && !request.body.empty?
  161. 5271 request.transition(:done)
  162. end
  163. end
  164. 30 def init_connection
  165. 4527 @connection = ::HTTP2::Client.new(@settings)
  166. 4527 @connection.on(:frame, &method(:on_frame))
  167. 4527 @connection.on(:frame_sent, &method(:on_frame_sent))
  168. 4527 @connection.on(:frame_received, &method(:on_frame_received))
  169. 4527 @connection.on(:origin, &method(:on_origin))
  170. 4527 @connection.on(:promise, &method(:on_promise))
  171. 4527 @connection.on(:altsvc) { |frame| on_altsvc(frame[:origin], frame) }
  172. 4527 @connection.on(:settings_ack, &method(:on_settings))
  173. 4527 @connection.on(:ack, &method(:on_pong))
  174. 4527 @connection.on(:goaway, &method(:on_close))
  175. #
  176. # Some servers initiate HTTP/2 negotiation right away, some don't.
  177. # As such, we have to check the socket buffer. If there is something
  178. # to read, the server initiated the negotiation. If not, we have to
  179. # initiate it.
  180. #
  181. 4527 @connection.send_connection_preface
  182. end
  183. 30 alias_method :reset, :init_connection
  184. 30 public :reset
  185. 30 def handle_stream(stream, request)
  186. 5224 request.on(:refuse, &method(:on_stream_refuse).curry(3)[stream, request])
  187. 5224 stream.on(:close, &method(:on_stream_close).curry(3)[stream, request])
  188. 10376 stream.on(:half_close) { on_stream_half_close(stream, request) }
  189. 5224 stream.on(:altsvc, &method(:on_altsvc).curry(2)[request.origin])
  190. 5224 stream.on(:headers, &method(:on_stream_headers).curry(3)[stream, request])
  191. 5224 stream.on(:data, &method(:on_stream_data).curry(3)[stream, request])
  192. end
  193. 30 def set_protocol_headers(request)
  194. 516 {
  195. 4680 ":scheme" => request.scheme,
  196. ":method" => request.verb,
  197. ":path" => request.path,
  198. ":authority" => request.authority,
  199. }
  200. end
  201. 30 def join_headers(stream, request)
  202. 5197 extra_headers = set_protocol_headers(request)
  203. 5197 if request.headers.key?("host")
  204. 9 request.log { "forbidden \"host\" header found (#{log_redact_headers(request.headers["host"])}), will use it as authority..." }
  205. 8 extra_headers[":authority"] = request.headers["host"]
  206. end
  207. 5197 request.log(level: 1, color: :yellow) do
  208. 146 "\n#{request.headers.merge(extra_headers).each.map { |k, v| "#{stream.id}: -> HEADER: #{k}: #{log_redact_headers(v)}" }.join("\n")}"
  209. end
  210. 5197 stream.headers(request.headers.each(extra_headers), end_stream: request.body.empty?)
  211. end
  212. 30 def join_trailers(stream, request)
  213. 1769 unless request.trailers?
  214. 1760 stream.data("", end_stream: true) if request.callbacks_for?(:trailers)
  215. 1596 return
  216. end
  217. 9 request.log(level: 1, color: :yellow) do
  218. 17 request.trailers.each.map { |k, v| "#{stream.id}: -> HEADER: #{k}: #{log_redact_headers(v)}" }.join("\n")
  219. end
  220. 9 stream.headers(request.trailers.each, end_stream: true)
  221. end
  222. 30 def join_body(stream, request)
  223. 6643 return if request.body.empty?
  224. 3305 chunk = @drains.delete(request) || request.drain_body
  225. 3503 while chunk
  226. 3588 next_chunk = request.drain_body
  227. 3588 send_chunk(request, stream, chunk, next_chunk)
  228. 3249 if next_chunk && (@buffer.full? || request.body.unbounded_body?)
  229. 1066 @drains[request] = next_chunk
  230. 1194 throw(:buffer_full)
  231. end
  232. 2055 chunk = next_chunk
  233. end
  234. 1772 return unless (error = request.drain_error)
  235. 28 on_stream_refuse(stream, request, error)
  236. end
  237. 30 def send_chunk(request, stream, chunk, next_chunk)
  238. 3612 request.log(level: 1, color: :green) { "#{stream.id}: -> DATA: #{chunk.bytesize} bytes..." }
  239. 3612 request.log(level: 2, color: :green) { "#{stream.id}: -> #{log_redact_body(chunk.inspect)}" }
  240. 3588 stream.data(chunk, end_stream: end_stream?(request, next_chunk))
  241. end
  242. 30 def end_stream?(request, next_chunk)
  243. 3215 !(next_chunk || request.trailers? || request.callbacks_for?(:trailers))
  244. end
  245. ######
  246. # HTTP/2 Callbacks
  247. ######
  248. 30 def on_stream_headers(stream, request, h)
  249. 4903 response = request.response
  250. 4903 if response.is_a?(Response) && response.version == "2.0"
  251. 133 on_stream_trailers(stream, request, response, h)
  252. 133 return
  253. end
  254. 4770 request.log(color: :yellow) do
  255. 146 h.map { |k, v| "#{stream.id}: <- HEADER: #{k}: #{k == ":status" ? v : log_redact_headers(v)}" }.join("\n")
  256. end
  257. 4770 _, status = h.shift
  258. 4770 headers = request.options.headers_class.new(h)
  259. 4770 raise HTTPX::Error, "maximum number of response headers exceeded" if h.size > @options.max_response_headers
  260. 4761 if (max_header_value_size = @options.max_response_header_value_size)
  261. 27 headers.each do |_, v| # rubocop:disable Style/HashEachMethods
  262. 81 raise HTTPX::Error, "maximum header value size exceeded" if v.size > max_header_value_size
  263. end
  264. end
  265. 4743 response = request.options.response_class.new(request, status, "2.0", headers)
  266. 4743 if response.content_length && response.content_length > request.options.max_response_body_size
  267. raise HTTPX::Error.new, "maximum response body size exceeded"
  268. end
  269. 4743 request.response = response
  270. 4265 @streams[request] = stream
  271. 4734 handle(request, stream) if request.expects?
  272. end
  273. 30 def on_stream_trailers(stream, request, response, h)
  274. 133 request.log(color: :yellow) do
  275. h.map { |k, v| "#{stream.id}: <- HEADER: #{k}: #{log_redact_headers(v)}" }.join("\n")
  276. end
  277. 133 response.merge_headers(h)
  278. end
  279. 30 def on_stream_data(stream, request, data)
  280. 8325 request.log(level: 1, color: :green) { "#{stream.id}: <- DATA: #{data.bytesize} bytes..." }
  281. 8325 request.log(level: 2, color: :green) { "#{stream.id}: <- #{log_redact_body(data.inspect)}" }
  282. 8304 request.response << data
  283. end
  284. 30 def on_stream_refuse(stream, request, error)
  285. 28 on_stream_close(stream, request, error)
  286. 28 stream.close
  287. end
  288. 30 def on_stream_half_close(stream, request)
  289. 5152 unless stream.send_buffer.empty?
  290. 1 stream.send_buffer.clear
  291. 1 stream.data("", end_stream: true)
  292. end
  293. # TODO: omit log line if response already here
  294. 5168 request.log(level: 2) { "#{stream.id}: waiting for response..." }
  295. end
  296. 30 def on_stream_close(stream, request, error)
  297. 4604 return if error == :stream_closed && !@streams.key?(request)
  298. 4592 log(level: 2) { "#{stream.id}: closing stream" }
  299. 4576 teardown(request)
  300. 4576 if error
  301. 28 case error
  302. when :http_1_1_required
  303. emit(:error, request, error)
  304. else
  305. 28 ex = Error.new(stream.id, error)
  306. 28 ex.set_backtrace(caller)
  307. 28 response = ErrorResponse.new(request, ex)
  308. 28 request.response = response
  309. 28 emit(:response, request, response)
  310. end
  311. else
  312. 4548 response = request.response
  313. 4548 if response && response.is_a?(Response) && response.status == 421
  314. 9 emit(:error, request, :http_1_1_required)
  315. else
  316. 4539 emit(:response, request, response)
  317. end
  318. end
  319. 4567 send(@pending.shift) unless @pending.empty?
  320. 4567 return unless @streams.empty? && exhausted?
  321. 9 if @pending.empty?
  322. close
  323. else
  324. 9 emit(:exhausted)
  325. end
  326. end
  327. 30 def on_frame(bytes)
  328. 27581 @buffer << bytes
  329. end
  330. 30 def on_settings(*)
  331. 4461 @handshake_completed = true
  332. 4461 emit(:current_timeout)
  333. 4461 @max_concurrent_requests = [@max_concurrent_requests, @connection.remote_settings[:settings_max_concurrent_streams]].min
  334. 4461 send_pending
  335. end
  336. 30 def on_close(_last_frame, error, _payload)
  337. 101 is_connection_closed = @connection.state == :closed
  338. 101 if error
  339. 101 @buffer.clear if is_connection_closed
  340. 90 case error
  341. when :http_1_1_required
  342. 48 while (request = @pending.shift)
  343. 18 emit(:error, request, error)
  344. end
  345. else
  346. 83 ex = GoawayError.new(error)
  347. 83 ex.set_backtrace(caller)
  348. 83 handle_error(ex)
  349. 83 teardown
  350. end
  351. end
  352. 101 return unless is_connection_closed && @streams.empty?
  353. 101 emit(:close) if is_connection_closed
  354. end
  355. 30 def on_frame_sent(frame)
  356. 23144 log(level: 2) { "#{frame[:stream]}: frame was sent!" }
  357. 23144 log(level: 2, color: :blue) { "#{frame[:stream]}: #{frame_with_extra_info(frame)}" }
  358. end
  359. 30 def on_frame_received(frame)
  360. 23956 log(level: 2) { "#{frame[:stream]}: frame was received!" }
  361. 23956 log(level: 2, color: :magenta) { "#{frame[:stream]}: #{frame_with_extra_info(frame)}" }
  362. end
  363. 30 def frame_with_extra_info(frame)
  364. 185 flags_bits = frame.fetch(:flags, 0)
  365. 185 case frame[:type]
  366. when :data
  367. 50 flags = [] #: Array[Symbol]
  368. 50 flags << :end_stream if flags_bits.anybits?(0b0001)
  369. 50 flags << :padded if flags_bits.anybits?(0b1000)
  370. 50 frame.merge(payload: frame[:payload].bytesize, flags: flags)
  371. when :push_promise, :headers
  372. 45 flags = [] #: Array[Symbol]
  373. 45 flags << :end_stream if flags_bits.anybits?(0b0001)
  374. 45 flags << :priority if flags_bits.anybits?(0b0010)
  375. 45 flags << :end_headers if flags_bits.anybits?(0b0100)
  376. 45 flags << :padded if flags_bits.anybits?(0b1000)
  377. 45 frame.merge(payload: log_redact_headers(frame[:payload]), flags: flags)
  378. when :ping
  379. flags = [] #: Array[Symbol]
  380. flags << :ack if flags_bits.anybits?(0b0001)
  381. frame.merge(payload: log_redact_headers(frame[:payload]), flags: flags)
  382. when :settings
  383. 72 flags = [] #: Array[Symbol]
  384. 72 flags << :ack if flags_bits.anybits?(0b0001)
  385. 72 frame.merge(flags: flags)
  386. when :window_update
  387. connection_or_stream = if (id = frame[:stream]).zero?
  388. @connection
  389. else
  390. @streams.each_value.find { |s| s.id == id }
  391. end
  392. if connection_or_stream
  393. frame.merge(
  394. local_window: connection_or_stream.local_window,
  395. remote_window: connection_or_stream.remote_window,
  396. buffered_amount: connection_or_stream.buffered_amount,
  397. stream_state: connection_or_stream.state,
  398. )
  399. else
  400. frame
  401. end
  402. else
  403. 18 frame
  404. end.merge(connection_state: @connection.state)
  405. end
  406. 30 def on_altsvc(origin, frame)
  407. log(level: 2) { "#{frame[:stream]}: altsvc frame was received" }
  408. log(level: 2) { "#{frame[:stream]}: #{log_redact_headers(frame.inspect)}" }
  409. alt_origin = URI.parse("#{frame[:proto]}://#{frame[:host]}:#{frame[:port]}")
  410. params = { "ma" => frame[:max_age] }
  411. emit(:altsvc, origin, alt_origin, origin, params)
  412. end
  413. 30 def on_promise(stream)
  414. 27 emit(:promise, @streams.key(stream.parent), stream)
  415. end
  416. 30 def on_origin(origin)
  417. emit(:origin, origin)
  418. end
  419. 30 def on_pong(ping)
  420. 59 raise PingError unless @pings.delete(ping.to_s)
  421. 59 emit(:pong)
  422. end
  423. 30 def teardown(request = nil)
  424. 4659 if request
  425. 4576 @drains.delete(request)
  426. 4576 @streams.delete(request)
  427. else
  428. 83 @drains.clear
  429. 83 @streams.clear
  430. end
  431. end
  432. end
  433. 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. 30 require "ipaddr"
  28. 30 module HTTPX
  29. # Represents a domain name ready for extracting its registered domain
  30. # and TLD.
  31. 30 class DomainName
  32. 30 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. 30 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. 30 attr_reader :domain
  47. 30 class << self
  48. 30 def new(domain)
  49. 963 return domain if domain.is_a?(self)
  50. 891 super
  51. end
  52. # Normalizes a _domain_ using the Punycode algorithm as necessary.
  53. # The result will be a downcased, ASCII-only string.
  54. 30 def normalize(domain)
  55. 855 unless domain.ascii_only?
  56. domain = domain.chomp(".").unicode_normalize(:nfc)
  57. domain = Punycode.encode_hostname(domain)
  58. end
  59. 855 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. 30 def initialize(hostname)
  65. 891 hostname = String(hostname)
  66. 891 raise ArgumentError, "domain name must not start with a dot: #{hostname}" if hostname.start_with?(".")
  67. 98 begin
  68. 891 @ipaddr = IPAddr.new(hostname)
  69. 36 @hostname = @ipaddr.to_s
  70. 36 return
  71. rescue IPAddr::Error
  72. 855 nil
  73. end
  74. 855 @hostname = DomainName.normalize(hostname)
  75. 855 tld = if (last_dot = @hostname.rindex("."))
  76. 207 @hostname[(last_dot + 1)..-1]
  77. else
  78. 648 @hostname
  79. end
  80. # unknown/local TLD
  81. 855 @domain = if last_dot
  82. # fallback - accept cookies down to second level
  83. # cf. http://www.dkim-reputation.org/regdom-libs/
  84. 207 if (penultimate_dot = @hostname.rindex(".", last_dot - 1))
  85. 54 @hostname[(penultimate_dot + 1)..-1]
  86. else
  87. 153 @hostname
  88. end
  89. else
  90. # no domain part - must be a local hostname
  91. 648 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. 30 def cookie_domain?(domain, host_only = false)
  100. # RFC 6265 #5.3
  101. # When the user agent "receives a cookie":
  102. 36 return self == @domain if host_only
  103. 36 domain = DomainName.new(domain)
  104. # RFC 6265 #5.1.3
  105. # Do not perform subdomain matching against IP addresses.
  106. 36 @hostname == domain.hostname if @ipaddr
  107. # RFC 6265 #4.1.1
  108. # Domain-value must be a subdomain.
  109. 36 @domain && self <= domain && domain <= @domain
  110. end
  111. 30 def <=>(other)
  112. 54 other = DomainName.new(other)
  113. 54 othername = other.hostname
  114. 54 if othername == @hostname
  115. 18 0
  116. 35 elsif @hostname.end_with?(othername) && @hostname[-othername.size - 1, 1] == "."
  117. # The other is higher
  118. 18 -1
  119. else
  120. # The other is lower
  121. 18 1
  122. end
  123. end
  124. end
  125. end

lib/httpx/errors.rb

97.78% lines covered

45 relevant lines. 44 lines covered and 1 lines missed.
    
  1. # frozen_string_literal: true
  2. 30 module HTTPX
  3. # the default exception class for exceptions raised by HTTPX.
  4. 30 class Error < StandardError; end
  5. 30 class UnsupportedSchemeError < Error; end
  6. 30 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. 30 class TimeoutError < Error
  10. # The timeout value which caused this error to be raised.
  11. 30 attr_reader :timeout
  12. # initializes the timeout exception with the +timeout+ causing the error, and the
  13. # error +message+ for it.
  14. 30 def initialize(timeout, message)
  15. 1019 @timeout = timeout
  16. 1019 super(message)
  17. end
  18. # clones this error into a HTTPX::ConnectionTimeoutError.
  19. 30 def to_connection_error
  20. 29 ex = ConnectTimeoutError.new(@timeout, message)
  21. 29 ex.set_backtrace(backtrace)
  22. 29 ex
  23. end
  24. end
  25. # Raise when it can't acquire a connection from the pool.
  26. 30 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. 30 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. 30 class RequestTimeoutError < TimeoutError
  34. # The HTTPX::Request request object this exception refers to.
  35. 30 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. 30 def initialize(request, response, timeout)
  39. 829 @request = request
  40. 829 @response = response
  41. 829 super(timeout, "Timed out after #{timeout} seconds")
  42. end
  43. 30 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. 30 class ReadTimeoutError < RequestTimeoutError; end
  49. # Error raised when there was a timeout while sending a request from the server.
  50. 30 class WriteTimeoutError < RequestTimeoutError; end
  51. # Error raised when a response couldn't be received for a request after multiple interactions.
  52. # This error should not be retriable.
  53. 30 class TotalRequestTimeoutError < RequestTimeoutError; end
  54. # Error raised when there was a timeout while waiting for the HTTP/2 settings frame from the server.
  55. 30 class SettingsTimeoutError < TimeoutError; end
  56. # Error raised when there was a timeout while resolving a domain to an IP.
  57. 30 class ResolveTimeoutError < TimeoutError; end
  58. # Error raised when there was a timeout waiting for readiness of the socket the request is related to.
  59. 30 class OperationTimeoutError < TimeoutError; end
  60. # Error raised when a connection liveness probe (aka ping) times out.
  61. 30 class PingTimeoutError < TimeoutError; end
  62. # Error raised when there was an error while resolving a domain to an IP.
  63. 30 class ResolveError < Error; end
  64. # Error raised when there was an error while resolving a domain to an IP
  65. # using a HTTPX::Resolver::Native resolver.
  66. 30 class NativeResolveError < ResolveError
  67. 30 attr_reader :host
  68. 30 attr_accessor :connection
  69. # initializes the exception with the +connection+ it refers to, the +host+ domain
  70. # which failed to resolve, and the error +message+.
  71. 30 def initialize(connection, host, message = "Can't resolve #{host}")
  72. 155 @connection = connection
  73. 155 @host = host
  74. 155 super(message)
  75. end
  76. end
  77. # The exception class for HTTP responses with 4xx or 5xx status.
  78. 30 class HTTPError < Error
  79. # The HTTPX::Response response object this exception refers to.
  80. 30 attr_reader :response
  81. # Creates the instance and assigns the HTTPX::Response +response+.
  82. 30 def initialize(response)
  83. 105 @response = response
  84. 105 super("HTTP Error: #{@response.status} #{@response.headers}\n#{@response.body}")
  85. end
  86. # The HTTP response status.
  87. #
  88. # error.status #=> 404
  89. 30 def status
  90. 18 @response.status
  91. end
  92. end
  93. 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. 30 require "uri"
  3. 30 module HTTPX
  4. 30 module ArrayExtensions
  5. 30 module Intersect
  6. refine Array do
  7. # Ruby 3.1 backport
  8. 4 def intersect?(arr)
  9. 19 if size < arr.size
  10. smaller = self
  11. else
  12. 19 smaller, arr = arr, self
  13. end
  14. 19 (arr & smaller).size > 0
  15. end
  16. 29 end unless Array.method_defined?(:intersect?)
  17. end
  18. end
  19. 30 module URIExtensions
  20. # uri 0.11 backport, ships with ruby 3.1
  21. 30 refine URI::Generic do
  22. 30 def non_ascii_hostname
  23. 831 @non_ascii_hostname
  24. end
  25. 30 def non_ascii_hostname=(hostname)
  26. 36 @non_ascii_hostname = hostname
  27. end
  28. def authority
  29. 6899 return host if port == default_port
  30. 909 "#{host}:#{port}"
  31. 29 end unless URI::HTTP.method_defined?(:authority)
  32. def origin
  33. 5597 "#{scheme}://#{authority}"
  34. 29 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. 30 module HTTPX
  3. 30 class Headers
  4. 30 class << self
  5. 30 def new(headers = nil)
  6. 35767 return headers if headers.is_a?(self)
  7. 15132 super
  8. end
  9. end
  10. 30 def initialize(headers = nil)
  11. 15132 if headers.nil? || headers.empty?
  12. 2193 @headers = headers.to_h
  13. 1976 return
  14. end
  15. 12939 @headers = {}
  16. 12939 headers.each do |field, value|
  17. 79830 field = downcased(field)
  18. 79830 value = array_value(value)
  19. 79830 current = @headers[field]
  20. 79830 if current.nil?
  21. 71878 @headers[field] = value
  22. else
  23. 65 current.concat(value)
  24. end
  25. end
  26. end
  27. # cloned initialization
  28. 30 def initialize_clone(orig, **kwargs)
  29. 9 super
  30. 9 @headers = orig.instance_variable_get(:@headers).clone(**kwargs)
  31. end
  32. # dupped initialization
  33. 30 def initialize_dup(orig)
  34. 30636 super
  35. 30636 @headers = orig.instance_variable_get(:@headers).transform_values(&:dup)
  36. end
  37. # freezes the headers hash
  38. 30 def freeze
  39. 30575 @headers.each_value(&:freeze).freeze
  40. 30575 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. 30 def merge(other)
  47. 6479 headers = dup
  48. 6479 other.each do |field, value|
  49. 16288 headers[downcased(field)] = value
  50. end
  51. 6479 headers
  52. end
  53. # returns the comma-separated values of the header field
  54. # identified by +field+, or nil otherwise.
  55. #
  56. 30 def [](field)
  57. 80920 a = @headers[downcased(field)] || return
  58. 34188 a.join(", ")
  59. end
  60. # sets +value+ (if not nil) as single value for the +field+ header.
  61. #
  62. 30 def []=(field, value)
  63. 31280 return unless value
  64. 28451 @headers[downcased(field)] = array_value(value)
  65. end
  66. # deletes all values associated with +field+ header.
  67. #
  68. 30 def delete(field)
  69. 424 canonical = downcased(field)
  70. 424 @headers.delete(canonical) if @headers.key?(canonical)
  71. end
  72. # adds additional +value+ to the existing, for header +field+.
  73. #
  74. 30 def add(field, value)
  75. 2040 (@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. 30 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. 30 def each(extra_headers = nil)
  89. 49549 return enum_for(__method__, extra_headers) { @headers.size } unless block_given?
  90. 27764 @headers.each do |field, value|
  91. 100624 yield(field, value.join(", ")) unless value.empty?
  92. end
  93. 7427 extra_headers.each do |field, value|
  94. 34075 yield(field, value) unless value.empty?
  95. 27745 end if extra_headers
  96. end
  97. 30 def ==(other)
  98. 7092 other == to_hash
  99. end
  100. 30 def empty?
  101. 414 @headers.empty?
  102. end
  103. # the headers store in Hash format
  104. 30 def to_hash
  105. 9702 Hash[to_a]
  106. end
  107. 30 alias_method :to_h, :to_hash
  108. # the headers store in array of pairs format
  109. 30 def to_a
  110. 9728 Array(each)
  111. end
  112. # headers as string
  113. 30 def to_s
  114. 2882 @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. 30 def key?(downcased_key)
  127. 103399 @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. 30 def get(field)
  134. 462 @headers[field] || EMPTY
  135. end
  136. 30 private
  137. 30 def array_value(value)
  138. 111110 Array(value)
  139. end
  140. 30 def downcased(field)
  141. 212298 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. 30 require "socket"
  3. 30 require "httpx/io/udp"
  4. 30 require "httpx/io/tcp"
  5. 30 require "httpx/io/unix"
  6. begin
  7. 30 require "httpx/io/ssl"
  8. rescue LoadError
  9. end

lib/httpx/io/ssl.rb

97.73% lines covered

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

lib/httpx/io/tcp.rb

91.67% lines covered

132 relevant lines. 121 lines covered and 11 lines missed.
    
  1. # frozen_string_literal: true
  2. 30 require "resolv"
  3. 30 module HTTPX
  4. 30 class TCP
  5. 30 include Loggable
  6. 30 using URIExtensions
  7. 30 attr_reader :ip, :port, :addresses, :state, :interests
  8. 30 alias_method :host, :ip
  9. 30 def initialize(origin, addresses, options)
  10. 9389 @state = :idle
  11. 9389 @keep_open = false
  12. 9389 @addresses = []
  13. 9389 @ip_index = -1
  14. 9389 @ip = nil
  15. 9389 @hostname = origin.host
  16. 9389 @options = options
  17. 9389 @fallback_protocol = @options.fallback_protocol
  18. 9389 @port = origin.port
  19. 9389 @interests = :w
  20. 9389 if (io = @options.io)
  21. 10 io =
  22. 47 case io
  23. when Hash
  24. 18 io[origin.authority]
  25. else
  26. 39 io
  27. end
  28. 57 raise Error, "Given IO objects do not match the request authority" unless io
  29. # @type var io: TCPSocket | OpenSSL::SSL::SSLSocket
  30. 57 _, _, _, ip = io.addr
  31. 57 @io = io
  32. 57 @addresses << (@ip = Resolver::Entry.new(ip))
  33. 57 @keep_open = true
  34. 57 @state = :connected
  35. else
  36. 9332 add_addresses(addresses)
  37. end
  38. 9389 @ip_index = @addresses.size - 1
  39. end
  40. 30 def socket
  41. 219 @io
  42. end
  43. 30 def add_addresses(addrs)
  44. 9960 return if addrs.empty?
  45. 9951 ip_index = @ip_index || (@addresses.size - 1)
  46. 9951 if addrs.first.ipv6?
  47. # should be the next in line
  48. 565 @addresses = [*@addresses[0, ip_index], *addrs, *@addresses[ip_index..-1]]
  49. else
  50. 9386 @addresses.unshift(*addrs)
  51. end
  52. 8969 @ip_index += addrs.size
  53. end
  54. # eliminates expired entries and returns whether there are still any left.
  55. 30 def addresses?
  56. 1250 prev_addr_size = @addresses.size
  57. 1250 @addresses.delete_if(&:expired?).sort! do |addr1, addr2|
  58. 2567 if addr1.ipv6?
  59. 7 addr2.ipv6? ? 0 : 1
  60. else
  61. 2560 addr2.ipv6? ? -1 : 0
  62. end
  63. end
  64. 1250 @ip_index = @addresses.size - 1 if prev_addr_size != @addresses.size
  65. 1250 @addresses.any?
  66. end
  67. 30 def to_io
  68. 37178 @io.to_io
  69. end
  70. 30 def protocol
  71. 6097 @fallback_protocol
  72. end
  73. 30 def connect
  74. 23764 return unless closed?
  75. 23612 if @addresses.empty?
  76. # an idle connection trying to connect with no available addresses is a connection
  77. # out of the initial context which is back to the DNS resolution loop. This may
  78. # happen in a fiber-aware context where a connection reconnects with expired addresses,
  79. # and context is passed back to a fiber on the same connection while waiting for the
  80. # DNS answer.
  81. log { "tried connecting while resolving, skipping..." }
  82. return
  83. end
  84. 23612 if !@io || @io.closed?
  85. 10663 transition(:idle)
  86. 10663 @io = build_socket
  87. end
  88. 23612 try_connect
  89. rescue Errno::EHOSTUNREACH,
  90. Errno::ENETUNREACH => e
  91. 14 @ip_index -= 1
  92. 14 raise e if @ip_index.negative?
  93. 7 log { "failed connecting to #{@ip} (#{e.message}), evict from cache and trying next..." }
  94. 7 @options.resolver_cache.evict(@hostname, @ip)
  95. 7 @io = build_socket
  96. 7 retry
  97. rescue Errno::ECONNREFUSED,
  98. Errno::EADDRNOTAVAIL,
  99. SocketError,
  100. IOError => e
  101. 1213 @ip_index -= 1
  102. 1254 raise e if @ip_index.negative?
  103. 1172 log { "failed connecting to #{@ip} (#{e.message}), trying next..." }
  104. 1158 @io = build_socket
  105. 1158 retry
  106. rescue Errno::ETIMEDOUT => e
  107. @ip_index -= 1
  108. raise ConnectTimeoutError.new(@options.timeout[:connect_timeout], e.message) if @ip_index.negative?
  109. log { "failed connecting to #{@ip} (#{e.message}), trying next..." }
  110. @io = build_socket
  111. retry
  112. end
  113. 30 def try_connect
  114. 23612 ret = @io.connect_nonblock(Socket.sockaddr_in(@port, @ip.to_s), exception: false)
  115. 22516 log(level: 3, color: :cyan) { "TCP CONNECT: #{ret}..." }
  116. 20169 case ret
  117. when :wait_readable
  118. @interests = :r
  119. return
  120. when :wait_writable
  121. 11814 @interests = :w
  122. 11814 return
  123. end
  124. 10530 transition(:connected)
  125. 10530 @interests = :w
  126. rescue Errno::EALREADY
  127. @interests = :w
  128. end
  129. 30 private :try_connect
  130. 30 def read(size, buffer)
  131. 71406 ret = @io.read_nonblock(size, buffer, exception: false)
  132. 71359 if ret == :wait_readable
  133. 18746 buffer.clear
  134. 17341 return 0
  135. end
  136. 52613 return if ret.nil?
  137. 52678 log { "READ: #{buffer.bytesize} bytes..." }
  138. 52567 buffer.bytesize
  139. end
  140. 30 def write(buffer)
  141. 25099 siz = @io.write_nonblock(buffer, exception: false)
  142. 25089 return 0 if siz == :wait_writable
  143. 25071 return if siz.nil?
  144. 25179 log { "WRITE: #{siz} bytes..." }
  145. 25071 buffer.shift!(siz)
  146. 25071 siz
  147. end
  148. 30 def close
  149. 12339 return if @keep_open || closed?
  150. 1054 begin
  151. 10411 @io.close
  152. rescue StandardError => e
  153. log { "error closing socket" }
  154. log { e.full_message(highlight: false) }
  155. ensure
  156. # @fiber-switch-guard
  157. # ensure that all :closed IOs don't leave dangling sockets
  158. # behind. This may happen in a fiber scheduler scenario where
  159. # connection is reused across fibers.
  160. 10411 transition(:closed) if @io.closed?
  161. end
  162. end
  163. # signals that the connection that contains this IO can be checked back into the pool.
  164. # that includes sockets opened outside of the scope of the session, or closed IOs.
  165. 30 def can_disconnect?
  166. 22714 @keep_open || @state == :closed
  167. end
  168. 30 def connected?
  169. 12701 @state == :connected
  170. end
  171. 30 def closed?
  172. 36192 @state == :idle || @state == :closed
  173. end
  174. skipped # :nocov:
  175. skipped def inspect
  176. skipped "#<#{self.class}:#{object_id} " \
  177. skipped "#{@ip}:#{@port} " \
  178. skipped "@state=#{@state} " \
  179. skipped "@hostname=#{@hostname} " \
  180. skipped "@addresses=#{@addresses} " \
  181. skipped "@state=#{@state}>"
  182. skipped end
  183. skipped # :nocov:
  184. 30 private
  185. 30 def build_socket
  186. 11828 @ip = @addresses[@ip_index]
  187. 11828 Socket.new(@ip.family, :STREAM, 0)
  188. end
  189. 30 def transition(nextstate)
  190. 16619 case nextstate
  191. # when :idle
  192. when :connected
  193. 6190 return unless @state == :idle
  194. when :closed
  195. 6073 return unless @state == :connected
  196. end
  197. 18504 do_transition(nextstate)
  198. end
  199. 30 def do_transition(nextstate)
  200. 36280 log(level: 1) { log_transition_state(nextstate) }
  201. 36032 @state = nextstate
  202. end
  203. 30 def log_transition_state(nextstate)
  204. 248 label = host
  205. 248 label = "#{label}(##{@io.fileno})" if nextstate == :connected
  206. 222 "#{label} #{@state} -> #{nextstate}"
  207. end
  208. end
  209. 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. 30 require "ipaddr"
  3. 30 module HTTPX
  4. 30 class UDP
  5. 30 include Loggable
  6. 30 def initialize(ip, port, options)
  7. 525 @host = ip
  8. 525 @port = port
  9. 525 @io = UDPSocket.new(IPAddr.new(ip).family)
  10. 525 @options = options
  11. end
  12. 30 def to_io
  13. 1400 @io.to_io
  14. end
  15. 30 def connect; end
  16. 30 def connected?
  17. 525 true
  18. end
  19. 30 def close
  20. 511 @io.close
  21. end
  22. 30 if RUBY_ENGINE == "jruby"
  23. # In JRuby, sendmsg_nonblock is not implemented
  24. 1 def write(buffer)
  25. 53 siz = @io.send(buffer.to_s, 0, @host, @port)
  26. 53 log { "WRITE: #{siz} bytes..." }
  27. 53 buffer.shift!(siz)
  28. 53 siz
  29. end
  30. else
  31. 29 def write(buffer)
  32. 772 siz = @io.sendmsg_nonblock(buffer.to_s, 0, Socket.sockaddr_in(@port, @host.to_s), exception: false)
  33. 772 return 0 if siz == :wait_writable
  34. 772 return if siz.nil?
  35. 772 log { "WRITE: #{siz} bytes..." }
  36. 772 buffer.shift!(siz)
  37. 772 siz
  38. end
  39. end
  40. 30 def read(size, buffer)
  41. 1526 ret = @io.recvfrom_nonblock(size, 0, buffer, exception: false)
  42. 1526 return 0 if ret == :wait_readable
  43. 752 return if ret.nil?
  44. 752 log { "READ: #{buffer.bytesize} bytes..." }
  45. 752 buffer.bytesize
  46. rescue IOError
  47. end
  48. end
  49. end

lib/httpx/io/unix.rb

97.3% lines covered

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

lib/httpx/loggable.rb

100.0% lines covered

29 relevant lines. 29 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 30 require "fiber" if RUBY_VERSION < "3.0.0"
  3. 30 module HTTPX
  4. 30 module Loggable
  5. 30 COLORS = {
  6. black: 30,
  7. red: 31,
  8. green: 32,
  9. yellow: 33,
  10. blue: 34,
  11. magenta: 35,
  12. cyan: 36,
  13. white: 37,
  14. }.freeze
  15. 30 USE_DEBUG_LOG = ENV.key?("HTTPX_DEBUG")
  16. 30 def log(
  17. level: @options.debug_level,
  18. color: nil,
  19. debug_level: @options.debug_level,
  20. debug: @options.debug,
  21. &msg
  22. )
  23. 968485 return unless debug_level >= level
  24. 380764 debug_stream = debug || ($stderr if USE_DEBUG_LOG)
  25. 380764 return unless debug_stream
  26. 4951 klass = self.class
  27. 10845 until (class_name = klass.name)
  28. 2379 klass = klass.superclass
  29. end
  30. 4951 message = +"(time:#{Time.now.utc}, pid:#{Process.pid}, " \
  31. 493 "tid:#{Thread.current.object_id}, " \
  32. 493 "fid:#{Fiber.current.object_id}, " \
  33. 493 "self:#{class_name}##{object_id}) "
  34. 4951 message << msg.call << "\n"
  35. 4951 message = "\e[#{COLORS[color]}m#{message}\e[0m" if color && debug_stream.respond_to?(:isatty) && debug_stream.isatty
  36. 4951 debug_stream << message
  37. end
  38. 30 def log_exception(ex, level: @options.debug_level, color: nil, debug_level: @options.debug_level, debug: @options.debug)
  39. 2176 log(level: level, color: color, debug_level: debug_level, debug: debug) { ex.full_message }
  40. end
  41. 30 def log_redact_headers(text)
  42. 1026 log_redact(text, @options.debug_redact == :headers)
  43. end
  44. 30 def log_redact_body(text)
  45. 104 log_redact(text, @options.debug_redact == :body)
  46. end
  47. 30 def log_redact(text, should_redact = nil)
  48. 1202 should_redact ||= @options.debug_redact == true
  49. 1202 return text.to_s unless should_redact
  50. 252 "[REDACTED]"
  51. end
  52. end
  53. end

lib/httpx/options.rb

96.39% lines covered

249 relevant lines. 240 lines covered and 9 lines missed.
    
  1. # frozen_string_literal: true
  2. 30 module HTTPX
  3. # Contains a set of options which are passed and shared across from session to its requests or
  4. # responses.
  5. 30 class Options
  6. 30 BUFFER_SIZE = 1 << 14
  7. 30 WINDOW_SIZE = 1 << 14 # 16K
  8. 30 MAX_BODY_THRESHOLD_SIZE = (1 << 10) * 112 # 112K
  9. 30 KEEP_ALIVE_TIMEOUT = 20
  10. 30 PING_TIMEOUT = 2
  11. 30 SETTINGS_TIMEOUT = 10
  12. 30 CLOSE_HANDSHAKE_TIMEOUT = 10
  13. 30 CONNECT_TIMEOUT = READ_TIMEOUT = WRITE_TIMEOUT = 60
  14. 30 REQUEST_TIMEOUT = OPERATION_TIMEOUT = TOTAL_REQUEST_TIMEOUT = nil
  15. 30 RESOLVER_TYPES = %i[memory file].freeze
  16. # default value used for "user-agent" header, when not overridden.
  17. 30 USER_AGENT = "httpx.rb/#{VERSION}".freeze # rubocop:disable Style/RedundantFreeze
  18. 30 @options_names = []
  19. 30 class << self
  20. 30 attr_reader :options_names
  21. 30 def inherited(klass)
  22. 49 super
  23. 49 klass.instance_variable_set(:@options_names, @options_names.dup)
  24. end
  25. 30 def new(options = {})
  26. # let enhanced options go through
  27. 17696 return options if self == Options && options.class < self
  28. 12625 return options if options.is_a?(self)
  29. 7705 super
  30. end
  31. 30 def freeze
  32. 19849 @options_names.freeze
  33. 19849 super
  34. end
  35. 30 def method_added(meth)
  36. 48685 super
  37. 48685 return unless meth =~ /^option_(.+)$/
  38. 20438 optname = Regexp.last_match(1) #: String
  39. 20438 if optname =~ /^(.+[^_])_+with/
  40. # ignore alias method chain generated methods.
  41. # this is the case with RBS runtime tests.
  42. # it relies on the "_with/_without" separator, which is the most used convention,
  43. # however it shouldn't be used in practice in httpx given the plugin architecture
  44. # as the main extension API.
  45. orig_name = Regexp.last_match(1) #: String
  46. return if @options_names.include?(orig_name.to_sym)
  47. end
  48. 20438 optname = optname.to_sym
  49. 20438 attr_reader(optname) unless method_defined?(optname)
  50. 20438 @options_names << optname unless @options_names.include?(optname)
  51. end
  52. end
  53. # creates a new options instance from a given hash, which optionally define the following:
  54. #
  55. # :debug :: an object which log messages are written to (must respond to <tt><<</tt>)
  56. # :debug_level :: the log level of messages (can be 1, 2, or 3).
  57. # :debug_redact :: whether header/body payload should be redacted (defaults to <tt>false</tt>).
  58. # :ssl :: a hash of options which can be set as params of OpenSSL::SSL::SSLContext (see HTTPX::SSL)
  59. # :http2_settings :: a hash of options to be passed to a HTTP2::Connection (ex: <tt>{ max_concurrent_streams: 2 }</tt>)
  60. # :fallback_protocol :: version of HTTP protocol to use by default in the absence of protocol negotiation
  61. # like ALPN (defaults to <tt>"http/1.1"</tt>)
  62. # :supported_compression_formats :: list of compressions supported by the transcoder layer (defaults to <tt>%w[gzip deflate]</tt>).
  63. # :decompress_response_body :: whether to auto-decompress response body (defaults to <tt>true</tt>).
  64. # :compress_request_body :: whether to auto-decompress response body (defaults to <tt>true</tt>)
  65. # :timeout :: hash of timeout configurations (supports <tt>:connect_timeout</tt>, <tt>:settings_timeout</tt>,
  66. # <tt>:operation_timeout</tt>, <tt>:keep_alive_timeout</tt>, <tt>:read_timeout</tt>, <tt>:write_timeout</tt>,
  67. # <tt>:request_timeout</tt>, <tt>:total_request_timeout</tt> and <tt>:ping_timeout</tt>,
  68. # :headers :: hash of HTTP headers (ex: <tt>{ "x-custom-foo" => "bar" }</tt>)
  69. # :max_response_body_size :: maximum size (in bytes) that the response body can consume (no threshold by default), after which an
  70. # error is raised.
  71. # :max_response_headers :: maximum number of header fields that a response can receive, after which an error is raised.
  72. # :max_response_header_value_size :: maximum size (in bytes) a header value can have (no threshold by default).
  73. # for cases where the value is broken into multiple header fields (such as "cookie" or "set-cookie"),
  74. # this is the total aggregated size.
  75. # :window_size :: number of bytes to read from a socket
  76. # :buffer_size :: internal read and write buffer size in bytes
  77. # :body_threshold_size :: maximum size in bytes of response payload that is buffered in memory.
  78. # :request_class :: class used to instantiate a request
  79. # :response_class :: class used to instantiate a response
  80. # :headers_class :: class used to instantiate headers
  81. # :request_body_class :: class used to instantiate a request body
  82. # :response_body_class :: class used to instantiate a response body
  83. # :connection_class :: class used to instantiate connections
  84. # :http1_class :: class used to manage HTTP1 sessions
  85. # :http2_class :: class used to imanage HTTP2 sessions
  86. # :resolver_native_class :: class used to resolve names using pure ruby DNS implementation
  87. # :resolver_system_class :: class used to resolve names using system-based (getaddrinfo) name resolution
  88. # :resolver_https_class :: class used to resolve names using DoH
  89. # :pool_class :: class used to instantiate the session connection pool
  90. # :options_class :: class used to instantiate options
  91. # :transport :: type of transport to use (set to "unix" for UNIX sockets)
  92. # :addresses :: bucket of peer addresses (can be a list of IP addresses, a hash of domain to list of adddresses;
  93. # paths should be used for UNIX sockets instead)
  94. # :io :: open socket, or domain/ip-to-socket hash, which requests should be sent to
  95. # :persistent :: whether to persist connections in between requests (defaults to <tt>true</tt>)
  96. # :resolver_class :: which resolver to use (defaults to <tt>:native</tt>, can also be <tt>:system<tt> for
  97. # using getaddrinfo or <tt>:https</tt> for DoH resolver, or a custom class inheriting
  98. # from HTTPX::Resolver::Resolver)
  99. # :resolver_cache :: strategy to cache DNS results, ignored by the <tt>:system</tt> resolver, can be set to <tt>:memory<tt>
  100. # or an instance of a custom class inheriting from HTTPX::Resolver::Cache::Base
  101. # :resolver_options :: hash of options passed to the resolver. Accepted keys depend on the resolver type.
  102. # :pool_options :: hash of options passed to the connection pool (See Pool#initialize).
  103. # :ip_families :: which socket families are supported (system-dependent)
  104. # :origin :: HTTP origin to set on requests with relative path (ex: "https://api.serv.com")
  105. # :base_path :: path to prefix given relative paths with (ex: "/v2")
  106. # :max_concurrent_requests :: max number of requests which can be set concurrently
  107. # :max_requests :: max number of requests which can be made on socket before it reconnects.
  108. # :close_on_fork :: whether the session automatically closes when the process is fork (defaults to <tt>false</tt>).
  109. # it only works if the session is persistent (and ruby 3.1 or higher is used).
  110. #
  111. # This list of options are enhanced with each loaded plugin, see the plugin docs for details.
  112. 30 def initialize(options = EMPTY_HASH)
  113. 7705 options_names = self.class.options_names
  114. 1556 defaults =
  115. 6149 case options
  116. when Options
  117. 5806 unknown_options = options.class.options_names - options_names
  118. 5806 raise Error, "unknown option: #{unknown_options.first}" unless unknown_options.empty?
  119. 5806 DEFAULT_OPTIONS.merge(options)
  120. else
  121. 1899 options.each_key do |k|
  122. 15625 raise Error, "unknown option: #{k}" unless options_names.include?(k)
  123. end
  124. 1890 options.empty? ? DEFAULT_OPTIONS : DEFAULT_OPTIONS.merge(options)
  125. end
  126. 7696 options_names.each do |k|
  127. 367781 v = defaults[k]
  128. 367781 if v.nil?
  129. 95632 instance_variable_set(:"@#{k}", v)
  130. 95632 next
  131. end
  132. 272149 value = __send__(:"option_#{k}", v)
  133. 272105 instance_variable_set(:"@#{k}", value)
  134. end
  135. 7652 do_initialize
  136. 7652 freeze
  137. end
  138. # returns the class with which to instantiate the DNS resolver.
  139. 30 def resolver_class
  140. 20224 case @resolver_class
  141. when Symbol
  142. 17336 public_send(:"resolver_#{@resolver_class}_class")
  143. else
  144. 4891 @resolver_class
  145. end
  146. end
  147. 30 def resolver_cache
  148. 20977 cache_type = @resolver_cache
  149. 18975 case cache_type
  150. when :memory
  151. 16293 Resolver::Cache::Memory.cache(cache_type)
  152. when :file
  153. Resolver::Cache::File.cache(cache_type)
  154. else
  155. 4684 unless cache_type.respond_to?(:resolve) &&
  156. cache_type.respond_to?(:get) &&
  157. cache_type.respond_to?(:set) &&
  158. cache_type.respond_to?(:evict)
  159. raise TypeError, ":resolver_cache must be a compatible resolver cache and implement #get, #set and #evict"
  160. end
  161. 4684 cache_type #: Object & Resolver::_Cache
  162. end
  163. end
  164. 30 def freeze
  165. 19788 self.class.options_names.each do |ivar|
  166. # avoid freezing debug option, as when it's set, it's usually an
  167. # object which cannot be frozen, like stderr or stdout. It's a
  168. # documented exception then, and still does not defeat the purpose
  169. # here, which is to make option objects shareable across ractors,
  170. # and in most cases debug should be nil, or one of the objects
  171. # which will eventually be shareable, like STDOUT or STDERR.
  172. 944433 next if ivar == :debug
  173. 924645 instance_variable_get(:"@#{ivar}").freeze
  174. end
  175. 19788 super
  176. end
  177. 30 REQUEST_BODY_IVARS = %i[@headers].freeze
  178. # checks whether +other+ matches the same connection-level options
  179. 30 def connection_options_match?(other, ignore_ivars = nil)
  180. 3118 return true if self == other
  181. # headers and other request options do not play a role, as they are
  182. # relevant only for the request.
  183. 646 ivars = instance_variables
  184. 31106 ivars.reject! { |iv| REQUEST_BODY_IVARS.include?(iv) }
  185. 1033 ivars.reject! { |iv| ignore_ivars.include?(iv) } if ignore_ivars
  186. 646 other_ivars = other.instance_variables
  187. 31106 other_ivars.reject! { |iv| REQUEST_BODY_IVARS.include?(iv) }
  188. 1033 other_ivars.reject! { |iv| ignore_ivars.include?(iv) } if ignore_ivars
  189. 646 return false if ivars.size != other_ivars.size
  190. 646 return false if ivars.sort != other_ivars.sort
  191. 646 ivars.all? do |ivar|
  192. 28508 instance_variable_get(ivar) == other.instance_variable_get(ivar)
  193. end
  194. end
  195. 30 RESOLVER_IVARS = %i[
  196. @resolver_class @resolver_cache @resolver_options
  197. @resolver_native_class @resolver_system_class @resolver_https_class
  198. ].freeze
  199. # checks whether +other+ matches the same resolver-level options
  200. 30 def resolver_options_match?(other)
  201. 317 self == other ||
  202. RESOLVER_IVARS.all? do |ivar|
  203. 849 instance_variable_get(ivar) == other.instance_variable_get(ivar)
  204. end
  205. end
  206. # returns a HTTPX::Options instance resulting of the merging of +other+ with self.
  207. # it may return self if +other+ is self or equal to self.
  208. 30 def merge(other)
  209. 50808 if (is_options = other.is_a?(Options))
  210. 15310 return self if eql?(other)
  211. 4243 opts_names = other.class.options_names
  212. 153529 return self if opts_names.all? { |opt| public_send(opt) == other.public_send(opt) }
  213. 3972 other_opts = opts_names
  214. else
  215. 35498 other_opts = other # : Hash[Symbol, untyped]
  216. 35498 other_opts = Hash[other] unless other.is_a?(Hash)
  217. 35490 return self if other_opts.empty?
  218. 34453 return self if other_opts.all? { |opt, v| !respond_to?(opt) || public_send(opt) == v }
  219. end
  220. 19649 opts = dup
  221. 19649 other_opts.each do |opt, v|
  222. 220459 next unless respond_to?(opt)
  223. 220459 v = other.public_send(opt) if is_options
  224. 220459 ivar = :"@#{opt}"
  225. 220459 unless v
  226. 60454 opts.instance_variable_set(ivar, v)
  227. 60454 next
  228. end
  229. 160005 v = opts.__send__(:"option_#{opt}", v)
  230. 159969 orig_v = public_send(opt)
  231. 159969 v = orig_v.merge(v) if orig_v.respond_to?(:merge) && v.respond_to?(:merge)
  232. 159969 opts.instance_variable_set(ivar, v)
  233. end
  234. 19613 opts
  235. end
  236. 30 def to_hash
  237. 6403 instance_variables.each_with_object({}) do |ivar, hs|
  238. 292274 val = instance_variable_get(ivar)
  239. 292274 next if val.nil?
  240. 204494 hs[ivar[1..-1].to_sym] = val
  241. end
  242. end
  243. 30 def extend_with_plugin_classes(pl)
  244. # extend request class
  245. 12088 if defined?(pl::RequestMethods) || defined?(pl::RequestClassMethods)
  246. 5864 @request_class = @request_class.dup
  247. 5864 SET_TEMPORARY_NAME[@request_class, pl]
  248. 5864 @request_class.__send__(:include, pl::RequestMethods) if defined?(pl::RequestMethods)
  249. 5864 @request_class.extend(pl::RequestClassMethods) if defined?(pl::RequestClassMethods)
  250. end
  251. # extend response class
  252. 12088 if defined?(pl::ResponseMethods) || defined?(pl::ResponseClassMethods)
  253. 5043 @response_class = @response_class.dup
  254. 5043 SET_TEMPORARY_NAME[@response_class, pl]
  255. 5043 @response_class.__send__(:include, pl::ResponseMethods) if defined?(pl::ResponseMethods)
  256. 5043 @response_class.extend(pl::ResponseClassMethods) if defined?(pl::ResponseClassMethods)
  257. end
  258. # extend headers class
  259. 12088 if defined?(pl::HeadersMethods) || defined?(pl::HeadersClassMethods)
  260. 234 @headers_class = @headers_class.dup
  261. 234 SET_TEMPORARY_NAME[@headers_class, pl]
  262. 234 @headers_class.__send__(:include, pl::HeadersMethods) if defined?(pl::HeadersMethods)
  263. 234 @headers_class.extend(pl::HeadersClassMethods) if defined?(pl::HeadersClassMethods)
  264. end
  265. # extend request body class
  266. 12088 if defined?(pl::RequestBodyMethods) || defined?(pl::RequestBodyClassMethods)
  267. 451 @request_body_class = @request_body_class.dup
  268. 451 SET_TEMPORARY_NAME[@request_body_class, pl]
  269. 451 @request_body_class.__send__(:include, pl::RequestBodyMethods) if defined?(pl::RequestBodyMethods)
  270. 451 @request_body_class.extend(pl::RequestBodyClassMethods) if defined?(pl::RequestBodyClassMethods)
  271. end
  272. # extend response body class
  273. 12088 if defined?(pl::ResponseBodyMethods) || defined?(pl::ResponseBodyClassMethods)
  274. 1398 @response_body_class = @response_body_class.dup
  275. 1398 SET_TEMPORARY_NAME[@response_body_class, pl]
  276. 1398 @response_body_class.__send__(:include, pl::ResponseBodyMethods) if defined?(pl::ResponseBodyMethods)
  277. 1398 @response_body_class.extend(pl::ResponseBodyClassMethods) if defined?(pl::ResponseBodyClassMethods)
  278. end
  279. # extend connection pool class
  280. 12088 if defined?(pl::PoolMethods)
  281. 1027 @pool_class = @pool_class.dup
  282. 1027 SET_TEMPORARY_NAME[@pool_class, pl]
  283. 1027 @pool_class.__send__(:include, pl::PoolMethods)
  284. end
  285. # extend connection class
  286. 12088 if defined?(pl::ConnectionMethods)
  287. 4933 @connection_class = @connection_class.dup
  288. 4933 SET_TEMPORARY_NAME[@connection_class, pl]
  289. 4933 @connection_class.__send__(:include, pl::ConnectionMethods)
  290. end
  291. # extend http1 class
  292. 12088 if defined?(pl::HTTP1Methods)
  293. 653 @http1_class = @http1_class.dup
  294. 653 SET_TEMPORARY_NAME[@http1_class, pl]
  295. 653 @http1_class.__send__(:include, pl::HTTP1Methods)
  296. end
  297. # extend http2 class
  298. 12088 if defined?(pl::HTTP2Methods)
  299. 1743 @http2_class = @http2_class.dup
  300. 1743 SET_TEMPORARY_NAME[@http2_class, pl]
  301. 1743 @http2_class.__send__(:include, pl::HTTP2Methods)
  302. end
  303. # extend native resolver class
  304. 12088 if defined?(pl::ResolverNativeMethods)
  305. 1781 @resolver_native_class = @resolver_native_class.dup
  306. 1781 SET_TEMPORARY_NAME[@resolver_native_class, pl]
  307. 1781 @resolver_native_class.__send__(:include, pl::ResolverNativeMethods)
  308. end
  309. # extend system resolver class
  310. 12088 if defined?(pl::ResolverSystemMethods)
  311. 758 @resolver_system_class = @resolver_system_class.dup
  312. 758 SET_TEMPORARY_NAME[@resolver_system_class, pl]
  313. 758 @resolver_system_class.__send__(:include, pl::ResolverSystemMethods)
  314. end
  315. # extend https resolver class
  316. 12088 if defined?(pl::ResolverHTTPSMethods)
  317. 123 @resolver_https_class = @resolver_https_class.dup
  318. 123 SET_TEMPORARY_NAME[@resolver_https_class, pl]
  319. 123 @resolver_https_class.__send__(:include, pl::ResolverHTTPSMethods)
  320. end
  321. 12088 return unless defined?(pl::OptionsMethods)
  322. # extend option class
  323. # works around lack of initialize_dup callback
  324. 5806 @options_class = @options_class.dup
  325. # (self.class.options_names)
  326. 5806 @options_class.__send__(:include, pl::OptionsMethods)
  327. end
  328. 30 private
  329. # number options
  330. 30 %i[
  331. max_concurrent_requests max_requests window_size buffer_size
  332. max_response_body_size max_response_headers max_response_header_value_size
  333. body_threshold_size debug_level
  334. ].each do |option|
  335. 270 class_eval(<<-OUT, __FILE__, __LINE__ + 1)
  336. 9 # converts +v+ into an Integer before setting the +#{option}+ option.
  337. 9 private def option_#{option}(value) # private def option_max_requests(v)
  338. value = Integer(value) unless value.respond_to?(:infinite?) && value.infinite?
  339. 9 raise TypeError, ":#{option} must be positive" unless value.positive? # raise TypeError, ":max_requests must be positive" unless value.positive?
  340. value
  341. end
  342. OUT
  343. end
  344. # hashable options
  345. 30 %i[ssl http2_settings resolver_options pool_options].each do |option|
  346. 120 class_eval(<<-OUT, __FILE__, __LINE__ + 1)
  347. 4 # converts +v+ into an Hash before setting the +#{option}+ option.
  348. 4 private def option_#{option}(value) # def option_ssl(v)
  349. Hash[value]
  350. end
  351. OUT
  352. end
  353. 30 %i[
  354. request_class response_class headers_class request_body_class
  355. response_body_class connection_class http1_class http2_class
  356. resolver_native_class resolver_system_class resolver_https_class options_class pool_class
  357. io fallback_protocol debug debug_redact
  358. compress_request_body decompress_response_body
  359. persistent close_on_fork
  360. ].each do |method_name|
  361. 630 class_eval(<<-OUT, __FILE__, __LINE__ + 1)
  362. 21 # sets +v+ as the value of the +#{method_name}+ option
  363. 21 private def option_#{method_name}(v); v; end # private def option_smth(v); v; end
  364. OUT
  365. end
  366. 30 def option_origin(value)
  367. 786 URI(value)
  368. end
  369. 30 def option_base_path(value)
  370. 36 String(value)
  371. end
  372. 30 def option_headers(value)
  373. 12409 value = value.dup if value.frozen?
  374. 12409 headers_class.new(value)
  375. end
  376. 30 def option_timeout(value)
  377. 14117 timeout_hash = Hash[value]
  378. 14117 default_timeouts = DEFAULT_OPTIONS[:timeout]
  379. # Validate keys and values
  380. 14117 timeout_hash.each do |key, val|
  381. 120603 raise TypeError, "invalid timeout: :#{key}" unless default_timeouts.key?(key)
  382. 120594 next if val.nil?
  383. 86585 raise TypeError, ":#{key} must be numeric" unless val.is_a?(Numeric)
  384. end
  385. 14099 timeout_hash
  386. end
  387. 30 def option_supported_compression_formats(value)
  388. 11841 Array(value).map(&:to_s)
  389. end
  390. 30 def option_transport(value)
  391. 56 transport = value.to_s
  392. 56 raise TypeError, "#{transport} is an unsupported transport type" unless %w[unix].include?(transport)
  393. 56 transport
  394. end
  395. 30 def option_addresses(value)
  396. 105 Array(value).map { |entry| Resolver::Entry.convert(entry) }
  397. end
  398. 30 def option_ip_families(value)
  399. 210 Array(value)
  400. end
  401. 30 def option_resolver_class(resolver_type)
  402. 11186 case resolver_type
  403. when Symbol
  404. 8074 meth = :"resolver_#{resolver_type}_class"
  405. 8074 raise TypeError, ":resolver_class must be a supported type" unless respond_to?(meth)
  406. 8065 resolver_type
  407. when Class
  408. 4263 raise TypeError, ":resolver_class must be a subclass of `#{Resolver::Resolver}`" unless resolver_type < Resolver::Resolver
  409. 4255 resolver_type
  410. else
  411. raise TypeError, ":resolver_class must be a supported type"
  412. end
  413. end
  414. 30 def option_resolver_cache(cache_type)
  415. 11796 if cache_type.is_a?(Symbol)
  416. 7626 raise TypeError, ":resolver_cache: #{cache_type} is invalid" unless RESOLVER_TYPES.include?(cache_type)
  417. 7626 require "httpx/resolver/cache/file" if cache_type == :file
  418. else
  419. 4170 unless cache_type.respond_to?(:resolve) &&
  420. cache_type.respond_to?(:get) &&
  421. cache_type.respond_to?(:set) &&
  422. cache_type.respond_to?(:evict)
  423. raise TypeError, ":resolver_cache must be a compatible resolver cache and implement #resolve, #get, #set and #evict"
  424. end
  425. end
  426. 11796 cache_type
  427. end
  428. # called after all options are initialized
  429. 30 def do_initialize
  430. 7652 hs = @headers
  431. # initialized default request headers
  432. 7652 hs["user-agent"] = USER_AGENT unless hs.key?("user-agent")
  433. 7652 hs["accept"] = "*/*" unless hs.key?("accept")
  434. 7652 if hs.key?("range")
  435. 9 hs.delete("accept-encoding")
  436. else
  437. 7643 hs["accept-encoding"] = supported_compression_formats unless hs.key?("accept-encoding")
  438. end
  439. end
  440. 30 def access_option(obj, k, ivar_map)
  441. case obj
  442. when Hash
  443. obj[ivar_map[k]]
  444. else
  445. obj.instance_variable_get(k)
  446. end
  447. end
  448. # rubocop:disable Lint/UselessConstantScoping
  449. # these really need to be defined at the end of the class
  450. 30 SET_TEMPORARY_NAME = ->(klass, pl = nil) do
  451. 25138 if klass.respond_to?(:set_temporary_name) # ruby 3.4 only
  452. 11339 name = klass.name || "#{klass.superclass.name}(plugin)"
  453. 11339 name = "#{name}/#{pl}" if pl
  454. 11339 klass.set_temporary_name(name)
  455. end
  456. end
  457. 2 DEFAULT_OPTIONS = {
  458. 28 :max_requests => Float::INFINITY,
  459. :debug => nil,
  460. 30 :debug_level => (ENV["HTTPX_DEBUG"] || 1).to_i,
  461. :debug_redact => ENV.key?("HTTPX_DEBUG_REDACT"),
  462. :ssl => EMPTY_HASH,
  463. :http2_settings => { settings_enable_push: 0 }.freeze,
  464. :fallback_protocol => "http/1.1",
  465. :supported_compression_formats => %w[gzip deflate],
  466. :decompress_response_body => true,
  467. :compress_request_body => true,
  468. :max_response_headers => 1000,
  469. :max_response_header_value_size => nil,
  470. :max_response_body_size => Float::INFINITY,
  471. :timeout => {
  472. connect_timeout: CONNECT_TIMEOUT,
  473. settings_timeout: SETTINGS_TIMEOUT,
  474. close_handshake_timeout: CLOSE_HANDSHAKE_TIMEOUT,
  475. operation_timeout: OPERATION_TIMEOUT,
  476. keep_alive_timeout: KEEP_ALIVE_TIMEOUT,
  477. ping_timeout: PING_TIMEOUT,
  478. read_timeout: READ_TIMEOUT,
  479. write_timeout: WRITE_TIMEOUT,
  480. request_timeout: REQUEST_TIMEOUT,
  481. total_request_timeout: TOTAL_REQUEST_TIMEOUT,
  482. }.freeze,
  483. :headers_class => Class.new(Headers, &SET_TEMPORARY_NAME),
  484. :headers => EMPTY_HASH,
  485. :window_size => WINDOW_SIZE,
  486. :buffer_size => BUFFER_SIZE,
  487. :body_threshold_size => MAX_BODY_THRESHOLD_SIZE,
  488. :request_class => Class.new(Request, &SET_TEMPORARY_NAME),
  489. :response_class => Class.new(Response, &SET_TEMPORARY_NAME),
  490. :request_body_class => Class.new(Request::Body, &SET_TEMPORARY_NAME),
  491. :response_body_class => Class.new(Response::Body, &SET_TEMPORARY_NAME),
  492. :pool_class => Class.new(Pool, &SET_TEMPORARY_NAME),
  493. :connection_class => Class.new(Connection, &SET_TEMPORARY_NAME),
  494. :http1_class => Class.new(Connection::HTTP1, &SET_TEMPORARY_NAME),
  495. :http2_class => Class.new(Connection::HTTP2, &SET_TEMPORARY_NAME),
  496. :resolver_native_class => Class.new(Resolver::Native, &SET_TEMPORARY_NAME),
  497. :resolver_system_class => Class.new(Resolver::System, &SET_TEMPORARY_NAME),
  498. :resolver_https_class => Class.new(Resolver::HTTPS, &SET_TEMPORARY_NAME),
  499. :options_class => Class.new(self, &SET_TEMPORARY_NAME),
  500. :transport => nil,
  501. :addresses => nil,
  502. :persistent => false,
  503. 30 :resolver_class => (ENV["HTTPX_RESOLVER"] || :native).to_sym,
  504. 30 :resolver_cache => (ENV["HTTPX_RESOLVER_CACHE"] || :memory).to_sym,
  505. :resolver_options => { cache: true }.freeze,
  506. :pool_options => EMPTY_HASH,
  507. :ip_families => nil,
  508. :close_on_fork => false,
  509. }.each_value(&:freeze).freeze
  510. # rubocop:enable Lint/UselessConstantScoping
  511. end
  512. end

lib/httpx/parser/http1.rb

100.0% lines covered

116 relevant lines. 116 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 30 module HTTPX
  3. 30 module Parser
  4. 30 class Error < Error; end
  5. 30 class HTTP1
  6. 30 VERSIONS = %w[1.0 1.1].freeze
  7. 30 attr_reader :status_code, :http_version, :headers
  8. 30 def initialize(observer, max_headers, max_header_value_size)
  9. 6196 @observer = observer
  10. 6196 @state = :idle
  11. 6196 @buffer = "".b
  12. 6196 @headers = {}
  13. 6196 @max_headers = max_headers
  14. 6196 @max_header_value_size = max_header_value_size
  15. 6196 @content_length = nil
  16. 6196 @_has_trailers = @upgrade = false
  17. end
  18. 30 def <<(chunk)
  19. 9181 @buffer << chunk
  20. 9181 parse
  21. end
  22. 30 def reset!
  23. 11986 @state = :idle
  24. 11986 @headers = {}
  25. 11986 @content_length = nil
  26. 11986 @_has_trailers = @upgrade = false
  27. 11986 @buffer = @buffer.to_s
  28. end
  29. 30 def upgrade?
  30. 5884 @upgrade
  31. end
  32. 30 def upgrade_data
  33. 37 @buffer.to_s
  34. end
  35. 30 private
  36. 30 def parse
  37. 9181 loop do
  38. 19705 state = @state
  39. 17816 case @state
  40. when :idle
  41. 6352 parse_headline
  42. when :headers, :trailers
  43. 6451 parse_headers
  44. when :data
  45. 6900 parse_data
  46. end
  47. 14210 return if @buffer.empty? || state == @state
  48. end
  49. end
  50. 30 def parse_headline
  51. #: @type ivar @buffer: String
  52. 6352 idx = @buffer.index("\n")
  53. 6352 return unless idx
  54. 6352 (m = %r{\AHTTP(?:/(\d+\.\d+))?\s+(\d\d\d)(?:\s+(.*))?}in.match(@buffer)) ||
  55. raise(Error, "wrong head line format")
  56. 6343 version, code, _ = m.captures
  57. 6343 raise(Error, "unsupported HTTP version (HTTP/#{version})") unless version && VERSIONS.include?(version)
  58. 6334 @http_version = version.split(".").map(&:to_i)
  59. 6334 @status_code = code.to_i
  60. 6334 raise(Error, "wrong status code (#{@status_code})") unless (100..599).cover?(@status_code)
  61. 6325 @buffer = @buffer.byteslice((idx + 1)..-1)
  62. 6325 nextstate(:headers)
  63. end
  64. 30 def parse_headers
  65. 6453 headers = @headers
  66. 6453 buffer = @buffer
  67. #: @type var buffer: String
  68. 50699 while (idx = buffer.index("\n"))
  69. # @type var line: String
  70. 49477 line = buffer.byteslice(0..idx)
  71. 49477 raise Error, "wrong header format" if line.start_with?("\s", "\t")
  72. 49468 line.lstrip!
  73. 49468 buffer = @buffer = buffer.byteslice((idx + 1)..-1)
  74. 49468 if line.empty?
  75. 5699 case @state
  76. when :headers
  77. 6280 prepare_data(headers)
  78. 6280 @observer.on_headers(headers)
  79. 5354 return unless @state == :headers
  80. # state might have been reset
  81. # in the :headers callback
  82. 5263 nextstate(:data)
  83. 5263 headers.clear
  84. when :trailers
  85. 18 @observer.on_trailers(headers)
  86. 18 headers.clear
  87. 18 nextstate(:complete)
  88. end
  89. 5272 return
  90. end
  91. 43170 separator_index = line.index(":")
  92. 43170 raise Error, "wrong header format" unless separator_index
  93. # @type var key: String
  94. 43161 key = line.byteslice(0..(separator_index - 1))
  95. 43161 key.rstrip! # was lstripped previously!
  96. # @type var value: String
  97. 43161 value = line.byteslice((separator_index + 1)..-1)
  98. 43161 value.strip!
  99. 43161 raise Error, "wrong header format" if value.nil?
  100. 43161 values = (headers[key.downcase] ||= []) << value
  101. 43161 raise Error, "maximum header value size exceeded" if @max_header_value_size && (values.sum(&:size) > @max_header_value_size)
  102. 43143 raise Error, "maximum number of response headers exceeded" if headers.size > @max_headers
  103. end
  104. end
  105. 30 def parse_data
  106. 6900 if @buffer.respond_to?(:each)
  107. # @type ivar @buffer: Transcoder::Chunker::Decoder
  108. 231 @buffer.each do |chunk|
  109. 333 @observer.on_data(chunk)
  110. end
  111. 6668 elsif @content_length
  112. # @type ivar @buffer: String
  113. 6553 data = @buffer.byteslice(0, @content_length)
  114. # @type var data: String
  115. 6553 @buffer = @buffer.byteslice(@content_length..-1) || "".b
  116. 5918 @content_length -= data.bytesize
  117. 6553 @observer.on_data(data)
  118. 6519 data.clear
  119. else
  120. # @type ivar @buffer: String
  121. 116 @observer.on_data(@buffer)
  122. 107 @buffer.clear
  123. end
  124. 6839 return unless no_more_data?
  125. 5040 @buffer = @buffer.to_s
  126. 5040 if @_has_trailers
  127. 18 nextstate(:trailers)
  128. else
  129. 5022 nextstate(:complete)
  130. end
  131. end
  132. 30 def prepare_data(headers)
  133. 6280 @upgrade = headers.key?("upgrade")
  134. 6280 @_has_trailers = headers.key?("trailer")
  135. 6280 if (tr_encodings = headers["transfer-encoding"])
  136. 171 tr_encodings.reverse_each do |tr_encoding|
  137. 171 tr_encoding.split(/ *, */).each do |encoding|
  138. 152 case encoding
  139. when "chunked"
  140. 171 @buffer = Transcoder::Chunker::Decoder.new(@buffer.to_s, @_has_trailers)
  141. end
  142. end
  143. end
  144. else
  145. 6109 @content_length = headers["content-length"][0].to_i if headers.key?("content-length")
  146. end
  147. end
  148. 30 def no_more_data?
  149. 6839 if @content_length
  150. 6519 @content_length <= 0
  151. 319 elsif @buffer.respond_to?(:finished?)
  152. # @type ivar @buffer: Transcoder::Chunker::Decoder
  153. 213 @buffer.finished?
  154. else
  155. 107 false
  156. end
  157. end
  158. 30 def nextstate(state)
  159. 16646 @state = state
  160. 15074 case state
  161. when :headers
  162. 6325 @observer.on_start
  163. when :complete
  164. 5040 @observer.on_complete
  165. 604 reset!
  166. 604 nextstate(:idle) unless @buffer.empty?
  167. end
  168. end
  169. end
  170. end
  171. end

lib/httpx/plugins/auth.rb

98.08% lines covered

104 relevant lines. 102 lines covered and 2 lines missed.
    
  1. # frozen_string_literal: true
  2. 23 module HTTPX
  3. 23 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. 23 module Auth
  12. 23 def self.subplugins
  13. 186 {
  14. 1491 retries: AuthRetries,
  15. }
  16. end
  17. # adds support for the following options:
  18. #
  19. # :auth_header_value :: the token to use as a string, or a callable which returns a string when called.
  20. # :auth_header_type :: the authentication type to use in the "authorization" header value (i.e. "Bearer", "Digest"...)
  21. # :auth_header_expires_at :: timestamp at which the auth header will be discarded. should be a callable (like a proc)
  22. # receiving the request as an argument, and should return either a Time object, or an integer (UNIX time).
  23. # :auth_header_expires_in :: time (in seconds) since the first use of an auth header after which that header will be discarded.
  24. # :generate_auth_value_on_retry :: callable which returns whether the request should regenerate the auth_header_value
  25. # when the request is retried (this option will only work if the session also loads the
  26. # <tt>:retries</tt> plugin).
  27. 23 module OptionsMethods
  28. 23 def option_auth_header_value(value)
  29. 648 value
  30. end
  31. 23 def option_auth_header_type(value)
  32. 522 value
  33. end
  34. 23 def option_auth_header_expires_at(value)
  35. 36 unless value.respond_to?(:call)
  36. value = Float(value)
  37. raise TypeError, "`:auth_header_expires_at` must be positive" unless value.positive?
  38. end
  39. 36 value
  40. end
  41. 23 def option_auth_header_expires_in(value)
  42. 36 value = Float(value)
  43. 36 raise TypeError, "`:auth_header_expires_in` must be positive" unless value.positive?
  44. 36 value
  45. end
  46. 23 def option_generate_auth_value_on_retry(value)
  47. 108 raise TypeError, "`:generate_auth_value_on_retry` must be a callable" unless value.respond_to?(:call)
  48. 108 value
  49. end
  50. end
  51. 23 module InstanceMethods
  52. 23 def initialize(*)
  53. 1878 super
  54. 1878 @auth_header_value = @auth_header_expires_at = nil
  55. 1878 @auth_header_value_mtx = Thread::Mutex.new
  56. 1878 @skip_auth_header_value = false
  57. end
  58. 23 def authorization(token = nil, auth_header_type: nil, &blk)
  59. 324 with(auth_header_type: auth_header_type, auth_header_value: token || blk)
  60. end
  61. 23 def bearer_auth(token = nil, &blk)
  62. 18 authorization(token, auth_header_type: "Bearer", &blk)
  63. end
  64. 23 def skip_auth_header
  65. 162 @skip_auth_header_value = true
  66. 162 yield
  67. ensure
  68. 162 @skip_auth_header_value = false
  69. end
  70. 23 def reset_auth_header_value!
  71. 18 @auth_header_value_mtx.synchronize do
  72. 18 @auth_header_value = @auth_header_expires_at = nil
  73. end
  74. end
  75. 23 private
  76. 23 def send_request(request, *)
  77. 1810 return super if @skip_auth_header_value || request.authorized?
  78. 1426 auth_header_value = @auth_header_value_mtx.synchronize do
  79. 1426 try_invalidate_auth_header_value
  80. 1426 @auth_header_value ||= begin
  81. 670 set_auth_header_expires_at(request)
  82. 670 generate_auth_token
  83. end
  84. end
  85. 1426 request.authorize(auth_header_value) if auth_header_value
  86. 1426 super
  87. end
  88. 23 def try_invalidate_auth_header_value
  89. 1426 return unless (expires_at = @auth_header_expires_at)
  90. 90 return if expires_at > Time.now.utc.to_i
  91. 36 @auth_header_value = @auth_header_expires_at = nil
  92. end
  93. 23 def generate_auth_token
  94. 688 return unless (auth_value = @options.auth_header_value)
  95. 432 auth_value = auth_value.call(self) if dynamic_auth_token?(auth_value)
  96. 432 auth_value
  97. end
  98. 23 def set_auth_header_expires_at(request)
  99. 724 @auth_header_expires_at = if (expires_in = request.options.auth_header_expires_in)
  100. 36 Time.now.to_i + expires_in
  101. 688 elsif (expires_at = request.options.auth_header_expires_at)
  102. 36 if expires_at.respond_to?(:call) && (expires_at = expires_at.call(request))
  103. 36 expires_at = expires_at.to_f
  104. 36 raise Error, "`:auth_header_expires_at` must be positive" unless expires_at.positive?
  105. 36 expires_at
  106. end
  107. end
  108. end
  109. 23 def dynamic_auth_token?(auth_header_value)
  110. 576 auth_header_value&.respond_to?(:call)
  111. end
  112. end
  113. 23 module RequestMethods
  114. 23 attr_reader :auth_token_value
  115. 23 def initialize(*)
  116. 1484 super
  117. 1484 @auth_token_value = @auth_header_value = nil
  118. end
  119. 23 def authorized?
  120. 1648 !@auth_token_value.nil?
  121. end
  122. 23 def unauthorize!
  123. 128 return unless (auth_value = @auth_header_value)
  124. 128 @headers.get("authorization").delete(auth_value)
  125. 128 @auth_token_value = @auth_header_value = nil
  126. end
  127. 23 def authorize(auth_value)
  128. 1374 @auth_header_value = auth_value
  129. 1374 if (auth_type = @options.auth_header_type)
  130. 72 @auth_header_value = "#{auth_type} #{@auth_header_value}"
  131. end
  132. 1374 @headers.add("authorization", @auth_header_value)
  133. 1374 @auth_token_value = auth_value
  134. end
  135. end
  136. 23 module AuthRetries
  137. 23 module InstanceMethods
  138. 23 private
  139. 23 def retryable_request?(request, response, options)
  140. 162 super || auth_error?(response, options)
  141. end
  142. 23 def retryable_response?(response, options)
  143. 144 auth_error?(response, options) || super
  144. end
  145. 23 def prepare_to_retry(request, response)
  146. 126 super
  147. 130 return unless auth_error?(response, request.options) ||
  148. 16 (@options.generate_auth_value_on_retry && @options.generate_auth_value_on_retry.call(response))
  149. # regenerate token before retry, but only if it's the first request from batch failing.
  150. # otherwise, it means that the first request already passed here, so this request should
  151. # use whatever was generated for it.
  152. 108 @auth_header_value_mtx.synchronize do
  153. 108 if request.auth_token_value == @auth_header_value
  154. 72 @auth_header_value = generate_auth_token
  155. 72 set_auth_header_expires_at(request)
  156. end
  157. end
  158. 108 request.unauthorize!
  159. end
  160. 23 def auth_error?(response, options)
  161. 288 response.is_a?(Response) && response.status == 401 && dynamic_auth_token?(options.auth_header_value)
  162. end
  163. end
  164. end
  165. end
  166. 23 register_plugin :auth, Auth
  167. end
  168. 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. 23 require "httpx/base64"
  3. 23 module HTTPX
  4. 23 module Plugins
  5. 23 module Authentication
  6. 23 class Basic
  7. 23 def initialize(user, password, **)
  8. 398 @user = user
  9. 398 @password = password
  10. end
  11. 23 def authenticate(*)
  12. 377 "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

88 relevant lines. 88 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 23 require "time"
  3. 23 require "securerandom"
  4. 23 require "digest"
  5. 23 module HTTPX
  6. 23 module Plugins
  7. 23 module Authentication
  8. 23 class Digest
  9. 23 class Error < Error
  10. end
  11. 23 def initialize(user, password, hashed: false, **)
  12. 216 @user = user
  13. 216 @password = password
  14. 216 @nonce = 0
  15. 216 @hashed = hashed
  16. end
  17. 23 def can_authenticate?(authenticate)
  18. 198 authenticate && /Digest .*/.match?(authenticate)
  19. end
  20. 23 def authenticate(request, authenticate)
  21. 198 "Digest #{generate_header(request.verb, request.path, authenticate)}"
  22. rescue StandardError => e
  23. 18 response = ErrorResponse.new(request, e)
  24. 18 request.response = response
  25. 18 request.emit_response(response)
  26. 18 nil
  27. end
  28. 23 private
  29. 23 def generate_header(meth, uri, authenticate)
  30. # discard first token, it's Digest
  31. 198 auth_info = authenticate[/^(\w+) (.*)/, 2]
  32. 198 raise_format_error unless auth_info
  33. 198 s = StringScanner.new(auth_info)
  34. 198 params = {}
  35. 302 until s.eos?
  36. 954 k = s.scan_until(/=/)
  37. 954 raise_format_error unless k&.end_with?("=")
  38. 936 if s.peek(1) == "\""
  39. 720 s.skip("\"")
  40. 720 v = s.scan_until(/"/)
  41. 720 raise_format_error unless v&.end_with?("\"")
  42. 720 v = v[0..-2]
  43. 720 s.skip_until(/,/)
  44. else
  45. 216 v = s.scan_until(/,|$/)
  46. 216 if v&.end_with?(",")
  47. 170 v = v[0..-2]
  48. else
  49. 46 raise_format_error unless s.eos?
  50. end
  51. 216 v = v[0..-2] if v&.end_with?(",")
  52. end
  53. 832 params[k[0..-2]] = v
  54. 936 s.skip(/\s/)
  55. end
  56. 180 nonce = params["nonce"]
  57. 180 nc = next_nonce
  58. # verify qop
  59. 180 qop = params["qop"]
  60. 180 if qop
  61. # some servers send multiple values wrapped in parentheses (i.e. "(qauth,)")
  62. 180 qop = qop[/\(?([^)]+)\)?/, 1]
  63. 360 qop = qop.split(",").map { |s| s.delete_prefix("'").delete_suffix("'") }.delete_if(&:empty?).map.first
  64. end
  65. 180 if params["algorithm"] =~ /(.*?)(-sess)?$/
  66. 162 alg = Regexp.last_match(1)
  67. 162 raise_format_error unless alg
  68. 162 algorithm = ::Digest.const_get(alg)
  69. 162 raise Error, "unknown algorithm \"#{alg}\"" unless algorithm
  70. 162 sess = Regexp.last_match(2)
  71. else
  72. 18 algorithm = ::Digest::MD5
  73. end
  74. 180 if qop || sess
  75. 180 cnonce = make_cnonce
  76. 180 nc = format("%<nonce>08x", nonce: nc)
  77. end
  78. 180 a1 = if sess
  79. 4 [
  80. 36 (@hashed ? @password : algorithm.hexdigest("#{@user}:#{params["realm"]}:#{@password}")),
  81. nonce,
  82. cnonce,
  83. 3 ].join ":"
  84. else
  85. 144 @hashed ? @password : "#{@user}:#{params["realm"]}:#{@password}"
  86. end
  87. 180 ha1 = algorithm.hexdigest(a1)
  88. 180 ha2 = algorithm.hexdigest("#{meth}:#{uri}")
  89. 180 request_digest = [ha1, nonce]
  90. 180 request_digest.push(nc, cnonce, qop) if qop
  91. 180 request_digest << ha2
  92. 180 request_digest = request_digest.join(":")
  93. 40 header = [
  94. 160 %(username="#{@user}"),
  95. 20 %(nonce="#{nonce}"),
  96. 20 %(uri="#{uri}"),
  97. 20 %(response="#{algorithm.hexdigest(request_digest)}"),
  98. ]
  99. 180 header << %(realm="#{params["realm"]}") if params.key?("realm")
  100. 180 header << %(algorithm=#{params["algorithm"]}) if params.key?("algorithm")
  101. 180 header << %(cnonce="#{cnonce}") if cnonce
  102. 180 header << %(nc=#{nc})
  103. 180 header << %(qop=#{qop}) if qop
  104. 180 header << %(opaque="#{params["opaque"]}") if params.key?("opaque")
  105. 180 header.join ", "
  106. end
  107. 23 def make_cnonce
  108. 200 ::Digest::MD5.hexdigest [
  109. Time.now.to_i,
  110. Process.pid,
  111. SecureRandom.random_number(2**32),
  112. ].join ":"
  113. end
  114. 23 def next_nonce
  115. 160 @nonce += 1
  116. end
  117. 23 def raise_format_error
  118. 18 raise Error, "unsupported digest header format"
  119. end
  120. end
  121. end
  122. end
  123. 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. 17 require "httpx/base64"
  3. 17 require "ntlm"
  4. 17 module HTTPX
  5. 17 module Plugins
  6. 17 module Authentication
  7. 17 class Ntlm
  8. 17 def initialize(user, password, domain: nil)
  9. 4 @user = user
  10. 4 @password = password
  11. 4 @domain = domain
  12. end
  13. 17 def can_authenticate?(authenticate)
  14. 2 authenticate && /NTLM .*/.match?(authenticate)
  15. end
  16. 17 def negotiate
  17. 4 "NTLM #{NTLM.negotiate(domain: @domain).to_base64}"
  18. end
  19. 17 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. 24 module HTTPX
  3. 24 module Plugins
  4. 24 module Authentication
  5. 24 class Socks5
  6. 24 def initialize(user, password, **)
  7. 54 @user = user
  8. 54 @password = password
  9. end
  10. 24 def can_authenticate?(*)
  11. 54 @user && @password
  12. end
  13. 24 def authenticate(*)
  14. 54 [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. 23 module HTTPX
  3. 23 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. 23 module AwsSdkAuthentication
  10. # Mock configuration, to be used only when resolving credentials
  11. 23 class Configuration
  12. 23 attr_reader :profile
  13. 23 def initialize(profile)
  14. 36 @profile = profile
  15. end
  16. 23 def respond_to_missing?(*)
  17. 18 true
  18. end
  19. 23 def method_missing(*); end
  20. end
  21. #
  22. # encapsulates access to an AWS SDK credentials store.
  23. #
  24. 23 class Credentials
  25. 23 def initialize(aws_credentials)
  26. 18 @aws_credentials = aws_credentials
  27. end
  28. 23 def username
  29. 18 @aws_credentials.access_key_id
  30. end
  31. 23 def password
  32. 18 @aws_credentials.secret_access_key
  33. end
  34. 23 def security_token
  35. 18 @aws_credentials.session_token
  36. end
  37. end
  38. 23 class << self
  39. 23 def load_dependencies(_klass)
  40. 18 require "aws-sdk-core"
  41. end
  42. 23 def configure(klass)
  43. 18 klass.plugin(:aws_sigv4)
  44. end
  45. 23 def extra_options(options)
  46. 18 options.merge(max_concurrent_requests: 1)
  47. end
  48. 23 def credentials(profile)
  49. 18 mock_configuration = Configuration.new(profile)
  50. 18 Credentials.new(Aws::CredentialProviderChain.new(mock_configuration).resolve)
  51. end
  52. 23 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. 18 keys = %w[AWS_REGION AMAZON_REGION AWS_DEFAULT_REGION]
  55. 18 env_region = ENV.values_at(*keys).compact.first
  56. 18 env_region = nil if env_region == ""
  57. 18 cfg_region = Aws.shared_config.region(profile: profile)
  58. 18 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. 23 module OptionsMethods
  65. 23 private
  66. 23 def option_aws_profile(value)
  67. 90 String(value)
  68. end
  69. end
  70. 23 module InstanceMethods
  71. #
  72. # aws_authentication
  73. # aws_authentication(credentials: Aws::Credentials.new('akid', 'secret'))
  74. # aws_authentication()
  75. #
  76. 23 def aws_sdk_authentication(
  77. credentials: AwsSdkAuthentication.credentials(@options.aws_profile),
  78. region: AwsSdkAuthentication.region(@options.aws_profile),
  79. **options
  80. )
  81. 18 aws_sigv4_authentication(
  82. credentials: credentials,
  83. region: region,
  84. provider_prefix: "aws",
  85. header_provider_field: "amz",
  86. **options
  87. )
  88. end
  89. 23 alias_method :aws_auth, :aws_sdk_authentication
  90. end
  91. end
  92. 23 register_plugin :aws_sdk_authentication, AwsSdkAuthentication
  93. end
  94. end

lib/httpx/plugins/aws_sigv4.rb

100.0% lines covered

122 relevant lines. 122 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 23 module HTTPX
  3. 23 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. 23 module AWSSigV4
  12. 23 Credentials = Struct.new(:username, :password, :security_token)
  13. # Signs requests using the AWS sigv4 signing.
  14. 23 class Signer
  15. 23 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. 171 @credentials = credentials || Credentials.new(username, password, security_token)
  29. 171 @service = service
  30. 171 @region = region
  31. 171 @unsigned_headers = Set.new(unsigned_headers.map(&:downcase))
  32. 171 @unsigned_headers << "authorization"
  33. 171 @unsigned_headers << "x-amzn-trace-id"
  34. 171 @unsigned_headers << "expect"
  35. 171 @apply_checksum_header = apply_checksum_header
  36. 171 @provider_prefix = provider_prefix
  37. 171 @header_provider_field = header_provider_field
  38. 171 @algorithm = algorithm
  39. end
  40. 23 def sign!(request)
  41. 171 lower_provider_prefix = "#{@provider_prefix}4"
  42. 171 upper_provider_prefix = lower_provider_prefix.upcase
  43. 171 downcased_algorithm = @algorithm.downcase
  44. 171 datetime = (request.headers["x-#{@header_provider_field}-date"] ||= Time.now.utc.strftime("%Y%m%dT%H%M%SZ"))
  45. 171 date = datetime[0, 8]
  46. 171 content_hashed = request.headers["x-#{@header_provider_field}-content-#{downcased_algorithm}"] || hexdigest(request.body)
  47. 162 request.headers["x-#{@header_provider_field}-content-#{downcased_algorithm}"] ||= content_hashed if @apply_checksum_header
  48. 162 request.headers["x-#{@header_provider_field}-security-token"] ||= @credentials.security_token if @credentials.security_token
  49. 162 signature_headers = request.headers.each.reject do |k, _|
  50. 1107 @unsigned_headers.include?(k)
  51. end
  52. # aws sigv4 needs to declare the host, regardless of protocol version
  53. 162 signature_headers << ["host", request.authority] unless request.headers.key?("host")
  54. 162 signature_headers.sort_by!(&:first)
  55. 162 signed_headers = signature_headers.map(&:first).join(";")
  56. 162 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. 162 creq = "#{request.verb}" \
  62. 18 "\n#{request.canonical_path}" \
  63. 18 "\n#{request.canonical_query}" \
  64. 18 "\n#{canonical_headers}" \
  65. 18 "\n#{signed_headers}" \
  66. 18 "\n#{content_hashed}"
  67. 162 credential_scope = "#{date}" \
  68. 18 "/#{@region}" \
  69. 18 "/#{@service}" \
  70. 18 "/#{lower_provider_prefix}_request"
  71. 162 algo_line = "#{upper_provider_prefix}-HMAC-#{@algorithm}"
  72. # string to sign
  73. 162 sts = "#{algo_line}" \
  74. 18 "\n#{datetime}" \
  75. 18 "\n#{credential_scope}" \
  76. 18 "\n#{OpenSSL::Digest.new(@algorithm).hexdigest(creq)}"
  77. # signature
  78. 162 k_date = hmac("#{upper_provider_prefix}#{@credentials.password}", date)
  79. 162 k_region = hmac(k_date, @region)
  80. 162 k_service = hmac(k_region, @service)
  81. 162 k_credentials = hmac(k_service, "#{lower_provider_prefix}_request")
  82. 162 sig = hexhmac(k_credentials, sts)
  83. 162 credential = "#{@credentials.username}/#{credential_scope}"
  84. # apply signature
  85. 144 request.headers["authorization"] =
  86. 18 "#{algo_line} " \
  87. 18 "Credential=#{credential}, " \
  88. 18 "SignedHeaders=#{signed_headers}, " \
  89. 18 "Signature=#{sig}"
  90. end
  91. 23 private
  92. 23 def hexdigest(value)
  93. 162 digest = OpenSSL::Digest.new(@algorithm)
  94. 162 if value.respond_to?(:read)
  95. 36 if value.respond_to?(:to_path)
  96. # files, pathnames
  97. 9 digest.file(value.to_path).hexdigest
  98. else
  99. # gzipped request bodies
  100. 27 raise Error, "request body must be rewindable" unless value.respond_to?(:rewind)
  101. 27 buffer = Tempfile.new("httpx", encoding: Encoding::BINARY, mode: File::RDWR)
  102. 2 begin
  103. 27 IO.copy_stream(value, buffer)
  104. 27 buffer.flush
  105. 27 digest.file(buffer.to_path).hexdigest
  106. ensure
  107. 27 value.rewind
  108. 27 buffer.close
  109. 27 buffer.unlink
  110. end
  111. end
  112. else
  113. # error on endless generators
  114. 126 raise Error, "hexdigest for endless enumerators is not supported" if value.unbounded_body?
  115. 117 mb_buffer = value.each.with_object("".b) do |chunk, b|
  116. 63 b << chunk
  117. 63 break if b.bytesize >= 1024 * 1024
  118. end
  119. 117 digest.hexdigest(mb_buffer)
  120. end
  121. end
  122. 23 def hmac(key, value)
  123. 648 OpenSSL::HMAC.digest(OpenSSL::Digest.new(@algorithm), key, value)
  124. end
  125. 23 def hexhmac(key, value)
  126. 162 OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new(@algorithm), key, value)
  127. end
  128. end
  129. 23 class << self
  130. 23 def load_dependencies(*)
  131. 171 require "set"
  132. 171 require "digest/sha2"
  133. 171 require "cgi/escape"
  134. end
  135. 23 def configure(klass)
  136. 171 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. 23 module OptionsMethods
  143. 23 private
  144. 23 def option_sigv4_signer(value)
  145. 360 value.is_a?(Signer) ? value : Signer.new(value)
  146. end
  147. end
  148. 23 module InstanceMethods
  149. 23 def aws_sigv4_authentication(**options)
  150. 171 with(sigv4_signer: Signer.new(**options))
  151. end
  152. 23 def build_request(*)
  153. 171 request = super
  154. 171 return request if request.headers.key?("authorization")
  155. 171 signer = request.options.sigv4_signer
  156. 171 return request unless signer
  157. 171 signer.sign!(request)
  158. 162 request
  159. end
  160. end
  161. 23 module RequestMethods
  162. 23 def canonical_path
  163. 162 path = uri.path.dup
  164. 162 path << "/" if path.empty?
  165. 198 path.gsub(%r{[^/]+}) { |part| CGI.escape(part.encode("UTF-8")).gsub("+", "%20").gsub("%7E", "~") }
  166. end
  167. 23 def canonical_query
  168. 198 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. 198 params.each.with_index.sort do |a, b|
  180. 72 a, a_offset = a
  181. 72 b, b_offset = b
  182. 72 a_name, a_value = a.split("=", 2)
  183. 72 b_name, b_value = b.split("=", 2)
  184. 72 if a_name == b_name
  185. 36 if a_value == b_value
  186. 18 a_offset <=> b_offset
  187. else
  188. 18 a_value <=> b_value
  189. end
  190. else
  191. 36 a_name <=> b_name
  192. end
  193. end.map(&:first).join("&")
  194. end
  195. end
  196. end
  197. 23 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. 23 module HTTPX
  3. 23 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. 23 module BasicAuth
  10. 23 class << self
  11. 23 def load_dependencies(_klass)
  12. 126 require_relative "auth/basic"
  13. end
  14. 23 def configure(klass)
  15. 126 klass.plugin(:auth)
  16. end
  17. end
  18. 23 module InstanceMethods
  19. 23 def basic_auth(user, password)
  20. 144 authorization(Authentication::Basic.new(user, password).authenticate)
  21. end
  22. end
  23. end
  24. 23 register_plugin :basic_auth, BasicAuth
  25. end
  26. end

lib/httpx/plugins/brotli.rb

100.0% lines covered

41 relevant lines. 41 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 22 module HTTPX
  3. 22 module Plugins
  4. 22 module Brotli
  5. 22 class Error < HTTPX::Error; end
  6. 22 class Deflater < Transcoder::Deflater
  7. 22 def initialize(body)
  8. 14 @compressor = ::Brotli::Compressor.new
  9. 14 super
  10. end
  11. 22 def deflate(chunk)
  12. 42 return @compressor.process(chunk) << @compressor.flush if chunk
  13. 28 @compressor.finish
  14. end
  15. end
  16. 22 class Inflater
  17. 22 def initialize(bytesize)
  18. 15 @inflater = ::Brotli::Decompressor.new
  19. 15 @bytesize = bytesize
  20. end
  21. 22 def call(chunk)
  22. 36 buffer = @inflater.process(chunk)
  23. 36 @bytesize -= chunk.bytesize
  24. 36 raise Error, "Unexpected end of compressed stream" if @bytesize <= 0 && !@inflater.finished?
  25. 36 buffer
  26. end
  27. end
  28. 22 module RequestBodyClassMethods
  29. 22 def initialize_deflater_body(body, encoding)
  30. 28 return Brotli.encode(body) if encoding == "br"
  31. 14 super
  32. end
  33. end
  34. 22 module ResponseBodyClassMethods
  35. 22 def initialize_inflater_by_encoding(encoding, response, **kwargs)
  36. 29 return Brotli.decode(response, **kwargs) if encoding == "br"
  37. 14 super
  38. end
  39. end
  40. 22 module_function
  41. 22 def load_dependencies(*)
  42. 29 gem "brotli", ">= 0.8.0"
  43. 29 require "brotli"
  44. end
  45. 22 def self.extra_options(options)
  46. 29 supported_compression_formats = (%w[br] + options.supported_compression_formats).freeze
  47. 29 options.merge(
  48. supported_compression_formats: supported_compression_formats,
  49. headers: options.headers_class.new(options.headers.merge("accept-encoding" => supported_compression_formats))
  50. )
  51. end
  52. 22 def encode(body)
  53. 14 Deflater.new(body)
  54. end
  55. 22 def decode(response, bytesize: nil)
  56. 15 bytesize ||= response.headers.key?("content-length") ? response.headers["content-length"].to_i : Float::INFINITY
  57. 15 Inflater.new(bytesize)
  58. end
  59. end
  60. 22 register_plugin :brotli, Brotli
  61. end
  62. end

lib/httpx/plugins/cache.rb

100.0% lines covered

94 relevant lines. 94 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 23 module HTTPX
  3. 23 module Plugins
  4. #
  5. # This plugin adds support for caching and reusing responses
  6. #
  7. # https://gitlab.com/os85/httpx/wikis/Cache
  8. #
  9. 23 module Cache
  10. 23 class << self
  11. 23 def load_dependencies(*)
  12. 324 require_relative "cache/store"
  13. 324 require_relative "cache/file_store"
  14. end
  15. 23 def extra_options(options)
  16. 324 options.merge(
  17. response_cache_store: :store,
  18. )
  19. end
  20. end
  21. # adds support for the following options:
  22. #
  23. # :cache_key :: callable which receives a request and returns the corresponding cache key as a string
  24. # (to be used by the cache store when storing cached responses)
  25. # :cacheable_request :: callable which receives a request and returns whether this request can use a previously cached response,
  26. # or for which a freshly retrieved response can be cached.
  27. # :cacheable_response :: callable which receives a request and a (freshly retrieved) response and returns whether the response
  28. # can be cached.
  29. # :valid_cached_response :: callable which receives a request and a (previously cached) response and returns whether the response
  30. # can still be used / returned to the caller.
  31. # :response_cache_store :: object where cached responses are fetch from or stored in; defaults to <tt>:store</tt> (in-memory
  32. # cache), can be set to <tt>:file_store</tt> (file system cache store) as well, or any object which
  33. # abides by the Cache Store Interface
  34. #
  35. # The Cache Store Interface requires implementation of the following methods:
  36. #
  37. # * +#get(request) -> response or nil+
  38. # * +#set(request, response) -> void+
  39. # * +#clear() -> void+)
  40. #
  41. 23 module OptionsMethods
  42. 23 private
  43. 23 def option_cache_key(v)
  44. 18 raise TypeError, "`:cache_key` must be a callable" unless v.respond_to?(:call)
  45. 18 v
  46. end
  47. 23 def option_cacheable_request(v)
  48. 18 raise TypeError, "`:cacheable_request` must be a callable" unless v.respond_to?(:call)
  49. 18 v
  50. end
  51. 23 def option_cacheable_response(v)
  52. 18 raise TypeError, "`:cacheable_response` must be a callable" unless v.respond_to?(:call)
  53. 18 v
  54. end
  55. 23 def option_valid_cached_response(v)
  56. 18 raise TypeError, "`:valid_cached_response` must be a callable" unless v.respond_to?(:call)
  57. 18 v
  58. end
  59. 23 def option_response_cache_store(value)
  60. 728 case value
  61. when :store
  62. 360 Store.new
  63. when :file_store
  64. 36 FileStore.new
  65. else
  66. 423 value
  67. end
  68. end
  69. end
  70. 23 module InstanceMethods
  71. # wipes out all cached responses from the cache store.
  72. 23 def clear_response_cache
  73. 189 @options.response_cache_store.clear
  74. end
  75. 23 def build_request(*)
  76. 684 request = super
  77. 684 return request unless cacheable_request?(request)
  78. 630 prepare_cache(request)
  79. 630 request
  80. end
  81. 23 private
  82. 23 def send_request(request, *)
  83. 315 return request if request.response
  84. 279 super
  85. end
  86. 23 def fetch_response(request, *)
  87. 594 response = super
  88. 594 return unless response
  89. 315 if cacheable_request?(request) && cacheable_response?(request, response) && !response.cached?
  90. 144 log { "caching response for #{request.uri}..." }
  91. 144 request.options.response_cache_store.set(request, response)
  92. end
  93. 315 response
  94. end
  95. # whether +request+ can use cached responses.
  96. 23 def cacheable_request?(request)
  97. 270 return false unless (call = request.options.cacheable_request)
  98. 252 call[request]
  99. end
  100. # whether the retrieved +response+ can be cached.
  101. 23 def cacheable_response?(request, response)
  102. 153 return false unless (call = request.options.cacheable_response)
  103. 90 call[request, response]
  104. end
  105. # whether the cached +cached_response+ is still valid for the current +request+
  106. 23 def valid_cached_response?(request, cached_response)
  107. 18 return false unless (call = request.options.valid_cached_response)
  108. 18 call[request, cached_response]
  109. end
  110. # will either assign a still-fresh cached response to +request+, or set up its HTTP
  111. # cache invalidation headers in case it's not fresh anymore.
  112. 23 def prepare_cache(request)
  113. 882 cached_response = retrieve_cached_response(request)
  114. 882 return unless cached_response && valid_cached_response?(request, cached_response)
  115. 90 request.cached_response = nil
  116. # if the cached response is still usable, we use it
  117. 90 cached_response.body.rewind
  118. 90 cached_response = cached_response.dup
  119. 90 cached_response.mark_as_cached!
  120. 90 request.response = cached_response
  121. 90 request.emit_response(cached_response)
  122. end
  123. # calls the cache store to retrieve the cached response for +request+. Caches it
  124. # for convenience of subplugins in order to minimize overhead of retrieval (which may
  125. # involve network).
  126. 23 def retrieve_cached_response(request)
  127. 1602 request.cached_response ||= request.options.response_cache_store.get(request)
  128. end
  129. end
  130. 23 module RequestMethods
  131. # points to a previously cached Response corresponding to this request.
  132. 23 attr_accessor :cached_response
  133. 23 def initialize(*)
  134. 855 super
  135. 855 @cached_response = nil
  136. end
  137. 23 def merge_headers(*)
  138. 342 super
  139. 342 @response_cache_key = nil
  140. end
  141. # returns a unique cache key as a String identifying this request
  142. 23 def response_cache_key
  143. 162 return unless (call = @options.cache_key)
  144. 162 call[self]
  145. end
  146. end
  147. 23 module ResponseMethods
  148. 23 attr_writer :original_request
  149. 23 def initialize(*)
  150. 684 super
  151. 684 @cached = false
  152. end
  153. # a copy of the request this response was originally cached from
  154. 23 def original_request
  155. 108 @original_request || @request
  156. end
  157. # whether this Response was duplicated from a previously {RequestMethods#cached_response}.
  158. 23 def cached?
  159. 710 @cached
  160. end
  161. # sets this Response as being duplicated from a previously cached response.
  162. 23 def mark_as_cached!
  163. 261 @cached = true
  164. end
  165. end
  166. 23 module ResponseBodyMethods
  167. 23 def decode_chunk(chunk)
  168. 530 return chunk if @response.cached?
  169. 345 super
  170. end
  171. end
  172. end
  173. 23 register_plugin :cache, Cache
  174. end
  175. end

lib/httpx/plugins/cache/file_store.rb

100.0% lines covered

73 relevant lines. 73 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 23 require "pathname"
  3. 23 module HTTPX::Plugins
  4. 23 module Cache
  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. 23 class FileStore
  11. 23 CRLF = HTTPX::Connection::HTTP1::CRLF
  12. 23 attr_reader :dir
  13. 23 def initialize(dir = Dir.tmpdir)
  14. 117 @dir = Pathname.new(dir).join("httpx-response-cache")
  15. 117 FileUtils.mkdir_p(@dir)
  16. end
  17. 23 def clear
  18. 81 FileUtils.rm_rf(@dir)
  19. end
  20. 23 def get(request)
  21. 522 path = file_path(request)
  22. 522 return unless File.exist?(path)
  23. 162 File.open(path, mode: File::RDONLY | File::BINARY) do |f|
  24. 162 f.flock(File::Constants::LOCK_SH)
  25. 162 read_from_file(request, f)
  26. end
  27. end
  28. 23 def set(request, response)
  29. 117 path = file_path(request)
  30. 117 file_exists = File.exist?(path)
  31. 117 mode = file_exists ? File::RDWR : File::CREAT | File::Constants::WRONLY
  32. 117 File.open(path, mode: mode | File::BINARY) do |f|
  33. 117 f.flock(File::Constants::LOCK_EX)
  34. 117 if file_exists
  35. 9 cached_response = read_from_file(request, f)
  36. 9 if cached_response
  37. 9 next if cached_response == request.cached_response
  38. 9 cached_response.close
  39. 9 f.truncate(0)
  40. 9 f.rewind
  41. end
  42. end
  43. # cache the request headers
  44. 117 f << request.verb << CRLF
  45. 117 f << request.uri << CRLF
  46. 117 request.headers.each do |field, value|
  47. 351 f << field << ":" << value << CRLF
  48. end
  49. 117 f << CRLF
  50. # cache the response
  51. 117 f << response.status << CRLF
  52. 117 f << response.version << CRLF
  53. 117 response.headers.each do |field, value|
  54. 333 f << field << ":" << value << CRLF
  55. end
  56. 117 f << CRLF
  57. 117 response.body.rewind
  58. 117 IO.copy_stream(response.body, f)
  59. end
  60. end
  61. 23 private
  62. 23 def file_path(request)
  63. 639 @dir.join(request.response_cache_key)
  64. end
  65. 23 def read_from_file(request, f)
  66. # if it's an empty file
  67. 171 return if f.eof?
  68. # read request data
  69. 171 verb = f.readline.delete_suffix!(CRLF)
  70. 171 uri = f.readline.delete_suffix!(CRLF)
  71. 171 request_headers = {}
  72. 760 while (line = f.readline) != CRLF
  73. 513 line.delete_suffix!(CRLF)
  74. 513 sep_index = line.index(":")
  75. 513 field = line.byteslice(0..(sep_index - 1))
  76. 513 value = line.byteslice((sep_index + 1)..-1)
  77. 456 request_headers[field] = value
  78. end
  79. 171 status = f.readline.delete_suffix!(CRLF)
  80. 171 version = f.readline.delete_suffix!(CRLF)
  81. 171 response_headers = {}
  82. 736 while (line = f.readline) != CRLF
  83. 486 line.delete_suffix!(CRLF)
  84. 486 sep_index = line.index(":")
  85. 486 field = line.byteslice(0..(sep_index - 1))
  86. 486 value = line.byteslice((sep_index + 1)..-1)
  87. 432 response_headers[field] = value
  88. end
  89. 171 original_request = request.options.request_class.new(verb, uri, request.options)
  90. 171 original_request.merge_headers(request_headers)
  91. 171 response = request.options.response_class.new(request, status, version, response_headers)
  92. 171 response.original_request = original_request
  93. 171 response.finish!
  94. 171 response.mark_as_cached!
  95. 171 IO.copy_stream(f, response.body)
  96. 171 response
  97. end
  98. end
  99. end
  100. end

lib/httpx/plugins/cache/store.rb

100.0% lines covered

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

lib/httpx/plugins/callbacks.rb

92.65% lines covered

68 relevant lines. 63 lines covered and 5 lines missed.
    
  1. # frozen_string_literal: true
  2. 30 module HTTPX
  3. 30 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. 30 module Callbacks
  10. 30 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. 30 class CallbackError < Exception; end # rubocop:disable Lint/InheritException
  20. 30 module InstanceMethods
  21. 30 include HTTPX::Callbacks
  22. 30 CALLBACKS.each do |meth|
  23. 270 class_eval(<<-MOD, __FILE__, __LINE__ + 1)
  24. 9 def on_#{meth}(&blk) # def on_connection_opened(&blk)
  25. 9 on(:#{meth}, &blk) # on(:connection_opened, &blk)
  26. self # self
  27. end # end
  28. MOD
  29. end
  30. 30 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. 30 private
  40. 30 def branch(options, &blk)
  41. 36 super(options).tap do |sess|
  42. 36 CALLBACKS.each do |cb|
  43. 324 next unless callbacks_for?(cb)
  44. 18 sess.callbacks(cb).concat(callbacks(cb))
  45. end
  46. 36 sess.wrap(&blk) if blk
  47. end
  48. end
  49. 30 def do_init_connection(connection, selector)
  50. 253 super
  51. 253 connection.on(:open) do
  52. 219 next unless connection.current_session == self
  53. 219 emit_or_callback_error(:connection_opened, connection.origin, connection.io.socket)
  54. end
  55. 253 connection.on(:callback_connection_closed) do
  56. 244 next unless connection.current_session == self
  57. 244 emit_or_callback_error(:connection_closed, connection.origin) if connection.used?
  58. end
  59. 253 connection
  60. end
  61. 30 def set_request_callbacks(request)
  62. 255 super
  63. 255 request.on(:headers) do
  64. 201 emit_or_callback_error(:request_started, request)
  65. end
  66. 255 request.on(:body_chunk) do |chunk|
  67. 18 emit_or_callback_error(:request_body_chunk, request, chunk)
  68. end
  69. 255 request.on(:done) do
  70. 183 emit_or_callback_error(:request_completed, request)
  71. end
  72. 255 request.on(:response_started) do |res|
  73. 201 if res.is_a?(Response)
  74. 165 emit_or_callback_error(:response_started, request, res)
  75. 147 res.on(:chunk_received) do |chunk|
  76. 167 emit_or_callback_error(:response_body_chunk, request, res, chunk)
  77. end
  78. else
  79. 36 emit_or_callback_error(:request_error, request, res.error)
  80. end
  81. end
  82. 255 request.on(:response) do |res|
  83. 147 emit_or_callback_error(:response_completed, request, res) if res.is_a?(Response)
  84. end
  85. end
  86. 30 def emit_or_callback_error(*args)
  87. 1344 emit(*args)
  88. rescue StandardError => e
  89. 153 ex = CallbackError.new(e.message)
  90. 153 ex.set_backtrace(e.backtrace)
  91. 153 raise ex
  92. end
  93. 30 def receive_requests(*)
  94. 255 super
  95. rescue CallbackError => e
  96. 135 raise e.cause
  97. end
  98. 30 def close(*)
  99. 253 super
  100. rescue CallbackError => e
  101. 9 raise e.cause
  102. end
  103. end
  104. 30 module ConnectionMethods
  105. 30 private
  106. 30 def disconnect
  107. 244 return if @exhausted
  108. 244 return unless @current_session && @current_selector
  109. 244 emit(:callback_connection_closed)
  110. 217 super
  111. end
  112. end
  113. end
  114. 30 register_plugin :callbacks, Callbacks
  115. end
  116. end

lib/httpx/plugins/circuit_breaker.rb

100.0% lines covered

67 relevant lines. 67 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 23 module HTTPX
  3. 23 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. 23 module CircuitBreaker
  10. 23 using URIExtensions
  11. 23 def self.load_dependencies(*)
  12. 63 require_relative "circuit_breaker/circuit"
  13. 63 require_relative "circuit_breaker/circuit_store"
  14. end
  15. 23 def self.extra_options(options)
  16. 63 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. 23 module InstanceMethods
  24. 23 include HTTPX::Callbacks
  25. 23 def initialize(*)
  26. 63 super
  27. 63 @circuit_store = CircuitStore.new(@options)
  28. end
  29. 23 %i[circuit_open].each do |meth|
  30. 23 class_eval(<<-MOD, __FILE__, __LINE__ + 1)
  31. 1 def on_#{meth}(&blk) # def on_circuit_open(&blk)
  32. 1 on(:#{meth}, &blk) # on(:circuit_open, &blk)
  33. self # self
  34. end # end
  35. MOD
  36. end
  37. 23 private
  38. 23 def send_requests(*requests)
  39. # @type var short_circuit_responses: Array[response]
  40. 252 short_circuit_responses = []
  41. # run all requests through the circuit breaker, see if the circuit is
  42. # open for any of them.
  43. 252 real_requests = requests.each_with_index.with_object([]) do |(req, idx), real_reqs|
  44. 252 short_circuit_response = @circuit_store.try_respond(req)
  45. 252 if short_circuit_response.nil?
  46. 198 real_reqs << req
  47. 198 next
  48. end
  49. 48 short_circuit_responses[idx] = short_circuit_response
  50. end
  51. # run requests for the remainder
  52. 252 unless real_requests.empty?
  53. 198 responses = super(*real_requests)
  54. 198 real_requests.each_with_index do |request, idx|
  55. 176 short_circuit_responses[requests.index(request)] = responses[idx]
  56. end
  57. end
  58. 252 short_circuit_responses
  59. end
  60. 23 def set_request_callbacks(request)
  61. 252 super
  62. 252 request.on(:response) do |response|
  63. 198 emit(:circuit_open, request) if try_circuit_open(request, response)
  64. end
  65. end
  66. 23 def try_circuit_open(request, response)
  67. 198 if response.is_a?(ErrorResponse)
  68. 128 case response.error
  69. when RequestTimeoutError
  70. 90 @circuit_store.try_open(request.uri, response)
  71. else
  72. 54 @circuit_store.try_open(request.origin, response)
  73. end
  74. 54 elsif (break_on = request.options.circuit_breaker_break_on) && break_on.call(response)
  75. 18 @circuit_store.try_open(request.uri, response)
  76. else
  77. 36 @circuit_store.try_close(request.uri)
  78. 16 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. 23 module OptionsMethods
  93. 23 private
  94. 23 def option_circuit_breaker_max_attempts(value)
  95. 126 attempts = Integer(value)
  96. 126 raise TypeError, ":circuit_breaker_max_attempts must be positive" unless attempts.positive?
  97. 126 attempts
  98. end
  99. 23 def option_circuit_breaker_reset_attempts_in(value)
  100. 72 timeout = Float(value)
  101. 72 raise TypeError, ":circuit_breaker_reset_attempts_in must be positive" unless timeout.positive?
  102. 72 timeout
  103. end
  104. 23 def option_circuit_breaker_break_in(value)
  105. 99 timeout = Float(value)
  106. 99 raise TypeError, ":circuit_breaker_break_in must be positive" unless timeout.positive?
  107. 99 timeout
  108. end
  109. 23 def option_circuit_breaker_half_open_drip_rate(value)
  110. 99 ratio = Float(value)
  111. 99 raise TypeError, ":circuit_breaker_half_open_drip_rate must be a number between 0 and 1" unless (0..1).cover?(ratio)
  112. 99 ratio
  113. end
  114. 23 def option_circuit_breaker_break_on(value)
  115. 18 raise TypeError, ":circuit_breaker_break_on must be called with the response" unless value.respond_to?(:call)
  116. 18 value
  117. end
  118. end
  119. end
  120. 23 register_plugin :circuit_breaker, CircuitBreaker
  121. end
  122. end

lib/httpx/plugins/circuit_breaker/circuit.rb

100.0% lines covered

48 relevant lines. 48 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 23 module HTTPX
  3. 23 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. 23 class Circuit
  13. 23 def initialize(max_attempts, reset_attempts_in, break_in, circuit_breaker_half_open_drip_rate)
  14. 63 @max_attempts = max_attempts
  15. 63 @reset_attempts_in = reset_attempts_in
  16. 63 @break_in = break_in
  17. 63 @circuit_breaker_half_open_drip_rate = circuit_breaker_half_open_drip_rate
  18. 63 @attempts = 0
  19. 63 @opened_at = @attempted_at = @response = nil
  20. 63 total_real_attempts = @max_attempts * @circuit_breaker_half_open_drip_rate
  21. 63 @drip_factor = (@max_attempts / total_real_attempts).round
  22. 63 @state = :closed
  23. end
  24. 23 def respond
  25. 252 try_close
  26. 224 case @state
  27. when :closed
  28. 68 nil
  29. when :half_open
  30. 56 @attempts += 1
  31. # do real requests while drip rate valid
  32. 63 if (@real_attempts % @drip_factor).zero?
  33. 40 @real_attempts += 1
  34. 40 return
  35. end
  36. 18 @response
  37. when :open
  38. 36 @response
  39. end
  40. end
  41. 23 def try_open(response)
  42. 144 case @state
  43. when :closed
  44. 135 now = Utils.now
  45. 135 if @attempts.positive?
  46. # reset if error happened long ago
  47. 54 @attempts = 0 if now - @attempted_at > @reset_attempts_in
  48. else
  49. 81 @attempted_at = now
  50. end
  51. 120 @attempts += 1
  52. 135 return unless @attempts >= @max_attempts
  53. 72 @state = :open
  54. 72 @opened_at = now
  55. 72 @response = response
  56. when :half_open
  57. # open immediately
  58. 27 @state = :open
  59. 27 @attempted_at = @opened_at = Utils.now
  60. 27 @response = response
  61. end
  62. end
  63. 23 def try_close
  64. 256 case @state
  65. when :closed
  66. 68 nil
  67. when :half_open
  68. # do not close circuit unless attempts exhausted
  69. 54 return unless @attempts >= @max_attempts
  70. # reset!
  71. 18 @attempts = 0
  72. 18 @opened_at = @attempted_at = @response = nil
  73. 18 @state = :closed
  74. when :open
  75. 81 if Utils.elapsed_time(@opened_at) > @break_in
  76. 45 @state = :half_open
  77. 45 @attempts = 0
  78. 45 @real_attempts = 0
  79. end
  80. end
  81. end
  82. end
  83. end
  84. 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. 23 module HTTPX::Plugins::CircuitBreaker
  3. 23 using HTTPX::URIExtensions
  4. 23 class CircuitStore
  5. 23 def initialize(options)
  6. 63 @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. 63 @circuits_mutex = Thread::Mutex.new
  15. end
  16. 23 def try_open(uri, response)
  17. 324 circuit = @circuits_mutex.synchronize { get_circuit_for_uri(uri) }
  18. 162 circuit.try_open(response)
  19. end
  20. 23 def try_close(uri)
  21. 36 circuit = @circuits_mutex.synchronize do
  22. 36 return unless @circuits.key?(uri.origin) || @circuits.key?(uri.to_s)
  23. 36 get_circuit_for_uri(uri)
  24. end
  25. 36 circuit.try_close
  26. end
  27. # if circuit is open, it'll respond with the stored response.
  28. # if not, nil.
  29. 23 def try_respond(request)
  30. 504 circuit = @circuits_mutex.synchronize { get_circuit_for_uri(request.uri) }
  31. 252 circuit.respond
  32. end
  33. 23 private
  34. 23 def get_circuit_for_uri(uri)
  35. 450 if uri.respond_to?(:origin) && @circuits.key?(uri.origin)
  36. 324 @circuits[uri.origin]
  37. else
  38. 126 @circuits[uri.to_s]
  39. end
  40. end
  41. end
  42. end

lib/httpx/plugins/content_digest.rb

100.0% lines covered

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

lib/httpx/plugins/cookies.rb

100.0% lines covered

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

lib/httpx/plugins/cookies/cookie.rb

97.65% lines covered

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

lib/httpx/plugins/cookies/jar.rb

100.0% lines covered

73 relevant lines. 73 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 23 module HTTPX
  3. 23 module Plugins::Cookies
  4. # The Cookie Jar
  5. #
  6. # It stores and manages cookies for a session, such as i.e. evicting when expired, access methods, or
  7. # initialization from parsing `Set-Cookie` HTTP header values.
  8. #
  9. # It closely follows the [CookieStore API](https://developer.mozilla.org/en-US/docs/Web/API/CookieStore),
  10. # by implementing the same methods, with a few specific conveniences for this non-browser manipulation use-case.
  11. #
  12. 23 class Jar
  13. 23 using URIExtensions
  14. 23 include Enumerable
  15. 23 def initialize_dup(orig)
  16. 243 super
  17. 243 @mtx = orig.instance_variable_get(:@mtx).dup
  18. 243 @cookies = orig.instance_variable_get(:@cookies).dup
  19. end
  20. # initializes the cookie store, either empty, or with whatever is passed as +cookies+, which
  21. # can be an array of HTTPX::Plugins::Cookies::Cookie objects or hashes-or-tuples of cookie attributes.
  22. 23 def initialize(cookies = nil)
  23. 774 @mtx = Thread::Mutex.new
  24. 774 @cookies = []
  25. 162 cookies.each do |elem|
  26. 270 cookie = case elem
  27. when Cookie
  28. 27 elem
  29. when Array
  30. 225 Cookie.new(*elem)
  31. else
  32. 18 Cookie.new(elem)
  33. end
  34. 270 @cookies << cookie
  35. 773 end if cookies
  36. end
  37. # parses the `Set-Cookie` header value as +set_cookie+ and does the corresponding updates.
  38. 23 def parse(set_cookie)
  39. 162 SetCookieParser.call(set_cookie) do |name, value, attrs|
  40. 234 set(Cookie.new(name, value, attrs))
  41. end
  42. end
  43. # returns the first HTTPX::Plugins::Cookie::Cookie instance in the store which matches either the name
  44. # (when String) or all attributes (when a Hash or array of tuples) passed to +name_or_options+
  45. 23 def get(name_or_options)
  46. 108 each.find { |ck| ck.match?(name_or_options) }
  47. end
  48. # returns all HTTPX::Plugins::Cookie::Cookie instances in the store which match either the name
  49. # (when String) or all attributes (when a Hash or array of tuples) passed to +name_or_options+
  50. 23 def get_all(name_or_options)
  51. 135 each.select { |ck| ck.match?(name_or_options) } # rubocop:disable Style/SelectByRegexp
  52. end
  53. # when +name+ is a HTTPX::Plugins::Cookie::Cookie, it stores it internally; when +name+ is a String,
  54. # it creates a cookie with it and the value-or-attributes passed to +value_or_options+.
  55. # optionally, +name+ can also be the attributes hash-or-array as long it contains a <tt>:name</tt> field).
  56. 23 def set(name, value_or_options = nil)
  57. 576 cookie = case name
  58. when Cookie
  59. 504 raise ArgumentError, "there should not be a second argument" if value_or_options
  60. 495 name
  61. when Array, Hash
  62. 18 raise ArgumentError, "there should not be a second argument" if value_or_options
  63. 9 Cookie.new(name)
  64. else
  65. 54 raise ArgumentError, "the second argument is required" unless value_or_options
  66. 45 Cookie.new(name, value_or_options)
  67. end
  68. 549 synchronize do
  69. # If the user agent receives a new cookie with the same cookie-name, domain-value, and path-value
  70. # as a cookie that it has already stored, the existing cookie is evicted and replaced with the new cookie.
  71. 1008 @cookies.delete_if { |ck| ck.name == cookie.name && ck.domain == cookie.domain && ck.path == cookie.path }
  72. 549 @cookies << cookie
  73. end
  74. end
  75. # @deprecated
  76. 23 def add(cookie, path = nil)
  77. 9 warn "DEPRECATION WARNING: calling `##{__method__}` is deprecated. Use `#set` instead."
  78. 9 c = cookie.dup
  79. 9 c.path = path if path && c.path == "/"
  80. 9 set(c)
  81. end
  82. # deletes all cookies in the store which match either the name (when String) or all attributes (when a Hash
  83. # or array of tuples) passed to +name_or_options+.
  84. #
  85. # alternatively, of +name_or_options+ is an instance of HTTPX::Plugins::Cookies::Cookiem, it deletes it from the store.
  86. 23 def delete(name_or_options)
  87. 36 synchronize do
  88. 32 case name_or_options
  89. when Cookie
  90. 9 @cookies.delete(name_or_options)
  91. else
  92. 54 @cookies.delete_if { |ck| ck.match?(name_or_options) }
  93. end
  94. end
  95. end
  96. # returns the list of valid cookies which matdh the domain and path from the URI object passed to +uri+.
  97. 23 def [](uri)
  98. 531 each(uri).sort
  99. end
  100. # enumerates over all stored cookies. if +uri+ is passed, it'll filter out expired cookies and
  101. # only yield cookies which match its domain and path.
  102. 23 def each(uri = nil, &blk)
  103. 1764 return enum_for(__method__, uri) unless blk
  104. 1431 return synchronize { @cookies.each(&blk) } unless uri
  105. 531 now = Time.now
  106. 531 tpath = uri.path
  107. 531 synchronize do
  108. 531 @cookies.delete_if do |cookie|
  109. 819 if cookie.expired?(now)
  110. 18 true
  111. else
  112. 801 yield cookie if cookie.valid_for_uri?(uri) && Cookie.path_match?(cookie.path, tpath)
  113. 801 false
  114. end
  115. end
  116. end
  117. end
  118. 23 def merge(other)
  119. 225 jar_dup = dup
  120. 225 other.each do |elem|
  121. 243 cookie = case elem
  122. when Cookie
  123. 225 elem
  124. when Array
  125. 9 Cookie.new(*elem)
  126. else
  127. 9 Cookie.new(elem)
  128. end
  129. 243 jar_dup.set(cookie)
  130. end
  131. 225 jar_dup
  132. end
  133. 23 private
  134. 23 def synchronize(&block)
  135. 1566 return yield if @mtx.owned?
  136. 1566 @mtx.synchronize(&block)
  137. end
  138. end
  139. end
  140. 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. 23 require "strscan"
  3. 23 require "time"
  4. 23 module HTTPX
  5. 23 module Plugins::Cookies
  6. 23 module SetCookieParser
  7. # Whitespace.
  8. 23 RE_WSP = /[ \t]+/.freeze
  9. # A pattern that matches a cookie name or attribute name which may
  10. # be empty, capturing trailing whitespace.
  11. 23 RE_NAME = /(?!#{RE_WSP})[^,;\\"=]*/.freeze
  12. 23 RE_BAD_CHAR = /([\x00-\x20\x7F",;\\])/.freeze
  13. # A pattern that matches the comma in a (typically date) value.
  14. 23 RE_COOKIE_COMMA = /,(?=#{RE_WSP}?#{RE_NAME}=)/.freeze
  15. 23 module_function
  16. 23 def scan_dquoted(scanner)
  17. 18 s = +""
  18. 24 until scanner.eos?
  19. 72 break if scanner.skip(/"/)
  20. 54 if scanner.skip(/\\/)
  21. 18 s << scanner.getch
  22. 35 elsif scanner.scan(/[^"\\]+/)
  23. 36 s << scanner.matched
  24. end
  25. end
  26. 18 s
  27. end
  28. 23 def scan_value(scanner, comma_as_separator = false)
  29. 495 value = +""
  30. 553 until scanner.eos?
  31. 855 if scanner.scan(/[^,;"]+/)
  32. 486 value << scanner.matched
  33. 368 elsif scanner.skip(/"/)
  34. # RFC 6265 2.2
  35. # A cookie-value may be DQUOTE'd.
  36. 18 value << scan_dquoted(scanner)
  37. 350 elsif scanner.check(/;/)
  38. 261 break
  39. 89 elsif comma_as_separator && scanner.check(RE_COOKIE_COMMA)
  40. 72 break
  41. else
  42. 18 value << scanner.getch
  43. end
  44. end
  45. 495 value.rstrip!
  46. 495 value
  47. end
  48. 23 def scan_name_value(scanner, comma_as_separator = false)
  49. 495 name = scanner.scan(RE_NAME)
  50. 495 name.rstrip! if name
  51. 495 if scanner.skip(/=/)
  52. 486 value = scan_value(scanner, comma_as_separator)
  53. else
  54. 9 scan_value(scanner, comma_as_separator)
  55. 9 value = nil
  56. end
  57. 495 [name, value]
  58. end
  59. 23 def call(set_cookie)
  60. 162 scanner = StringScanner.new(set_cookie)
  61. # RFC 6265 4.1.1 & 5.2
  62. 188 until scanner.eos?
  63. 234 start = scanner.pos
  64. 234 len = nil
  65. 234 scanner.skip(RE_WSP)
  66. 234 name, value = scan_name_value(scanner, true)
  67. 234 value = nil if name && name.empty?
  68. 234 attrs = {}
  69. 263 until scanner.eos?
  70. 333 if scanner.skip(/,/)
  71. # The comma is used as separator for concatenating multiple
  72. # values of a header.
  73. 72 len = (scanner.pos - 1) - start
  74. 72 break
  75. 260 elsif scanner.skip(/;/)
  76. 261 scanner.skip(RE_WSP)
  77. 261 aname, avalue = scan_name_value(scanner, true)
  78. 261 next if (aname.nil? || aname.empty?) || value.nil?
  79. 261 aname.downcase!
  80. 232 case aname
  81. when "expires"
  82. 18 next unless avalue
  83. # RFC 6265 5.2.1
  84. 18 (avalue = Time.parse(avalue)) || next
  85. when "max-age"
  86. 9 next unless avalue
  87. # RFC 6265 5.2.2
  88. 9 next unless /\A-?\d+\z/.match?(avalue)
  89. 9 avalue = Integer(avalue)
  90. when "domain"
  91. # RFC 6265 5.2.3
  92. # An empty value SHOULD be ignored.
  93. 27 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. 198 next unless avalue && avalue.start_with?("/")
  99. when "secure", "httponly"
  100. # RFC 6265 5.2.5, 5.2.6
  101. 9 avalue = true
  102. end
  103. 232 attrs[aname] = avalue
  104. end
  105. end
  106. 234 len ||= scanner.pos - start
  107. 234 next if len > Cookie::MAX_LENGTH
  108. 234 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

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

lib/httpx/plugins/expect.rb

100.0% lines covered

71 relevant lines. 71 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 23 module HTTPX
  3. 23 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. 23 module Expect
  10. 23 EXPECT_TIMEOUT = 2
  11. 23 NOEXPECT_STORE_MUTEX = Thread::Mutex.new
  12. 23 class Store
  13. 23 def initialize
  14. 10 @store = []
  15. 10 @mutex = Thread::Mutex.new
  16. end
  17. 23 def include?(host)
  18. 310 @mutex.synchronize { @store.include?(host) }
  19. end
  20. 23 def add(host)
  21. 36 @mutex.synchronize { @store << host }
  22. end
  23. 23 def delete(host)
  24. 18 @mutex.synchronize { @store.delete(host) }
  25. end
  26. end
  27. 23 class << self
  28. 23 def no_expect_store
  29. 182 return Ractor.store_if_absent(:httpx_no_expect_store) { Store.new } if Utils.in_ractor?
  30. 182 @no_expect_store ||= NOEXPECT_STORE_MUTEX.synchronize do
  31. 10 @no_expect_store || Store.new
  32. end
  33. end
  34. 23 def extra_options(options)
  35. 227 options.merge(expect_timeout: EXPECT_TIMEOUT)
  36. end
  37. end
  38. # adds support for the following options:
  39. #
  40. # :expect_timeout :: time (in seconds) to wait for a 100-expect response,
  41. # before retrying without the Expect header (defaults to <tt>2</tt>).
  42. # :expect_threshold_size :: min threshold (in bytes) of the request payload to enable the 100-continue negotiation on.
  43. 23 module OptionsMethods
  44. 23 private
  45. 23 def option_expect_timeout(value)
  46. 416 seconds = Float(value)
  47. 416 raise TypeError, ":expect_timeout must be positive" unless seconds.positive?
  48. 416 seconds
  49. end
  50. 23 def option_expect_threshold_size(value)
  51. 18 bytes = Integer(value)
  52. 18 raise TypeError, ":expect_threshold_size must be positive" unless bytes.positive?
  53. 18 bytes
  54. end
  55. end
  56. 23 module RequestMethods
  57. 23 def initialize(*)
  58. 263 super
  59. 263 @informational_status = nil
  60. 263 return if @body.empty?
  61. 173 threshold = @options.expect_threshold_size
  62. 173 return if threshold && !@body.unbounded_body? && @body.bytesize < threshold
  63. 155 return if Expect.no_expect_store.include?(origin)
  64. 130 @headers["expect"] = "100-continue"
  65. end
  66. 23 def response=(response)
  67. 159 if response.is_a?(Response) &&
  68. response.status == 100 &&
  69. !@headers.key?("expect") &&
  70. 4 (@state == :body || @state == :done)
  71. # if we're past this point, this means that we just received a 100-Continue response,
  72. # but the request doesn't have the expect flag, and is already flushing (or flushed) the body.
  73. #
  74. # this means that expect was deactivated for this request too soon, i.e. response took longer.
  75. #
  76. # so we have to reactivate it again.
  77. 8 @headers["expect"] = "100-continue"
  78. 9 @informational_status = 100
  79. 9 Expect.no_expect_store.delete(origin)
  80. end
  81. 159 super
  82. end
  83. end
  84. 23 module ConnectionMethods
  85. 23 def send_request_to_parser(request)
  86. 94 super
  87. 94 return unless request.headers["expect"] == "100-continue"
  88. 74 expect_timeout = request.options.expect_timeout
  89. 74 return if expect_timeout.nil? || expect_timeout.infinite?
  90. 74 set_request_timeout(:expect_timeout, request, expect_timeout, :expect, %i[body response]) do
  91. # expect timeout expired
  92. 18 if request.state == :expect && !request.expects?
  93. 18 Expect.no_expect_store.add(request.origin)
  94. 18 request.headers.delete("expect")
  95. 18 consume
  96. end
  97. end
  98. end
  99. end
  100. 23 module InstanceMethods
  101. 23 def fetch_response(request, selector, options)
  102. 188 response = super
  103. 188 return unless response
  104. 94 if response.is_a?(Response) && response.status == 417 && request.headers.key?("expect")
  105. 2 response.close
  106. 2 request.headers.delete("expect")
  107. 2 request.transition(:idle)
  108. 2 send_request(request, selector, options)
  109. # recalling itself, in case an error was triggered by the above, and we can
  110. # verify retriability again.
  111. 2 return fetch_response(request, selector, options)
  112. end
  113. 92 response
  114. end
  115. end
  116. end
  117. 23 register_plugin :expect, Expect
  118. end
  119. end

lib/httpx/plugins/fiber_concurrency.rb

88.89% lines covered

117 relevant lines. 104 lines covered and 13 lines missed.
    
  1. # frozen_string_literal: true
  2. 30 module HTTPX
  3. 30 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/Fiber-Concurrency
  9. #
  10. 30 module FiberConcurrency
  11. 30 def self.subplugins
  12. 130 {
  13. 2120 h2c: FiberConcurrencyH2C,
  14. stream: FiberConcurrencyStream,
  15. }
  16. end
  17. 30 module InstanceMethods
  18. 30 private
  19. 30 def send_request(request, *)
  20. 909 request.set_context!
  21. 909 super
  22. end
  23. 30 def get_current_selector
  24. 750 super(&nil) || begin
  25. 625 return unless block_given?
  26. 625 default = yield
  27. 625 set_current_selector(default)
  28. 625 default
  29. end
  30. end
  31. end
  32. 30 module RequestMethods
  33. # the execution context (fiber) this request was sent on.
  34. 30 attr_reader :context
  35. 30 def initialize(*)
  36. 790 super
  37. 790 @context = nil
  38. end
  39. # sets the execution context for this request. the default is the current fiber.
  40. 30 def set_context!
  41. 909 @context ||= Fiber.current # rubocop:disable Naming/MemoizedInstanceVariableName
  42. end
  43. # checks whether the current execution context is the one where the request was created.
  44. 30 def current_context?
  45. 3998 @context == Fiber.current
  46. end
  47. 30 def complete!(response = @response)
  48. 759 @context = nil
  49. 759 super
  50. end
  51. end
  52. 30 module ConnectionMethods
  53. 30 def current_context?
  54. 679 @pending.any?(&:current_context?) || (
  55. 161 @sibling && @sibling.pending.any?(&:current_context?)
  56. )
  57. end
  58. 30 def interests
  59. 13216 return if connecting? && @pending.none?(&:current_context?)
  60. 13158 super
  61. end
  62. 30 def on_io_error(e)
  63. 50 return super unless e.is_a?(IOError) && e.message.include?("stream closed in another thread")
  64. # @fiber-switch-guard
  65. # sockets closed during fiber scheduler switches are raised in separate fibers than the fiber the
  66. # socket may be used in. this check verifies that this is actually about this socket.
  67. 50 return unless to_io.closed?
  68. 14 if @state == :closing
  69. # @fiber-switch-guard
  70. # if the connection is reused across fibers, the socket may have been closed in the other fiber
  71. # and switched here during the process, so continue what it was doing and transition to closed
  72. # via #call.
  73. 14 call
  74. elsif !backlog?
  75. super
  76. end
  77. end
  78. 30 def on_connect_error(e)
  79. 18 return super unless e.is_a?(IOError) && e.message.include?("stream closed in another thread")
  80. # @fiber-switch-guard
  81. # sockets closed during fiber scheduler switches are raised in separate fibers than the fiber the
  82. # socket may be used in. this check verifies that this is actually about this socket.
  83. return unless to_io.closed? && !backlog?
  84. super
  85. end
  86. 30 private
  87. # checks whether the connection has any pending request (which the connection itself may
  88. # have stored, or it may be somewhere in the parser).
  89. 30 def backlog?
  90. @pending.any? ||
  91. (@parser && (@parser.pending.any? || @parser.requests.any?))
  92. end
  93. end
  94. 30 module HTTP1Methods
  95. 30 def interests
  96. 1323 request = @request || @requests.first
  97. 1323 return unless request
  98. 1277 return unless request.current_context? || @requests.any?(&:current_context?) || @pending.any?(&:current_context?)
  99. 1277 super
  100. end
  101. end
  102. 30 module HTTP2Methods
  103. 30 def initialize(*)
  104. 493 super
  105. 1528 @contexts = Hash.new { |hs, k| hs[k] = Set.new }
  106. end
  107. 30 def interests
  108. 10426 if @connection.state == :connected && @handshake_completed && !@contexts.key?(Fiber.current)
  109. 658 return :w unless @pings.empty?
  110. 529 return
  111. end
  112. 9768 super
  113. end
  114. 30 def send(request, *)
  115. 1125 add_to_context(request)
  116. 1125 super
  117. end
  118. 30 private
  119. 30 def on_close(_, error, _)
  120. 20 if error == :http_1_1_required
  121. # remove all pending requests context
  122. @pending.each do |req|
  123. clear_from_context(req)
  124. end
  125. end
  126. 20 super
  127. end
  128. 30 def on_stream_close(_, request, error)
  129. 571 clear_from_context(request) if error != :stream_closed && @streams.key?(request)
  130. 571 super
  131. end
  132. 30 def teardown(request = nil)
  133. 563 super
  134. 563 if request
  135. 543 clear_from_context(request)
  136. else
  137. 20 @contexts.clear
  138. end
  139. end
  140. 30 def add_to_context(request)
  141. 1125 @contexts[request.context] << request
  142. end
  143. 30 def clear_from_context(request)
  144. 1086 requests = @contexts[request.context]
  145. 1086 requests.delete(request)
  146. 1086 @contexts.delete(request.context) if requests.empty?
  147. end
  148. end
  149. 30 module ResolverNativeMethods
  150. 30 def calculate_interests
  151. 562 return if @queries.empty?
  152. 518 return unless @queries.values.any?(&:current_context?) || @connections.any?(&:current_context?)
  153. 518 super
  154. end
  155. 30 def disconnect
  156. 40 return unless @connections.all?(&:current_context?)
  157. 40 super
  158. end
  159. 30 def on_io_error(e)
  160. # TODO: return super if this is not stream clsed in another thread
  161. 4 log { "IO Erroring: #{e.message}, current:#{@name}, queries:#{@queries.size}" }
  162. 4 return unless @name
  163. super
  164. end
  165. end
  166. 30 module ResolverSystemMethods
  167. 30 def interests
  168. return unless @queries.any? { |_, conn| conn.current_context? }
  169. super
  170. end
  171. end
  172. 30 module FiberConcurrencyH2C
  173. 30 module HTTP2Methods
  174. 30 def upgrade(request, *)
  175. @contexts[request.context] << request
  176. super
  177. end
  178. end
  179. end
  180. 30 module FiberConcurrencyStream
  181. 30 module StreamResponseMethods
  182. 30 def close
  183. 9 unless @request.current_context?
  184. 9 @request.close
  185. 9 return
  186. end
  187. super
  188. end
  189. end
  190. end
  191. end
  192. 30 register_plugin :fiber_concurrency, FiberConcurrency
  193. end
  194. end

lib/httpx/plugins/follow_redirects.rb

100.0% lines covered

117 relevant lines. 117 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 30 module HTTPX
  3. 30 class InsecureRedirectError < Error
  4. end
  5. 30 module Plugins
  6. #
  7. # This plugin adds support for automatically following redirect (status 30X) responses.
  8. #
  9. # It has a default upper bound of followed redirects (see *MAX_REDIRECTS* and the *max_redirects* option),
  10. # after which it will return the last redirect response. It will **not** raise an exception.
  11. #
  12. # It doesn't follow insecure redirects (https -> http) by default (see *follow_insecure_redirects*).
  13. #
  14. # It doesn't propagate authorization related headers to requests redirecting to different origins
  15. # (see *allow_auth_to_other_origins*) to override.
  16. #
  17. # It allows customization of when to redirect via the *redirect_on* callback option).
  18. #
  19. # https://gitlab.com/os85/httpx/wikis/Follow-Redirects
  20. #
  21. 30 module FollowRedirects
  22. 30 MAX_REDIRECTS = 3
  23. 30 REDIRECT_STATUS = (300..399).freeze
  24. 30 REQUEST_BODY_HEADERS = %w[transfer-encoding content-encoding content-type content-length content-language content-md5 trailer].freeze
  25. 30 using URIExtensions
  26. # adds support for the following options:
  27. #
  28. # :max_redirects :: max number of times a request will be redirected (defaults to <tt>3</tt>).
  29. # :follow_insecure_redirects :: whether redirects to an "http://" URI, when coming from an "https//", are allowed
  30. # (defaults to <tt>false</tt>).
  31. # :allow_auth_to_other_origins :: whether auth-related headers, such as "Authorization", are propagated on redirection
  32. # (defaults to <tt>false</tt>).
  33. # :redirect_on :: optional callback which receives the redirect location and can halt the redirect chain if it returns <tt>false</tt>.
  34. 30 module OptionsMethods
  35. 30 private
  36. 30 def option_max_redirects(value)
  37. 718 num = Integer(value)
  38. 718 raise TypeError, ":max_redirects must be positive" if num.negative?
  39. 718 num
  40. end
  41. 30 def option_follow_insecure_redirects(value)
  42. 27 value
  43. end
  44. 30 def option_allow_auth_to_other_origins(value)
  45. 27 value
  46. end
  47. 30 def option_redirect_on(value)
  48. 54 raise TypeError, ":redirect_on must be callable" unless value.respond_to?(:call)
  49. 54 value
  50. end
  51. end
  52. 30 module InstanceMethods
  53. # returns a session with the *max_redirects* option set to +n+
  54. 30 def max_redirects(n)
  55. 72 with(max_redirects: n.to_i)
  56. end
  57. 30 private
  58. 30 def fetch_response(request, selector, options)
  59. 1374 redirect_request = request.redirect_request
  60. 1374 response = super(redirect_request, selector, options)
  61. 1374 return unless response
  62. 730 max_redirects = redirect_request.max_redirects
  63. 730 return response unless response.is_a?(Response)
  64. 694 return response unless REDIRECT_STATUS.include?(response.status) && response.headers.key?("location")
  65. 479 return response unless max_redirects.positive?
  66. 443 redirect_uri = __get_location_from_response(response)
  67. 443 if options.redirect_on
  68. 36 redirect_allowed = options.redirect_on.call(redirect_uri)
  69. 36 return response unless redirect_allowed
  70. end
  71. # build redirect request
  72. 425 request_body = redirect_request.body
  73. 425 redirect_method = "GET"
  74. 425 redirect_params = {}
  75. 425 if response.status == 305 && options.respond_to?(:proxy)
  76. 9 request_body.rewind
  77. # The requested resource MUST be accessed through the proxy given by
  78. # the Location field. The Location field gives the URI of the proxy.
  79. 9 redirect_options = options.merge(headers: redirect_request.headers,
  80. proxy: { uri: redirect_uri },
  81. max_redirects: max_redirects - 1)
  82. 8 redirect_params[:body] = request_body
  83. 9 redirect_uri = redirect_request.uri
  84. 9 options = redirect_options
  85. else
  86. 416 redirect_headers = redirect_request_headers(redirect_request.uri, redirect_uri, request.headers, options)
  87. 416 redirect_opts = Hash[options]
  88. 371 redirect_params[:max_redirects] = max_redirects - 1
  89. 416 unless request_body.empty?
  90. 27 if response.status == 307
  91. # The method and the body of the original request are reused to perform the redirected request.
  92. 9 redirect_method = redirect_request.verb
  93. 9 request_body.rewind
  94. 8 redirect_params[:body] = request_body
  95. else
  96. # redirects are **ALWAYS** GET, so remove body-related headers
  97. 18 REQUEST_BODY_HEADERS.each do |h|
  98. 126 redirect_headers.delete(h)
  99. end
  100. 16 redirect_params[:body] = nil
  101. end
  102. end
  103. 416 options = options.class.new(redirect_opts.merge(headers: redirect_headers.to_h))
  104. end
  105. 425 redirect_uri = Utils.to_uri(redirect_uri)
  106. 425 if !options.follow_insecure_redirects &&
  107. response.uri.scheme == "https" &&
  108. redirect_uri.scheme == "http"
  109. 9 error = InsecureRedirectError.new(redirect_uri.to_s)
  110. 9 error.set_backtrace(caller)
  111. 8 return ErrorResponse.new(request, error)
  112. end
  113. 416 retry_request = build_request(redirect_method, redirect_uri, redirect_params, options)
  114. 416 request.redirect_request = retry_request
  115. 416 redirect_after = response.headers["retry-after"]
  116. 416 if redirect_after
  117. # Servers send the "Retry-After" header field to indicate how long the
  118. # user agent ought to wait before making a follow-up request.
  119. # When sent with any 3xx (Redirection) response, Retry-After indicates
  120. # the minimum time that the user agent is asked to wait before issuing
  121. # the redirected request.
  122. #
  123. 108 redirect_after = Utils.parse_retry_after(redirect_after)
  124. 108 retry_start = Utils.now
  125. 108 log { "redirecting after #{redirect_after} secs..." }
  126. 108 selector.after(redirect_after) do
  127. 108 if (response = request.response)
  128. 36 response.finish!
  129. 36 retry_request.response = response
  130. # request has terminated abruptly meanwhile
  131. 36 retry_request.emit_response(response)
  132. else
  133. 72 log { "redirecting (elapsed time: #{Utils.elapsed_time(retry_start)})!!" }
  134. 72 send_request(retry_request, selector, options)
  135. end
  136. end
  137. else
  138. 308 send_request(retry_request, selector, options)
  139. # recalling itself, in case an error was triggered by the above, and we can
  140. # verify retriability again.
  141. 308 return fetch_response(request, selector, options)
  142. end
  143. 48 nil
  144. end
  145. # :nodoc:
  146. 30 def redirect_request_headers(original_uri, redirect_uri, headers, options)
  147. 416 headers = headers.dup
  148. 416 return headers if options.allow_auth_to_other_origins
  149. 407 return headers unless headers.key?("authorization")
  150. 9 return headers if original_uri.origin == redirect_uri.origin
  151. 9 headers.delete("authorization")
  152. 9 headers
  153. end
  154. # :nodoc:
  155. 30 def __get_location_from_response(response)
  156. # @type var location_uri: http_uri
  157. 443 location_uri = URI(response.headers["location"])
  158. 443 location_uri = response.uri.merge(location_uri) if location_uri.relative?
  159. 443 location_uri
  160. end
  161. end
  162. 30 module RequestMethods
  163. # returns the top-most original HTTPX::Request from the redirect chain
  164. 30 attr_accessor :root_request
  165. 30 def initialize(*)
  166. 730 super
  167. 730 @redirect_request = nil
  168. end
  169. 30 def on_response_arrived=(cb)
  170. 1310 @redirect_request.on_response_arrived = cb if @redirect_request
  171. 1310 super
  172. end
  173. # returns the follow-up redirect request, or itself
  174. 30 def redirect_request
  175. 1374 @redirect_request || self
  176. end
  177. # sets the follow-up redirect request
  178. 30 def redirect_request=(req)
  179. 416 @redirect_request = req
  180. 416 req.root_request = @root_request || self
  181. 416 req.on_response_arrived = @on_response_arrived
  182. 416 @response = nil
  183. end
  184. 30 def response
  185. 3853 return super unless @redirect_request && @response.nil?
  186. 176 @redirect_request.response
  187. end
  188. 30 def max_redirects
  189. 730 @options.max_redirects || MAX_REDIRECTS
  190. end
  191. end
  192. 30 module ConnectionMethods
  193. 30 private
  194. 30 def set_request_request_timeout(request)
  195. 680 return unless request.root_request.nil?
  196. 307 super
  197. end
  198. end
  199. end
  200. 30 register_plugin :follow_redirects, FollowRedirects
  201. end
  202. 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. 21 module HTTPX
  3. 21 GRPCError = Class.new(Error) do
  4. 21 attr_reader :status, :details, :metadata
  5. 21 def initialize(status, details, metadata)
  6. 28 @status = status
  7. 28 @details = details
  8. 28 @metadata = metadata
  9. 28 super("GRPC error, code=#{status}, details=#{details}, metadata=#{metadata}")
  10. end
  11. end
  12. 21 module Plugins
  13. #
  14. # This plugin adds DSL to build GRPC interfaces.
  15. #
  16. # https://gitlab.com/os85/httpx/wikis/GRPC
  17. #
  18. 21 module GRPC
  19. 21 unless String.method_defined?(:underscore)
  20. 21 module StringExtensions
  21. 21 refine String do
  22. 21 def underscore
  23. 364 s = dup # Avoid mutating the argument, as it might be frozen.
  24. 364 s.gsub!(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
  25. 364 s.gsub!(/([a-z\d])([A-Z])/, '\1_\2')
  26. 364 s.tr!("-", "_")
  27. 364 s.downcase!
  28. 364 s
  29. end
  30. end
  31. end
  32. 21 using StringExtensions
  33. end
  34. 21 DEADLINE = 60
  35. 21 MARSHAL_METHOD = :encode
  36. 21 UNMARSHAL_METHOD = :decode
  37. 21 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. 21 class << self
  45. 21 def load_dependencies(*)
  46. 161 require "stringio"
  47. 161 require "httpx/plugins/grpc/grpc_encoding"
  48. 161 require "httpx/plugins/grpc/message"
  49. 161 require "httpx/plugins/grpc/call"
  50. end
  51. 21 def configure(klass)
  52. 161 klass.plugin(:persistent)
  53. 161 klass.plugin(:stream)
  54. end
  55. 21 def extra_options(options)
  56. 161 options.merge(
  57. fallback_protocol: "h2",
  58. grpc_rpcs: {}.freeze,
  59. grpc_compression: false,
  60. grpc_deadline: DEADLINE
  61. )
  62. end
  63. end
  64. 21 module OptionsMethods
  65. 21 private
  66. 21 def option_grpc_service(value)
  67. 140 String(value)
  68. end
  69. 21 def option_grpc_compression(value)
  70. 350 case value
  71. when true, false
  72. 322 value
  73. else
  74. 28 value.to_s
  75. end
  76. end
  77. 21 def option_grpc_rpcs(value)
  78. 1463 Hash[value]
  79. end
  80. 21 def option_grpc_deadline(value)
  81. 1099 raise TypeError, ":grpc_deadline must be positive" unless value.positive?
  82. 1099 value
  83. end
  84. 21 def option_call_credentials(value)
  85. 21 raise TypeError, ":call_credentials must respond to #call" unless value.respond_to?(:call)
  86. 21 value
  87. end
  88. end
  89. 21 module ResponseMethods
  90. 21 attr_reader :trailing_metadata
  91. 21 def merge_headers(trailers)
  92. 133 @trailing_metadata = Hash[trailers]
  93. 133 super
  94. end
  95. end
  96. 21 module RequestBodyMethods
  97. 21 def initialize(*, **)
  98. 147 super
  99. 147 if (compression = @headers["grpc-encoding"])
  100. 14 deflater_body = self.class.initialize_deflater_body(@body, compression)
  101. 14 @body = Transcoder::GRPCEncoding.encode(deflater_body || @body, compressed: !deflater_body.nil?)
  102. else
  103. 133 @body = Transcoder::GRPCEncoding.encode(@body, compressed: false)
  104. end
  105. end
  106. end
  107. 21 module InstanceMethods
  108. 21 def with_channel_credentials(ca_path, key = nil, cert = nil, **ssl_opts)
  109. # @type var ssl_params: ::Hash[::Symbol, untyped]
  110. 84 ssl_params = {
  111. **ssl_opts,
  112. ca_file: ca_path,
  113. }
  114. 84 if key
  115. 84 key = File.read(key) if File.file?(key)
  116. 84 ssl_params[:key] = OpenSSL::PKey.read(key)
  117. end
  118. 84 if cert
  119. 84 cert = File.read(cert) if File.file?(cert)
  120. 84 ssl_params[:cert] = OpenSSL::X509::Certificate.new(cert)
  121. end
  122. 84 with(ssl: ssl_params)
  123. end
  124. 21 def rpc(rpc_name, input, output, **opts)
  125. 364 rpc_name = rpc_name.to_s
  126. 364 raise Error, "rpc #{rpc_name} already defined" if @options.grpc_rpcs.key?(rpc_name)
  127. rpc_opts = {
  128. 364 deadline: @options.grpc_deadline,
  129. }.merge(opts)
  130. 364 local_rpc_name = rpc_name.underscore
  131. 364 session_class = Class.new(self.class) do
  132. # define rpc method with ruby style name
  133. 364 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. 364 unless local_rpc_name == rpc_name
  140. 14 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. 364 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. 21 def build_stub(origin, service: nil, compression: false)
  154. 161 scheme = @options.ssl.empty? ? "http" : "https"
  155. 161 origin = URI.parse("#{scheme}://#{origin}")
  156. 161 session = self
  157. 161 if service && service.respond_to?(:rpc_descs)
  158. # it's a grpc generic service
  159. 70 service.rpc_descs.each do |rpc_name, rpc_desc|
  160. rpc_opts = {
  161. 350 marshal_method: rpc_desc.marshal_method,
  162. unmarshal_method: rpc_desc.unmarshal_method,
  163. }
  164. 350 input = rpc_desc.input
  165. 350 input = input.type if input.respond_to?(:type)
  166. 350 output = rpc_desc.output
  167. 350 if output.respond_to?(:type)
  168. 140 rpc_opts[:stream] = true
  169. 140 output = output.type
  170. end
  171. 350 session = session.rpc(rpc_name, input, output, **rpc_opts)
  172. end
  173. 70 service = service.service_name
  174. end
  175. 161 session.with(origin: origin, grpc_service: service, grpc_compression: compression)
  176. end
  177. 21 def execute(rpc_method, input,
  178. deadline: DEADLINE,
  179. metadata: nil,
  180. **opts)
  181. 147 grpc_request = build_grpc_request(rpc_method, input, deadline: deadline, metadata: metadata, **opts)
  182. 147 response = request(grpc_request, **opts)
  183. 147 response.raise_for_status unless opts[:stream]
  184. 133 GRPC::Call.new(response)
  185. end
  186. 21 private
  187. 21 def rpc_execute(rpc_name, input, **opts)
  188. 70 rpc_name, input_enc, output_enc, rpc_opts = @options.grpc_rpcs[rpc_name]
  189. 70 exec_opts = rpc_opts.merge(opts)
  190. 70 marshal_method ||= exec_opts.delete(:marshal_method) || MARSHAL_METHOD
  191. 70 unmarshal_method ||= exec_opts.delete(:unmarshal_method) || UNMARSHAL_METHOD
  192. 70 messages = if input.respond_to?(:each)
  193. 28 Enumerator.new do |y|
  194. 28 input.each do |message|
  195. 56 y << input_enc.__send__(marshal_method, message)
  196. end
  197. end
  198. else
  199. 42 input_enc.__send__(marshal_method, input)
  200. end
  201. 70 call = execute(rpc_name, messages, **exec_opts)
  202. 70 call.decoder = output_enc.method(unmarshal_method)
  203. 70 call
  204. end
  205. 21 def build_grpc_request(rpc_method, input, deadline:, metadata: nil, **opts)
  206. 147 uri = @options.origin.dup
  207. 147 rpc_method = "/#{rpc_method}" unless rpc_method.start_with?("/")
  208. 147 rpc_method = "/#{@options.grpc_service}#{rpc_method}" if @options.grpc_service
  209. 147 uri.path = rpc_method
  210. 147 headers = HEADERS.merge(
  211. "grpc-accept-encoding" => ["identity", *@options.supported_compression_formats]
  212. )
  213. 147 unless deadline == Float::INFINITY
  214. # convert to milliseconds
  215. 147 deadline = (deadline * 1000.0).to_i
  216. 147 headers["grpc-timeout"] = "#{deadline}m"
  217. end
  218. 147 headers = headers.merge(metadata.transform_keys(&:to_s)) if metadata
  219. # prepare compressor
  220. 147 compression = @options.grpc_compression == true ? "gzip" : @options.grpc_compression
  221. 147 headers["grpc-encoding"] = compression if compression
  222. 147 headers.merge!(@options.call_credentials.call.transform_keys(&:to_s)) if @options.call_credentials
  223. 147 build_request("POST", uri, headers: headers, body: input, **opts)
  224. end
  225. end
  226. end
  227. 21 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. 21 module HTTPX
  3. 21 module Plugins
  4. 21 module GRPC
  5. # Encapsulates call information
  6. 21 class Call
  7. 21 attr_writer :decoder
  8. 21 def initialize(response)
  9. 133 @response = response
  10. 182 @decoder = ->(z) { z }
  11. 133 @consumed = false
  12. 133 @grpc_response = nil
  13. end
  14. 21 def inspect
  15. "#{self.class}(#{grpc_response})"
  16. end
  17. 21 def to_s
  18. 77 grpc_response.to_s
  19. end
  20. 21 def metadata
  21. response.headers
  22. end
  23. 21 def trailing_metadata
  24. 84 return unless @consumed
  25. 56 @response.trailing_metadata
  26. end
  27. 21 private
  28. 21 def grpc_response
  29. 217 @grpc_response ||= if @response.respond_to?(:each)
  30. 28 Enumerator.new do |y|
  31. 28 Message.stream(@response).each do |message|
  32. 56 y << @decoder.call(message)
  33. end
  34. 28 @consumed = true
  35. end
  36. else
  37. 105 @consumed = true
  38. 105 @decoder.call(Message.unary(@response))
  39. end
  40. end
  41. 21 def respond_to_missing?(meth, *args, &blk)
  42. 28 grpc_response.respond_to?(meth, *args) || super
  43. end
  44. 21 def method_missing(meth, *args, &blk)
  45. 56 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. 21 module HTTPX
  3. 21 module Transcoder
  4. 21 module GRPCEncoding
  5. 21 class Deflater
  6. 21 extend Forwardable
  7. 21 attr_reader :content_type
  8. 21 def initialize(body, compressed:)
  9. 147 @content_type = body.content_type
  10. 147 @body = BodyReader.new(body)
  11. 147 @compressed = compressed
  12. end
  13. 21 def bytesize
  14. 525 return @body.bytesize if @body.respond_to?(:bytesize)
  15. Float::INFINITY
  16. end
  17. 21 def read(length = nil, outbuf = nil)
  18. 322 buf = @body.read(length, outbuf)
  19. 294 return unless buf
  20. 161 compressed_flag = @compressed ? 1 : 0
  21. 161 buf = outbuf if outbuf
  22. 161 buf = buf.b if buf.frozen?
  23. 161 buf.prepend([compressed_flag, buf.bytesize].pack("CL>"))
  24. 161 buf
  25. end
  26. end
  27. 21 class Inflater
  28. 21 def initialize(response)
  29. 105 @response = response
  30. 105 @grpc_encodings = nil
  31. end
  32. 21 def call(message, &blk)
  33. 133 data = "".b
  34. 133 until message.empty?
  35. 133 compressed, size = message.unpack("CL>")
  36. 133 encoded_data = message.byteslice(5..(size + 5 - 1))
  37. 133 if compressed == 1
  38. 14 grpc_encodings.reverse_each do |encoding|
  39. 14 decoder = @response.body.class.initialize_inflater_by_encoding(encoding, @response, bytesize: encoded_data.bytesize)
  40. 14 encoded_data = decoder.call(encoded_data)
  41. 14 blk.call(encoded_data) if blk
  42. 14 data << encoded_data
  43. end
  44. else
  45. 119 blk.call(encoded_data) if blk
  46. 119 data << encoded_data
  47. end
  48. 133 message = message.byteslice((size + 5)..-1)
  49. end
  50. 133 data
  51. end
  52. 21 private
  53. 21 def grpc_encodings
  54. 14 @grpc_encodings ||= @response.headers.get("grpc-encoding")
  55. end
  56. end
  57. 21 def self.encode(*args, **kwargs)
  58. 147 Deflater.new(*args, **kwargs)
  59. end
  60. 21 def self.decode(response)
  61. 105 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. 21 module HTTPX
  3. 21 module Plugins
  4. 21 module GRPC
  5. # Encoding module for GRPC responses
  6. #
  7. # Can encode and decode grpc messages.
  8. 21 module Message
  9. 21 module_function
  10. # decodes a unary grpc response
  11. 21 def unary(response)
  12. 105 verify_status(response)
  13. 77 decoder = Transcoder::GRPCEncoding.decode(response)
  14. 77 decoder.call(response.to_s)
  15. end
  16. # lazy decodes a grpc stream response
  17. 21 def stream(response, &block)
  18. 56 return enum_for(__method__, response) unless block
  19. 28 decoder = Transcoder::GRPCEncoding.decode(response)
  20. 28 response.each do |frame|
  21. 56 decoder.call(frame, &block)
  22. end
  23. 28 verify_status(response)
  24. end
  25. 21 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. 21 def verify_status(response)
  31. # return standard errors if need be
  32. 133 response.raise_for_status
  33. 133 status = Integer(response.headers["grpc-status"])
  34. 133 message = response.headers["grpc-message"]
  35. 133 return if status.zero?
  36. 28 response.close
  37. 28 raise GRPCError.new(status, message, response.trailing_metadata)
  38. end
  39. end
  40. end
  41. end
  42. end

lib/httpx/plugins/h2c.rb

94.74% lines covered

57 relevant lines. 54 lines covered and 3 lines missed.
    
  1. # frozen_string_literal: true
  2. 30 module HTTPX
  3. 30 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. 30 module H2C
  11. 30 VALID_H2C_VERBS = %w[GET OPTIONS HEAD].freeze
  12. 30 class << self
  13. 30 def load_dependencies(klass)
  14. 18 klass.plugin(:upgrade)
  15. end
  16. 30 def call(connection, request, response)
  17. 18 connection.upgrade_to_h2c(request, response)
  18. end
  19. 30 def extra_options(options)
  20. 20 options.merge(
  21. 18 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. 30 module OptionsMethods
  28. 30 def option_h2c_class(value)
  29. 18 value
  30. end
  31. end
  32. 30 module RequestMethods
  33. 30 def valid_h2c_verb?
  34. 18 VALID_H2C_VERBS.include?(@verb)
  35. end
  36. end
  37. 30 module ConnectionMethods
  38. 30 using URIExtensions
  39. 30 def initialize(*)
  40. 18 super
  41. 18 @h2c_handshake = false
  42. end
  43. 30 def send(request)
  44. 45 return super if @h2c_handshake
  45. 18 return super unless request.valid_h2c_verb? && request.scheme == "http"
  46. 18 return super if @upgrade_protocol == "h2c"
  47. 18 @h2c_handshake = true
  48. # build upgrade request
  49. 18 request.headers.add("connection", "upgrade")
  50. 18 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. 18 super
  54. end
  55. 30 def upgrade_to_h2c(request, response)
  56. 18 enqueue_pending_requests_from_parser(@parser)
  57. 18 @parser = request.options.h2c_class.new(@write_buffer, @options)
  58. 18 set_parser_callbacks(@parser)
  59. 16 @inflight += 1 # request is being completed below
  60. 18 @parser.upgrade(request, response)
  61. 18 @upgrade_protocol = "h2c"
  62. end
  63. 30 private
  64. 30 def send_request_to_parser(request)
  65. 63 super
  66. 63 return unless request.headers["upgrade"] == "h2c" && parser.is_a?(Connection::HTTP1)
  67. 18 max_concurrent_requests = parser.max_concurrent_requests
  68. 18 return if max_concurrent_requests == 1
  69. parser.max_concurrent_requests = 1
  70. request.once(:response) do
  71. parser.max_concurrent_requests = max_concurrent_requests
  72. end
  73. end
  74. end
  75. 30 module H2CParser
  76. 30 def upgrade(request, response)
  77. # skip checks, it is assumed that this is the first
  78. # request in the connection
  79. 18 stream = @connection.upgrade
  80. # on_settings
  81. 18 handle_stream(stream, request)
  82. 16 @streams[request] = stream
  83. # clean up data left behind in the buffer, if the server started
  84. # sending frames
  85. 18 data = response.read
  86. 18 @connection << data
  87. end
  88. end
  89. end
  90. 30 register_plugin(:h2c, H2C)
  91. end
  92. end

lib/httpx/plugins/ntlm_auth.rb

100.0% lines covered

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

lib/httpx/plugins/ntlm_v2_auth.rb

96.0% lines covered

50 relevant lines. 48 lines covered and 2 lines missed.
    
  1. # frozen_string_literal: true
  2. 23 module HTTPX
  3. 23 module Plugins
  4. # https://gitlab.com/os85/httpx/wikis/Auth#ntlm-v2-auth
  5. 23 module NtlmV2Auth
  6. 23 class << self
  7. 23 def load_dependencies(klass)
  8. 36 require "rubyntlm"
  9. 36 klass.plugin(:auth)
  10. end
  11. 23 def extra_options(options)
  12. 36 options.merge(max_concurrent_requests: 1)
  13. end
  14. end
  15. 23 class Authenticator
  16. 23 def initialize(user, password, domain: nil)
  17. 18 @user = user
  18. 18 @password = password
  19. 18 @domain = domain
  20. end
  21. 23 def can_authenticate?(www_authenticate)
  22. 18 www_authenticate && /NTLM/i.match?(www_authenticate)
  23. end
  24. 23 def negotiate
  25. 18 t1 = Net::NTLM::Message::Type1.new
  26. 18 t1.domain = @domain if @domain
  27. 18 "NTLM #{t1.encode64}"
  28. end
  29. 23 def authenticate(_request, www_authenticate)
  30. 18 challenge_b64 = www_authenticate[/NTLM (.+)/i, 1]
  31. 18 t2 = Net::NTLM::Message.decode64(challenge_b64)
  32. 18 t3 = t2.response(
  33. { user: @user, password: @password, domain: @domain },
  34. ntlmv2: true
  35. )
  36. 18 "NTLM #{t3.encode64}"
  37. end
  38. end
  39. 23 module OptionsMethods
  40. 23 private
  41. 23 def option_ntlm(value)
  42. 54 raise TypeError, ":ntlm must be a #{Authenticator}" unless value.is_a?(NtlmV2Auth::Authenticator)
  43. 36 value
  44. end
  45. end
  46. 23 module InstanceMethods
  47. 23 def ntlm_auth(user, password, domain = nil)
  48. 18 with(ntlm: Authenticator.new(user, password, domain: domain))
  49. end
  50. 23 private
  51. 23 def send_requests(*requests)
  52. 18 requests.flat_map do |request|
  53. 18 ntlm = request.options.ntlm
  54. 18 if ntlm
  55. 18 request.authorize(ntlm.negotiate)
  56. 36 probe_response = wrap { super(request).first }
  57. 18 return probe_response unless probe_response.is_a?(Response)
  58. 18 if probe_response.status == 401 && ntlm.can_authenticate?(probe_response.headers["www-authenticate"])
  59. 18 request.transition(:idle)
  60. 18 request.unauthorize!
  61. 18 request.authorize(ntlm.authenticate(request,
  62. 1 probe_response.headers["www-authenticate"]).encode("utf-8"))
  63. 18 super(request)
  64. else
  65. probe_response
  66. end
  67. else
  68. super(request)
  69. end
  70. end
  71. end
  72. end
  73. end
  74. 23 register_plugin :ntlm_v2_auth, NtlmV2Auth
  75. end
  76. end

lib/httpx/plugins/oauth.rb

91.36% lines covered

162 relevant lines. 148 lines covered and 14 lines missed.
    
  1. # frozen_string_literal: true
  2. 23 module HTTPX
  3. 23 module Plugins
  4. #
  5. # This plugin adds support for managing an OAuth Session associated with the given session.
  6. #
  7. # The scope of OAuth support is limited to the `client_crendentials` and `refresh_token` grants.
  8. #
  9. # https://gitlab.com/os85/httpx/wikis/OAuth
  10. #
  11. 23 module OAuth
  12. 23 class << self
  13. 23 def load_dependencies(klass)
  14. 288 require "monitor"
  15. 288 require_relative "auth/basic"
  16. 288 klass.plugin(:auth)
  17. end
  18. 23 def subplugins
  19. 64 {
  20. 511 retries: OAuthRetries,
  21. }
  22. end
  23. 23 def extra_options(options)
  24. 288 options.merge(auth_header_type: "Bearer")
  25. end
  26. end
  27. 23 SUPPORTED_GRANT_TYPES = %w[client_credentials refresh_token].freeze
  28. 23 SUPPORTED_AUTH_METHODS = %w[client_secret_basic client_secret_post].freeze
  29. # Implements the bulk of functionality and maintains the state associated with the
  30. # management of the the lifecycle of an OAuth session.
  31. 23 class OAuthSession
  32. 23 def initialize(
  33. issuer:,
  34. client_id:,
  35. client_secret:,
  36. access_token: nil,
  37. refresh_token: nil,
  38. scope: nil,
  39. audience: nil,
  40. token_endpoint: nil,
  41. grant_type: nil,
  42. token_endpoint_auth_method: nil
  43. )
  44. 288 @issuer = URI(issuer)
  45. 288 @client_id = client_id
  46. 288 @client_secret = client_secret
  47. 288 @token_endpoint = URI(token_endpoint) if token_endpoint
  48. 288 @scope = case scope
  49. when String
  50. 180 scope.split
  51. when Array
  52. 36 scope
  53. end
  54. 288 @audience = audience
  55. 288 @access_token = access_token
  56. 288 @refresh_token = refresh_token
  57. 288 @token_endpoint_auth_method = String(token_endpoint_auth_method) if token_endpoint_auth_method
  58. 288 @grant_type = grant_type || (@refresh_token ? "refresh_token" : "client_credentials")
  59. 288 @expires_at = nil
  60. 288 @token_mon = Monitor.new
  61. 288 unless @token_endpoint_auth_method.nil? || SUPPORTED_AUTH_METHODS.include?(@token_endpoint_auth_method)
  62. 18 raise Error, "#{@token_endpoint_auth_method} is not a supported auth method"
  63. end
  64. 270 return if SUPPORTED_GRANT_TYPES.include?(@grant_type)
  65. 18 raise Error, "#{@grant_type} is not a supported grant type"
  66. end
  67. # returns the URL where to request access and refresh tokens from.
  68. 23 def token_endpoint
  69. 270 @token_endpoint || "#{@issuer}/token"
  70. end
  71. # returns the oauth-documented authorization method to use when requesting a token.
  72. 23 def token_endpoint_auth_method
  73. 396 @token_endpoint_auth_method || "client_secret_basic"
  74. end
  75. 23 def expires_at
  76. 180 @token_mon.synchronize { @expires_at }
  77. end
  78. 23 def access_token
  79. 252 @token_mon.synchronize do
  80. 252 if (expires_at = @expires_at) && expires_at < Time.now.to_i
  81. 18 reset!
  82. end
  83. 252 @access_token
  84. end
  85. end
  86. 23 def reset!
  87. 54 @token_mon.synchronize do
  88. 54 @access_token = @expires_at = nil
  89. end
  90. end
  91. # when not available, it uses the +http+ object to request new access and refresh tokens.
  92. 23 def fetch_access_token(http)
  93. 144 return access_token if access_token
  94. 126 load(http)
  95. # always prefer refresh token grant if a refresh token is available
  96. 126 grant_type = @refresh_token ? "refresh_token" : @grant_type
  97. 126 headers = {} # : Hash[String ,String]
  98. 28 form_post = {
  99. 98 "grant_type" => @grant_type,
  100. "scope" => Array(@scope).join(" "),
  101. "audience" => @audience,
  102. }.compact
  103. # auth
  104. 112 case token_endpoint_auth_method
  105. when "client_secret_post"
  106. 16 form_post["client_id"] = @client_id
  107. 16 form_post["client_secret"] = @client_secret
  108. when "client_secret_basic"
  109. 96 headers["authorization"] = Authentication::Basic.new(@client_id, @client_secret).authenticate
  110. end
  111. 112 case grant_type
  112. when "client_credentials"
  113. # do nothing
  114. when "refresh_token"
  115. 18 ref_token = refresh_token
  116. 18 raise Error, "cannot use the `\"refresh_token\"` grant type without a refresh token" unless ref_token
  117. 16 form_post["refresh_token"] = ref_token
  118. end
  119. # POST /token
  120. 126 token_request = http.build_request("POST", token_endpoint, headers: headers, form: form_post)
  121. 126 token_request.headers.delete("authorization") unless token_endpoint_auth_method == "client_secret_basic"
  122. 252 token_response = http.skip_auth_header { http.request(token_request) }
  123. 13 begin
  124. 126 token_response.raise_for_status
  125. rescue HTTPError => e
  126. @refresh_token = nil if e.response.status == 401 && (grant_type == "refresh_token")
  127. raise e
  128. end
  129. 126 payload = token_response.json
  130. 126 @token_mon.synchronize do
  131. 126 @refresh_token = payload.fetch("refresh_token", @refresh_token)
  132. 126 if (expires_in = payload["expires_in"])
  133. 126 @expires_at = Time.now.to_i + Integer(expires_in)
  134. end
  135. 126 @access_token = payload["access_token"]
  136. end
  137. end
  138. # TODO: remove this after deprecating the `:oauth_session` option
  139. 23 def merge(other)
  140. obj = dup
  141. case other
  142. when OAuthSession
  143. other.instance_variables.each do |ivar|
  144. val = other.instance_variable_get(ivar)
  145. next unless val
  146. obj.instance_variable_set(ivar, val)
  147. end
  148. when Hash
  149. other.each do |k, v|
  150. obj.instance_variable_set(:"@#{k}", v) if obj.instance_variable_defined?(:"@#{k}")
  151. end
  152. end
  153. obj
  154. end
  155. 23 private
  156. 23 def refresh_token
  157. 36 @token_mon.synchronize { @refresh_token }
  158. end
  159. # uses +http+ to fetch for the oauth server metadata.
  160. 23 def load(http)
  161. 144 return if @grant_type && @scope
  162. 72 metadata = http.skip_auth_header { http.get("#{@issuer}/.well-known/oauth-authorization-server").raise_for_status.json }
  163. 36 @token_mon.synchronize do
  164. 36 @token_endpoint = metadata["token_endpoint"]
  165. 36 @scope = metadata["scopes_supported"]
  166. 144 @grant_type = Array(metadata["grant_types_supported"]).find { |gr| SUPPORTED_GRANT_TYPES.include?(gr) }
  167. 36 @token_endpoint_auth_method = Array(metadata["token_endpoint_auth_methods_supported"]).find do |am|
  168. 36 SUPPORTED_AUTH_METHODS.include?(am)
  169. end
  170. end
  171. 16 nil
  172. end
  173. end
  174. # adds support for the following options:
  175. #
  176. # :oauth_options :: an hash of options to be used during session management.
  177. # check the parameters to initialize the OAuthSession class.
  178. 23 module OptionsMethods
  179. 23 private
  180. 23 def option_oauth_session(value)
  181. 36 warn "DEPRECATION WARNING: option `:oauth_session` is deprecated. " \
  182. "Use `:oauth_options` instead."
  183. 32 case value
  184. when Hash
  185. 18 OAuthSession.new(**value)
  186. when OAuthSession
  187. 18 value
  188. else
  189. raise TypeError, ":oauth_session must be a #{OAuthSession}"
  190. end
  191. end
  192. 23 def option_oauth_options(value)
  193. 468 value = Hash[value] unless value.is_a?(Hash)
  194. 450 value
  195. end
  196. end
  197. 23 module InstanceMethods
  198. 23 attr_reader :oauth_session
  199. 23 protected :oauth_session
  200. 23 def initialize(*)
  201. 486 super
  202. 486 @oauth_session = if @options.oauth_options
  203. 270 OAuthSession.new(**@options.oauth_options)
  204. 215 elsif @options.oauth_session
  205. 18 @oauth_session = @options.oauth_session.dup
  206. end
  207. end
  208. 23 def initialize_dup(other)
  209. 18 super
  210. 18 @oauth_session = other.instance_variable_get(:@oauth_session).dup
  211. end
  212. 23 def oauth_auth(**args)
  213. 18 warn "DEPRECATION WARNING: `#{__method__}` is deprecated. " \
  214. "Use `with(oauth_options: options)` instead."
  215. 18 with(oauth_options: args)
  216. end
  217. # will eagerly negotiate new oauth tokens with the issuer
  218. 23 def refresh_oauth_tokens!
  219. 18 return unless @oauth_session
  220. 18 @oauth_session.reset!
  221. 18 @oauth_session.fetch_access_token(self)
  222. 18 if (expires_at = @oauth_session.expires_at)
  223. 18 @auth_header_value_mtx.synchronize do
  224. 18 @auth_header_expires_at = expires_at
  225. end
  226. end
  227. end
  228. # TODO: deprecate
  229. 23 def with_access_token
  230. 18 warn "DEPRECATION WARNING: `#{__method__}` is deprecated. " \
  231. "The session will automatically handle token lifecycles for you."
  232. 18 other_session = dup # : instance
  233. 18 oauth_session = other_session.oauth_session
  234. 18 oauth_session.fetch_access_token(other_session)
  235. 18 if (expires_at = oauth_session.expires_at)
  236. 18 @auth_header_expires_at = expires_at
  237. end
  238. 18 other_session
  239. end
  240. 23 def reset_auth_header_value!
  241. super.tap do
  242. @oauth_session.reset if @oauth_session
  243. end
  244. end
  245. 23 private
  246. 23 def generate_auth_token
  247. 54 return unless @oauth_session
  248. 54 @oauth_session.fetch_access_token(self)
  249. end
  250. 23 def set_auth_header_expires_at(_)
  251. 54 return super unless @oauth_session
  252. 54 expires_at = @oauth_session.expires_at
  253. 54 return super unless expires_at
  254. 18 @auth_header_expires_at = expires_at
  255. end
  256. 23 def dynamic_auth_token?(_)
  257. 36 @oauth_session
  258. end
  259. end
  260. 23 module OAuthRetries
  261. 23 module InstanceMethods
  262. 23 private
  263. 23 def prepare_to_retry(_request, response)
  264. 18 @oauth_session.reset! if @oauth_session
  265. 18 super
  266. end
  267. end
  268. end
  269. end
  270. 23 register_plugin :oauth, OAuth
  271. end
  272. end

lib/httpx/plugins/persistent.rb

100.0% lines covered

30 relevant lines. 30 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 30 module HTTPX
  3. 30 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. 30 module Persistent
  20. 30 class << self
  21. 30 def load_dependencies(klass)
  22. 615 klass.plugin(:fiber_concurrency)
  23. 615 max_retries = if klass.default_options.respond_to?(:max_retries)
  24. 9 [klass.default_options.max_retries, 1].max
  25. else
  26. 606 1
  27. end
  28. 615 klass.plugin(:retries, max_retries: max_retries)
  29. end
  30. end
  31. 30 def self.extra_options(options)
  32. 615 options.merge(persistent: true)
  33. end
  34. 30 module InstanceMethods
  35. 30 def close(*)
  36. 369 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. 369 Thread.list.each do |th|
  41. 5195 store = thread_selector_store(th)
  42. 5195 next unless store && store.key?(self)
  43. 352 selector = store.delete(self)
  44. 352 selector_close(selector)
  45. end
  46. end
  47. 30 private
  48. 30 def retryable_request?(request, response, *)
  49. 787 super || begin
  50. 226 return false unless response && response.is_a?(ErrorResponse)
  51. 32 error = response.error
  52. 352 Retries::RECONNECTABLE_ERRORS.any? { |klass| error.is_a?(klass) }
  53. end
  54. end
  55. 30 def retryable_error?(ex, options)
  56. 145 super &&
  57. # under the persistent plugin rules, requests are only retried for connection related errors,
  58. # which do not include request timeout related errors. This only gets overriden if the end user
  59. # manually changed +:max_retries+ to something else, which means it is aware of the
  60. # consequences.
  61. 129 (!ex.is_a?(RequestTimeoutError) || options.max_retries != 1)
  62. end
  63. end
  64. end
  65. 30 register_plugin :persistent, Persistent
  66. end
  67. end

lib/httpx/plugins/proxy.rb

94.94% lines covered

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

lib/httpx/plugins/proxy/http.rb

94.87% lines covered

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

lib/httpx/plugins/query.rb

100.0% lines covered

15 relevant lines. 15 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 23 module HTTPX
  3. 23 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. 23 module Query
  9. 23 def self.subplugins
  10. 3 {
  11. 23 retries: QueryRetries,
  12. }
  13. end
  14. 23 module InstanceMethods
  15. 23 def query(*uri, **options)
  16. 18 request("QUERY", uri, **options)
  17. end
  18. end
  19. 23 module QueryRetries
  20. 23 module InstanceMethods
  21. 23 private
  22. 23 def retryable_request?(request, *)
  23. 27 super || request.verb == "QUERY"
  24. end
  25. end
  26. end
  27. end
  28. 23 register_plugin :query, Query
  29. end
  30. end

lib/httpx/plugins/rate_limiter.rb

100.0% lines covered

21 relevant lines. 21 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 23 module HTTPX
  3. 23 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. 23 module RateLimiter
  14. 23 RATE_LIMIT_CODES = [429, 503].freeze
  15. 23 class << self
  16. 23 def load_dependencies(klass)
  17. 72 klass.plugin(:retries)
  18. end
  19. end
  20. 23 module InstanceMethods
  21. 23 private
  22. 23 def retryable_request?(request, response, options)
  23. 144 super || rate_limit_error?(response)
  24. end
  25. 23 def retryable_response?(response, options)
  26. 144 rate_limit_error?(response) || super
  27. end
  28. 23 def rate_limit_error?(response)
  29. 144 response.is_a?(Response) && RATE_LIMIT_CODES.include?(response.status)
  30. end
  31. # Servers send the "Retry-After" header field to indicate how long the
  32. # user agent ought to wait before making a follow-up request. When
  33. # sent with a 503 (Service Unavailable) response, Retry-After indicates
  34. # how long the service is expected to be unavailable to the client.
  35. # When sent with any 3xx (Redirection) response, Retry-After indicates
  36. # the minimum time that the user agent is asked to wait before issuing
  37. # the redirected request.
  38. #
  39. 23 def when_to_retry(_, response, options)
  40. 72 return super unless response.is_a?(Response)
  41. 72 retry_after = response.headers["retry-after"]
  42. 72 return super unless retry_after
  43. 36 Utils.parse_retry_after(retry_after)
  44. end
  45. end
  46. end
  47. 23 register_plugin :rate_limiter, RateLimiter
  48. end
  49. end

lib/httpx/plugins/response_cache.rb

99.12% lines covered

113 relevant lines. 112 lines covered and 1 lines missed.
    
  1. # frozen_string_literal: true
  2. 23 module HTTPX
  3. 23 module Plugins
  4. #
  5. # This plugin caches and reuses responses based on HTTP caching directives defined by
  6. # the [HTTP Caching RFC](https://www.rfc-editor.org/rfc/rfc9111.html)
  7. #
  8. # https://gitlab.com/os85/httpx/wikis/Response-Cache
  9. #
  10. 23 module ResponseCache
  11. 23 CACHEABLE_VERBS = %w[GET HEAD].freeze
  12. 23 CACHEABLE_STATUS_CODES = [200, 203, 206, 300, 301, 410].freeze
  13. 23 SUPPORTED_VARY_HEADERS = %w[accept accept-encoding accept-language cookie origin].sort.freeze
  14. 23 private_constant :CACHEABLE_VERBS
  15. 23 private_constant :CACHEABLE_STATUS_CODES
  16. 23 class << self
  17. 23 def load_dependencies(klass)
  18. 270 klass.plugin(:cache)
  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. 23 def cacheable_response?(response)
  23. 189 response.is_a?(Response) &&
  24. (
  25. 189 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. 23 def not_modified?(response)
  40. 189 response.is_a?(Response) && response.status == 304
  41. end
  42. 23 def extra_options(options)
  43. 270 options.merge(
  44. supported_vary_headers: SUPPORTED_VARY_HEADERS,
  45. )
  46. end
  47. end
  48. # adds support for the following options:
  49. #
  50. # :supported_vary_headers :: array of header values that will be considered for a "vary" header based cache validation
  51. # (defaults to {SUPPORTED_VARY_HEADERS}).
  52. #
  53. 23 module OptionsMethods
  54. 23 private
  55. 23 def option_supported_vary_headers(value)
  56. 270 Array(value).sort
  57. end
  58. end
  59. 23 module InstanceMethods
  60. 23 private
  61. 23 def fetch_response(request, *)
  62. 360 response = super
  63. 360 return unless response
  64. 189 if ResponseCache.not_modified?(response)
  65. 36 log { "returning cached response for #{request.uri}" }
  66. 36 response.copy_from_cached!
  67. end
  68. 189 response
  69. end
  70. # will either assign a still-fresh cached response to +request+, or set up its HTTP
  71. # cache invalidation headers in case it's not fresh anymore.
  72. 23 def prepare_cache(request)
  73. 792 super
  74. 792 return if request.response # already cached
  75. 720 cached_response = retrieve_cached_response(request)
  76. 720 return unless cached_response && match_by_vary?(request, cached_response)
  77. 270 if !request.headers.key?("if-modified-since") && (last_modified = cached_response.headers["last-modified"])
  78. 36 request.headers.add("if-modified-since", last_modified)
  79. end
  80. 270 if !request.headers.key?("if-none-match") && (etag = cached_response.headers["etag"])
  81. 198 request.headers.add("if-none-match", etag)
  82. end
  83. end
  84. 23 def cacheable_request?(request)
  85. 83 (
  86. 747 request.cacheable_verb? &&
  87. (
  88. 729 !request.headers.key?("cache-control") || !request.headers.get("cache-control").include?("no-store")
  89. )
  90. ) || super
  91. end
  92. 23 def cacheable_response?(_, response)
  93. 189 ResponseCache.cacheable_response?(response) || super
  94. end
  95. # +cached_response+ is still valid if it's still fresh
  96. 23 def valid_cached_response?(_, cached_response)
  97. 342 cached_response.fresh?
  98. end
  99. # whether the +cached_response+ complies with the directives set by the +request+ "vary" header
  100. # (true when none is available).
  101. 23 def match_by_vary?(request, cached_response)
  102. 270 vary = cached_response.vary
  103. 270 return true unless vary
  104. 108 original_request = cached_response.original_request
  105. 108 if vary == %w[*]
  106. 36 request.options.supported_vary_headers.each do |field|
  107. 180 return false unless request.headers[field] == original_request.headers[field]
  108. end
  109. 32 return true
  110. end
  111. 72 vary.all? do |field|
  112. 72 !original_request.headers.key?(field) || request.headers[field] == original_request.headers[field]
  113. end
  114. end
  115. end
  116. 23 module RequestMethods
  117. # returns whether this request is cacheable as per HTTP caching rules.
  118. 23 def cacheable_verb?
  119. 747 CACHEABLE_VERBS.include?(@verb)
  120. end
  121. # returns a unique cache key as a String identifying this request
  122. 23 def response_cache_key
  123. 1971 @response_cache_key ||= begin
  124. 567 keys = [@verb, @uri.merge(path)]
  125. 567 @options.supported_vary_headers.each do |field|
  126. 2835 value = @headers[field]
  127. 2835 keys << value if value
  128. end
  129. 567 Digest::SHA1.hexdigest("httpx-response-cache-#{keys.join("-")}")
  130. end
  131. end
  132. end
  133. 23 module ResponseMethods
  134. 23 attr_writer :revalidated_at
  135. 23 def initialize(*)
  136. 576 super
  137. 576 @revalidated_at = nil
  138. end
  139. # eager-copies the response headers and body from {RequestMethods#cached_response}.
  140. 23 def copy_from_cached!
  141. 36 cached_response = @request.cached_response
  142. 36 return unless cached_response
  143. # 304 responses do not have content-type, which are needed for decoding.
  144. 36 @headers = @headers.class.new(cached_response.headers.merge(@headers))
  145. 36 @body = cached_response.body.dup
  146. 36 @body.rewind
  147. 36 cached_response.revalidated_at = date
  148. end
  149. # A response is fresh if its age has not yet exceeded its freshness lifetime.
  150. # other (#cache_control} directives may influence the outcome, as per the rules
  151. # from the {rfc}[https://www.rfc-editor.org/rfc/rfc7234]
  152. 23 def fresh?
  153. 342 if cache_control
  154. 126 return false if cache_control.include?("no-cache")
  155. 90 return true if cache_control.include?("immutable")
  156. # check age: max-age
  157. 216 max_age = cache_control.find { |directive| directive.start_with?("s-maxage") }
  158. 216 max_age ||= cache_control.find { |directive| directive.start_with?("max-age") }
  159. 90 max_age = max_age[/age=(\d+)/, 1] if max_age
  160. 90 max_age = max_age.to_i if max_age
  161. 90 return max_age > age if max_age
  162. end
  163. # check age: expires
  164. 216 if @headers.key?("expires")
  165. 5 begin
  166. 54 expires = Time.httpdate(@headers["expires"])
  167. rescue ArgumentError
  168. 18 return false
  169. end
  170. 32 return (expires - Time.now).to_i.positive?
  171. end
  172. 162 false
  173. end
  174. # returns the "cache-control" directives as an Array of String(s).
  175. 23 def cache_control
  176. 1008 return @cache_control if defined?(@cache_control)
  177. 41 @cache_control = begin
  178. 369 @headers["cache-control"].split(/ *, */) if @headers.key?("cache-control")
  179. end
  180. end
  181. # returns the "vary" header value as an Array of (String) headers.
  182. 23 def vary
  183. 288 return @vary if defined?(@vary)
  184. 24 @vary = begin
  185. 216 @headers["vary"].split(/ *, */).map(&:downcase) if @headers.key?("vary")
  186. end
  187. end
  188. 23 private
  189. # returns the value of the "age" header as an Integer (time since epoch).
  190. # if no "age" of header exists, it returns the number of seconds since {#date}.
  191. 23 def age
  192. 108 if (revalidated_at = @revalidated_at)
  193. (Time.now - revalidated_at).to_i
  194. else
  195. 108 return @headers["age"].to_i if @headers.key?("age")
  196. 108 (Time.now - date).to_i
  197. end
  198. end
  199. # returns the value of the "date" header as a Time object
  200. 23 def date
  201. 144 @date ||= Time.httpdate(@headers["date"])
  202. rescue NoMethodError, ArgumentError
  203. 18 Time.now
  204. end
  205. end
  206. end
  207. 23 register_plugin :response_cache, ResponseCache
  208. end
  209. end

lib/httpx/plugins/retries.rb

95.8% lines covered

119 relevant lines. 114 lines covered and 5 lines missed.
    
  1. # frozen_string_literal: true
  2. 30 module HTTPX
  3. 30 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. 30 module Retries
  15. 30 MAX_RETRIES = 3
  16. # TODO: pass max_retries in a configure/load block
  17. 30 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. 28 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. 30 RETRYABLE_ERRORS = (RECONNECTABLE_ERRORS + [
  32. Parser::Error,
  33. TimeoutError,
  34. ]).freeze
  35. 30 DEFAULT_JITTER = ->(interval) { interval * ((rand + 1) * 0.5) }.freeze
  36. # list of supported backoff algorithms
  37. 30 BACKOFF_ALGORITHMS = %i[exponential_backoff polynomial_backoff].freeze
  38. 30 class << self
  39. 30 if ENV.key?("HTTPX_NO_JITTER")
  40. 30 def extra_options(options)
  41. 1135 options.merge(max_retries: MAX_RETRIES)
  42. end
  43. else
  44. def extra_options(options)
  45. options.merge(max_retries: MAX_RETRIES, retry_jitter: DEFAULT_JITTER)
  46. end
  47. end
  48. # returns the time to wait before resending +request+ as per the polynomial backoff retry strategy,
  49. # where base is 1 and exponent is 2.
  50. 30 def retry_after_polynomial_backoff(request, _)
  51. 36 offset = request.options.max_retries - request.retries
  52. 36 1 * ((offset - 1)**2)
  53. end
  54. # returns the time to wait before resending +request+ as per the exponential backoff retry strategy,
  55. # where base is 2
  56. 30 def retry_after_exponential_backoff(request, _)
  57. 36 offset = request.options.max_retries - request.retries
  58. 36 2**(offset - 1)
  59. end
  60. end
  61. # adds support for the following options:
  62. #
  63. # :max_retries :: max number of times a request will be retried (defaults to <tt>3</tt>).
  64. # :retry_change_requests :: whether idempotent requests are retried (defaults to <tt>false</tt>).
  65. # :retry_after:: seconds after which a request is retried; can also be a callable object (i.e. <tt>->(req, res) { ... } </tt>)
  66. # or the name of a supported backoff algorithm (i.e. <tt>:exponential_backoff</tt>).
  67. # :retry_jitter :: number of seconds applied to *:retry_after* (must be a callable, i.e. <tt>->(retry_after) { ... } </tt>).
  68. # :retry_on :: callable which alternatively defines a different rule for when a response is to be retried
  69. # (i.e. <tt>->(res) { ... }</tt>).
  70. 30 module OptionsMethods
  71. 30 private
  72. 30 def option_retry_after(value)
  73. 270 if value.respond_to?(:call)
  74. 126 value1 = value
  75. 126 value1 = value1.method(:call) unless value1.respond_to?(:arity)
  76. # allow ->(*) arity as well, which is < 0
  77. 126 raise TypeError, "`:retry_after` proc has invalid number of parameters" unless value1.arity.negative? || value1.arity.between?(
  78. 1, 2
  79. )
  80. else
  81. 128 case value
  82. when Symbol
  83. 36 raise TypeError, "`retry_after`: `#{value}` is not a supported backoff algorithm" unless BACKOFF_ALGORITHMS.include?(value)
  84. 36 value = Retries.method(:"retry_after_#{value}")
  85. else
  86. 108 value = Float(value)
  87. 108 raise TypeError, "`:retry_after` must be positive" unless value.positive?
  88. end
  89. end
  90. 270 value
  91. end
  92. 30 def option_retry_jitter(value)
  93. # return early if callable
  94. 54 raise TypeError, ":retry_jitter must be callable" unless value.respond_to?(:call)
  95. 54 value
  96. end
  97. 30 def option_max_retries(value)
  98. 3693 num = Integer(value)
  99. 3693 raise TypeError, ":max_retries must be positive" unless num >= 0
  100. 3693 num
  101. end
  102. 30 def option_retry_change_requests(v)
  103. 101 v
  104. end
  105. 30 def option_retry_on(value)
  106. 414 raise TypeError, ":retry_on must be called with the response" unless value.respond_to?(:call)
  107. 414 value
  108. end
  109. end
  110. 30 module InstanceMethods
  111. # returns a `:retries` plugin enabled session with +n+ maximum retries per request setting.
  112. 30 def max_retries(n)
  113. 162 with(max_retries: n)
  114. end
  115. 30 private
  116. 30 def fetch_response(request, selector, options)
  117. 4771 response = super
  118. 4771 if response &&
  119. request.retries.positive? &&
  120. retryable_request?(request, response, options) &&
  121. retryable_response?(response, options)
  122. 1017 try_partial_retry(request, response)
  123. 1017 log { "failed to get response, #{request.retries} tries to go..." }
  124. 1017 prepare_to_retry(request, response)
  125. 1017 if (retry_after = when_to_retry(request, response, options)) && retry_after.positive?
  126. 162 retry_start = Utils.now
  127. 162 log { "retrying after #{retry_after} secs..." }
  128. 162 selector.after(retry_after) do
  129. 162 if (response = request.response)
  130. response.finish!
  131. # request has terminated abruptly meanwhile
  132. request.emit_response(response)
  133. else
  134. 162 log { "retrying (elapsed time: #{Utils.elapsed_time(retry_start)})!!" }
  135. 162 send_request(request, selector, options)
  136. end
  137. end
  138. 144 return
  139. else
  140. 855 send_request(request, selector, options)
  141. # recalling itself, in case an error was triggered by the above, and we can
  142. # verify retriability again.
  143. 770 return fetch_response(request, selector, options)
  144. end
  145. end
  146. 3754 response
  147. end
  148. # returns whether +request+ can be retried.
  149. 30 def retryable_request?(request, _, options)
  150. 1964 IDEMPOTENT_METHODS.include?(request.verb) || options.retry_change_requests
  151. end
  152. 30 def retryable_response?(response, options)
  153. 1554 (response.is_a?(ErrorResponse) && retryable_error?(response.error, options)) || options.retry_on&.call(response)
  154. end
  155. # returns whether the +ex+ exception happend for a retriable request.
  156. 30 def retryable_error?(ex, _)
  157. 8489 RETRYABLE_ERRORS.any? { |klass| ex.is_a?(klass) } && !ex.is_a?(TotalRequestTimeoutError)
  158. end
  159. 30 def proxy_error?(request, response, _)
  160. 72 super && !request.retries.positive?
  161. end
  162. 30 def prepare_to_retry(request, _response)
  163. 1017 request.retries -= 1 unless request.ping? # do not exhaust retries on connection liveness probes
  164. 1017 request.transition(:idle)
  165. end
  166. 30 def when_to_retry(request, response, options)
  167. 963 retry_after = options.retry_after
  168. 963 return unless retry_after
  169. 144 retry_after = retry_after.call(request, response) if retry_after.respond_to?(:call)
  170. 144 return unless retry_after
  171. # apply jitter
  172. 144 if (jitter = request.options.retry_jitter)
  173. 18 retry_after = jitter.call(retry_after)
  174. end
  175. 144 retry_after
  176. end
  177. #
  178. # Attempt to set the request to perform a partial range request.
  179. # This happens if the peer server accepts byte-range requests, and
  180. # the last response contains some body payload.
  181. #
  182. 30 def try_partial_retry(request, response)
  183. 1017 response = response.response if response.is_a?(ErrorResponse)
  184. 1017 return unless response
  185. 420 unless response.headers.key?("accept-ranges") &&
  186. response.headers["accept-ranges"] == "bytes" && # there's nothing else supported though...
  187. 18 (original_body = response.body)
  188. 402 response.body.close
  189. 359 return
  190. end
  191. 18 request.partial_response = response
  192. 18 size = original_body.bytesize
  193. 16 request.headers["range"] = "bytes=#{size}-"
  194. end
  195. end
  196. 30 module RequestMethods
  197. # number of retries left.
  198. 30 attr_accessor :retries
  199. # a response partially received before.
  200. 30 attr_writer :partial_response
  201. # initializes the request instance, sets the number of retries for the request.
  202. 30 def initialize(*args)
  203. 1473 super
  204. 1473 @retries = @options.max_retries
  205. 1473 @partial_response = nil
  206. end
  207. 30 def response=(response)
  208. 2553 if (partial_response = @partial_response)
  209. 18 if response.is_a?(Response) && response.status == 206
  210. 18 response.from_partial_response(partial_response)
  211. else
  212. partial_response.close
  213. end
  214. 18 @partial_response = nil
  215. end
  216. 2553 super
  217. end
  218. end
  219. 30 module ResponseMethods
  220. 30 def from_partial_response(response)
  221. 18 @status = response.status
  222. 18 @headers = response.headers
  223. 18 @body = response.body
  224. end
  225. end
  226. end
  227. 30 register_plugin :retries, Retries
  228. end
  229. end

lib/httpx/plugins/server_sent_events.rb

97.37% lines covered

76 relevant lines. 74 lines covered and 2 lines missed.
    
  1. # frozen_string_literal: true
  2. 23 module HTTPX
  3. 23 module Plugins
  4. #
  5. # This plugin implements convenience methods for Server Sent Events streams.
  6. #
  7. # https://gitlab.com/os85/httpx/wikis/Server-Sent-Events
  8. #
  9. 23 module ServerSentEvents
  10. 23 Message = if RUBY_VERSION >= "3.2.0" # rubocop:disable Naming/ConstantName
  11. 19 Data.define(:data, :event, :id, :retry_after) do
  12. 19 def initialize(event: nil, id: nil, retry_after: nil, **kwargs)
  13. 80 super
  14. end
  15. end
  16. else
  17. 4 Struct.new(:data, :event, :id, :retry_after, keyword_init: true)
  18. end
  19. 23 class << self
  20. 23 def subplugins
  21. 16 {
  22. 127 retries: ServerSentEventsRetries,
  23. }
  24. end
  25. 23 def load_dependencies(klass)
  26. 72 klass.plugin(:stream)
  27. end
  28. end
  29. # adds support for the following options:
  30. #
  31. # :event_stream :: whether the request is a server-sent events text event stream (defaults to <tt>false</tt>).
  32. 23 module OptionsMethods
  33. 23 def option_event_stream(val)
  34. 72 val
  35. end
  36. end
  37. 23 module InstanceMethods
  38. 23 def request(*args, **options)
  39. 162 options[:stream] = true if options[:event_stream]
  40. 162 super
  41. end
  42. 23 def build_request(*)
  43. 90 super.tap do |request|
  44. 90 if request.options.event_stream
  45. 64 request.headers["accept"] = "text/event-stream"
  46. 64 request.headers["cache-control"] = "no-cache"
  47. end
  48. end
  49. end
  50. end
  51. 23 module RequestMethods
  52. 23 attr_accessor :last_server_sent_message
  53. 23 def initialize(*)
  54. 90 super
  55. 90 @last_server_sent_message = nil
  56. end
  57. end
  58. 23 module StreamResponseMethods
  59. # yields each event Message as the server emits them.
  60. 23 def each_message(&block)
  61. 108 return enum_for(__method__) unless block
  62. 54 payload = {}
  63. 54 each_line do |line|
  64. 531 if line.empty?
  65. 162 if payload[:comment]
  66. 18 payload.clear
  67. 18 next
  68. end
  69. 144 next if payload.empty?
  70. 144 message = Message.new(**payload)
  71. 144 payload.clear
  72. 144 @request.last_server_sent_message = message
  73. 144 yield message
  74. else
  75. 369 type, value = line.split(": ", 2)
  76. 328 case type
  77. when "data"
  78. 180 type = type.to_sym
  79. 180 if payload.key?(type)
  80. 18 payload[type] << "\n" << value
  81. else
  82. 144 payload[type] = value
  83. end
  84. when "id", "event", "retry"
  85. 171 type = type.to_sym
  86. 171 raise_format_error(line) if payload.key?(type) || value.empty?
  87. 171 type = :retry_after if type == :retry # avoid using keyword
  88. 152 payload[type] = value
  89. else
  90. # skip if it's a comment
  91. 18 if line.start_with?(":")
  92. 16 payload[:comment] = true
  93. 18 next
  94. end
  95. raise_format_error(line)
  96. end
  97. end
  98. end
  99. end
  100. 23 private
  101. 23 def raise_format_error(line)
  102. raise Error, "'#{line}': invalid or unsupported event stream format"
  103. end
  104. end
  105. 23 module ServerSentEventsRetries
  106. 23 module InstanceMethods
  107. 23 private
  108. 23 def prepare_to_retry(request, *)
  109. 18 super
  110. 18 last_message = request.last_server_sent_message
  111. 18 return unless last_message && last_message.id
  112. 16 request.headers["last-event-id"] = last_message.id
  113. ensure
  114. 18 request.last_server_sent_message = nil
  115. end
  116. 23 def when_to_retry(request, *)
  117. 18 retry_after = request.last_server_sent_message&.retry_after
  118. 18 retry_after / 1_000.0 if retry_after # original in milliseconds
  119. 18 request.last_server_sent_message&.retry_after && super
  120. end
  121. end
  122. end
  123. end
  124. 23 register_plugin(:server_sent_events, ServerSentEvents)
  125. end
  126. end

lib/httpx/plugins/ssrf_filter.rb

100.0% lines covered

69 relevant lines. 69 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 23 module HTTPX
  3. 23 class ServerSideRequestForgeryError < Error; end
  4. 23 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. 23 module SsrfFilter
  11. 23 module IPAddrExtensions
  12. 23 refine IPAddr do
  13. 23 def prefixlen
  14. 368 mask_addr = @mask_addr
  15. 368 raise "Invalid mask" if mask_addr.zero?
  16. 627 mask_addr >>= 1 while mask_addr.nobits?(0x1)
  17. 368 length = 0
  18. 621 while mask_addr & 0x1 == 0x1
  19. 5566 length += 1
  20. 5566 mask_addr >>= 1
  21. end
  22. 368 length
  23. end
  24. end
  25. end
  26. 23 using IPAddrExtensions
  27. # https://en.wikipedia.org/wiki/Reserved_IP_addresses
  28. 2 IPV4_BLACKLIST = [
  29. 21 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. 3 IPV6_BLACKLIST = ([
  47. 21 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. 368 prefixlen = ipaddr.prefixlen
  60. 368 ipv4_compatible = ipaddr.ipv4_compat.mask(96 + prefixlen)
  61. 368 ipv4_mapped = ipaddr.ipv4_mapped.mask(80 + prefixlen)
  62. 368 [ipv4_compatible, ipv4_mapped]
  63. end).freeze
  64. 23 class << self
  65. 23 def extra_options(options)
  66. 115 options.merge(allowed_schemes: %w[https http])
  67. end
  68. 23 def unsafe_ip_address?(ipaddr)
  69. 106 range = ipaddr.to_range
  70. 106 return true if range.first != range.last
  71. 124 return IPV6_BLACKLIST.any? { |r| r.include?(ipaddr) } if ipaddr.ipv6?
  72. 1066 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. # :extra_unsafe_ranges :: A list of IP ranges (or addresses) that will be filtered, in addition to the defaults
  79. # :safe_private_ranges :: A list of IP ranges (or addresses) that will not be filtered, even if they'd be filtered by default
  80. 23 module OptionsMethods
  81. 23 private
  82. 23 def option_allowed_schemes(value)
  83. 124 Array(value)
  84. end
  85. 23 def option_extra_unsafe_ranges(value)
  86. 36 Array(value).map { |v| v.is_a?(IPAddr) ? v : IPAddr.new(v) }
  87. end
  88. 23 def option_safe_private_ranges(value)
  89. 54 Array(value).map { |v| v.is_a?(IPAddr) ? v : IPAddr.new(v) }
  90. end
  91. end
  92. 23 module InstanceMethods
  93. 23 def send_requests(*requests)
  94. 133 responses = requests.map do |request|
  95. 133 next if @options.allowed_schemes.include?(request.uri.scheme)
  96. 9 error = ServerSideRequestForgeryError.new("#{request.uri} URI scheme not allowed")
  97. 9 error.set_backtrace(caller)
  98. 9 response = ErrorResponse.new(request, error)
  99. 9 request.response = response
  100. 9 request.emit_response(response)
  101. 9 response
  102. end
  103. 266 allowed_requests = requests.select { |req| responses[requests.index(req)].nil? }
  104. 133 allowed_responses = super(*allowed_requests)
  105. 133 allowed_responses.each_with_index do |res, idx|
  106. 124 req = allowed_requests[idx]
  107. 110 responses[requests.index(req)] = res
  108. end
  109. 133 responses
  110. end
  111. end
  112. 23 module ConnectionMethods
  113. 23 def initialize(*)
  114. begin
  115. 124 super
  116. 8 rescue ServerSideRequestForgeryError => e
  117. # may raise when IPs are passed as options via :addresses
  118. 18 throw(:resolve_error, e)
  119. end
  120. end
  121. 23 def addresses=(addrs)
  122. 142 addrs.reject! do |ipaddr|
  123. 142 ipaddr = ipaddr.address
  124. 196 next false if @options.safe_private_ranges&.any? { |r| r.include?(ipaddr) }
  125. 124 SsrfFilter.unsafe_ip_address?(ipaddr) || @options.extra_unsafe_ranges&.any? { |r| r.include?(ipaddr) }
  126. end
  127. 142 raise ServerSideRequestForgeryError, "#{@origin.host} has no public IP addresses" if addrs.empty?
  128. 54 super
  129. end
  130. end
  131. end
  132. 23 register_plugin :ssrf_filter, SsrfFilter
  133. end
  134. end

lib/httpx/plugins/stream.rb

97.37% lines covered

114 relevant lines. 111 lines covered and 3 lines missed.
    
  1. # frozen_string_literal: true
  2. 30 module HTTPX
  3. 30 class StreamResponse
  4. 30 attr_reader :request
  5. 30 def initialize(request, session)
  6. 333 @request = request
  7. 333 @options = @request.options
  8. 333 @session = session
  9. 333 @response_enum = nil
  10. 333 @buffered_chunks = []
  11. end
  12. 30 def each(&block)
  13. 527 return enum_for(__method__) unless block
  14. 351 if (response_enum = @response_enum)
  15. 18 @response_enum = nil
  16. # streaming already started, let's finish it
  17. 48 while (chunk = @buffered_chunks.shift)
  18. 18 block.call(chunk)
  19. end
  20. # consume enum til the end
  21. 1 begin
  22. 57 while (chunk = response_enum.next)
  23. 27 block.call(chunk)
  24. end
  25. rescue StopIteration
  26. 18 return
  27. end
  28. end
  29. 333 @request.stream = self
  30. 30 begin
  31. 333 @on_chunk = block
  32. 333 response = @session.request(@request)
  33. 288 response.raise_for_status
  34. ensure
  35. 288 @on_chunk = nil
  36. end
  37. end
  38. 30 def each_line(&block)
  39. 176 return enum_for(__method__) unless block
  40. 115 line = "".b
  41. 115 each do |chunk|
  42. 484 line << chunk
  43. 1402 while (idx = line.index("\n"))
  44. 592 if idx.zero?
  45. 162 yield ""
  46. else
  47. 430 yield line.byteslice(0..(idx - 1))
  48. end
  49. 592 line = line.byteslice((idx + 1)..-1)
  50. end
  51. end
  52. 79 yield line unless line.empty?
  53. end
  54. # This is a ghost method. It's to be used ONLY internally, when processing streams
  55. 30 def on_chunk(chunk)
  56. 1009 raise NoMethodError unless @on_chunk
  57. 1009 @on_chunk.call(chunk)
  58. end
  59. skipped # :nocov:
  60. skipped def inspect
  61. skipped "#<#{self.class}:#{object_id}>"
  62. skipped end
  63. skipped # :nocov:
  64. 30 def to_s
  65. 18 if @request.response
  66. @request.response.to_s
  67. else
  68. 18 @buffered_chunks.join
  69. end
  70. end
  71. 30 private
  72. 30 def response
  73. 505 @request.response || begin
  74. 68 response_enum = each
  75. 122 while (chunk = response_enum.next)
  76. 63 @buffered_chunks << chunk
  77. 63 break if @request.response
  78. end
  79. 63 @response_enum = response_enum
  80. 63 @request.response
  81. end
  82. end
  83. 30 def respond_to_missing?(meth, include_private)
  84. 59 if (response = @request.response)
  85. response.respond_to_missing?(meth, include_private)
  86. else
  87. 59 @options.response_class.method_defined?(meth) || (include_private && @options.response_class.private_method_defined?(meth))
  88. end || super
  89. end
  90. 30 def method_missing(meth, *args, **kwargs, &block)
  91. 255 return super unless response.respond_to?(meth)
  92. 250 response.__send__(meth, *args, **kwargs, &block)
  93. end
  94. end
  95. 30 module Plugins
  96. #
  97. # This plugin adds support for streaming a response (useful for i.e. "text/event-stream" payloads).
  98. #
  99. # https://gitlab.com/os85/httpx/wikis/Stream
  100. #
  101. 30 module Stream
  102. 30 STREAM_REQUEST_OPTIONS = { timeout: { read_timeout: Float::INFINITY, operation_timeout: 60 }.freeze }.freeze
  103. 30 def self.extra_options(options)
  104. 537 options.merge(
  105. stream: false,
  106. timeout: { read_timeout: Float::INFINITY, operation_timeout: 60 },
  107. stream_response_class: Class.new(StreamResponse, &Options::SET_TEMPORARY_NAME).freeze
  108. )
  109. end
  110. # adds support for the following options:
  111. #
  112. # :stream :: whether the request to process should be handled as a stream (defaults to <tt>false</tt>).
  113. # :stream_response_class :: Class used to build the stream response object.
  114. 30 module OptionsMethods
  115. 30 def option_stream(val)
  116. 579 val
  117. end
  118. 30 def option_stream_response_class(value)
  119. 1319 value
  120. end
  121. 30 def extend_with_plugin_classes(pl)
  122. 364 return super unless defined?(pl::StreamResponseMethods)
  123. 238 @stream_response_class = @stream_response_class.dup
  124. 238 Options::SET_TEMPORARY_NAME[@stream_response_class, pl]
  125. 238 @stream_response_class.__send__(:include, pl::StreamResponseMethods) if defined?(pl::StreamResponseMethods)
  126. 238 super
  127. end
  128. end
  129. 30 module InstanceMethods
  130. 30 def request(*args, **options)
  131. 848 if args.first.is_a?(Request)
  132. 543 requests = args
  133. 543 request = requests.first
  134. 543 unless request.options.stream && !request.stream
  135. 452 if options[:stream]
  136. warn "passing `stream: true` with a request object is not supported anymore. " \
  137. "You can instead build the request object with `stream :true`"
  138. end
  139. 452 return super
  140. end
  141. else
  142. 305 return super unless options[:stream]
  143. 260 requests = build_requests(*args, options)
  144. 260 request = requests.first
  145. end
  146. 351 raise Error, "only 1 response at a time is supported for streaming requests" unless requests.size == 1
  147. 333 @options.stream_response_class.new(request, self)
  148. end
  149. 30 def build_request(verb, uri, params = EMPTY_HASH, options = @options)
  150. 567 return super unless params[:stream]
  151. 378 super(verb, uri, params, options.merge(STREAM_REQUEST_OPTIONS.merge(stream: true)))
  152. end
  153. end
  154. 30 module RequestMethods
  155. 30 attr_accessor :stream
  156. end
  157. 30 module ResponseMethods
  158. 30 def stream
  159. 530 request = @request.root_request if @request.respond_to?(:root_request)
  160. 530 request ||= @request
  161. 530 request.stream
  162. end
  163. end
  164. 30 module ResponseBodyMethods
  165. 30 def initialize(*)
  166. 530 super
  167. 530 @stream = @response.stream
  168. end
  169. 30 def write(chunk)
  170. 1260 return super unless @stream
  171. 1131 return 0 if chunk.empty?
  172. 1009 chunk = decode_chunk(chunk)
  173. 1009 @stream.on_chunk(chunk.dup)
  174. 964 chunk.bytesize
  175. end
  176. 30 private
  177. 30 def transition(*)
  178. 202 return if @stream
  179. 157 super
  180. end
  181. end
  182. end
  183. 30 register_plugin :stream, Stream
  184. end
  185. end

lib/httpx/plugins/stream_bidi.rb

93.99% lines covered

183 relevant lines. 172 lines covered and 11 lines missed.
    
  1. # frozen_string_literal: true
  2. 23 module HTTPX
  3. 23 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. 23 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. 23 module HTTP2Methods
  19. 23 def initialize(*)
  20. 99 super
  21. 99 @lock = Thread::Mutex.new
  22. end
  23. 23 %i[close empty? exhausted? send <<].each do |lock_meth|
  24. 115 class_eval(<<-METH, __FILE__, __LINE__ + 1)
  25. 5 # lock.aware version of +#{lock_meth}+
  26. 5 def #{lock_meth}(*) # def close(*)
  27. return super unless @options.stream
  28. return super if @lock.owned?
  29. # small race condition between
  30. # checking for ownership and
  31. # acquiring lock.
  32. # TODO: fix this at the parser.
  33. @lock.synchronize { super }
  34. end
  35. METH
  36. end
  37. 23 private
  38. 23 %i[join_headers join_trailers join_body].each do |lock_meth|
  39. 69 class_eval(<<-METH, __FILE__, __LINE__ + 1)
  40. 3 # lock.aware version of +#{lock_meth}+
  41. 3 private def #{lock_meth}(*) # private def join_headers(*)
  42. return super unless @options.stream
  43. return super if @lock.owned?
  44. # small race condition between
  45. # checking for ownership and
  46. # acquiring lock.
  47. # TODO: fix this at the parser.
  48. @lock.synchronize { super }
  49. end
  50. METH
  51. end
  52. 23 def handle_stream(stream, request)
  53. 99 return super unless @options.stream
  54. 90 request.flush_buffer_on_body do
  55. 339 next unless request.headers_sent
  56. 249 handle(request, stream)
  57. 249 emit(:flush_buffer)
  58. end
  59. 90 super
  60. end
  61. # when there ain't more chunks, it makes the buffer as full.
  62. 23 def send_chunk(request, stream, chunk, next_chunk)
  63. 373 return super unless @options.stream
  64. 373 super
  65. 373 return if next_chunk
  66. 339 request.transition(:waiting_for_chunk)
  67. 339 throw(:buffer_full)
  68. end
  69. # sets end-stream flag when the request is closed.
  70. 23 def end_stream?(request, next_chunk)
  71. 373 return super unless @options.stream
  72. 373 request.closed? && next_chunk.nil?
  73. end
  74. end
  75. # BidiBuffer is a thread-safe Buffer which can receive data from any thread.
  76. #
  77. # It uses a dual-buffer strategy with mutex protection:
  78. # - +@buffer+ is the main buffer, protected by +@buffer_mutex+
  79. # - +@oob_buffer+ receives data when +@buffer_mutex+ is contended
  80. #
  81. # This allows non-blocking writes from any thread while maintaining thread safety.
  82. 23 class BidiBuffer < Buffer
  83. 23 def initialize(*)
  84. 54 super
  85. 54 @buffer_mutex = Thread::Mutex.new
  86. 54 @oob_mutex = Thread::Mutex.new
  87. 54 @oob_buffer = "".b
  88. end
  89. # buffers the +chunk+ to be sent (thread-safe, non-blocking)
  90. 23 def <<(chunk)
  91. if @buffer_mutex.try_lock
  92. begin
  93. super
  94. ensure
  95. @buffer_mutex.unlock
  96. end
  97. else
  98. # another thread holds the lock, use OOB buffer to avoid blocking
  99. @oob_mutex.synchronize { @oob_buffer << chunk }
  100. end
  101. end
  102. # reconciles the main and secondary buffer (thread-safe, callable from any thread).
  103. 23 def rebuffer
  104. 3856 @buffer_mutex.synchronize do
  105. 3856 @oob_mutex.synchronize do
  106. 3856 return if @oob_buffer.empty?
  107. @buffer << @oob_buffer
  108. @oob_buffer.clear
  109. end
  110. end
  111. end
  112. 23 Buffer.instance_methods - Object.instance_methods - %i[<<].each do |meth|
  113. 23 class_eval(<<-MOD, __FILE__, __LINE__ + 1)
  114. 1 def #{meth}(*) # def empty?
  115. @buffer_mutex.synchronize { super }
  116. end
  117. MOD
  118. end
  119. end
  120. # Proxy to wake up the session main loop when one
  121. # of the connections has buffered data to write. It abides by the HTTPX::_Selectable API,
  122. # which allows it to be registered in the selector alongside actual HTTP-based
  123. # HTTPX::Connection objects.
  124. 23 class Signal
  125. 23 attr_reader :error
  126. 23 def initialize
  127. 108 @closed = false
  128. 108 @error = nil
  129. 108 @pipe_read, @pipe_write = IO.pipe
  130. end
  131. 23 def state
  132. 813 @closed ? :closed : :open
  133. end
  134. # noop
  135. 23 def log(**, &_); end
  136. 23 def to_io
  137. 1519 @pipe_read.to_io
  138. end
  139. 23 def wakeup
  140. 249 return if @closed
  141. 204 @pipe_write.write("\0")
  142. end
  143. 23 def call
  144. 195 return if @closed
  145. 195 @pipe_read.readpartial(1)
  146. end
  147. 23 def interests
  148. 813 return if @closed
  149. 804 :r
  150. end
  151. 23 def timeout; end
  152. 23 def inflight?
  153. !@closed
  154. end
  155. 23 def force_close(*)
  156. terminate
  157. end
  158. 23 def terminate
  159. 81 return if @closed
  160. 63 @pipe_write.close
  161. 63 @pipe_read.close
  162. 63 @closed = true
  163. end
  164. 23 def on_error(error)
  165. @error = error
  166. terminate
  167. end
  168. 23 alias_method :on_io_error, :on_error
  169. # noop (the owner connection will take of it)
  170. 23 def handle_socket_timeout(interval); end
  171. end
  172. 23 class << self
  173. 23 def load_dependencies(klass)
  174. 81 klass.plugin(:stream)
  175. end
  176. 23 def extra_options(options)
  177. 81 options.merge(fallback_protocol: "h2")
  178. end
  179. end
  180. 23 module InstanceMethods
  181. 23 def initialize(*)
  182. 108 @signal = Signal.new
  183. 108 super
  184. end
  185. 23 def close(selector = Selector.new)
  186. 81 @signal.terminate
  187. 81 selector.deregister(@signal)
  188. 81 super
  189. end
  190. 23 def select_connection(connection, selector)
  191. 153 return super unless connection.options.stream
  192. 144 super
  193. 144 selector.register(@signal)
  194. 144 connection.signal = @signal
  195. end
  196. 23 def deselect_connection(connection, *)
  197. 90 return super unless connection.options.stream
  198. 81 super
  199. 81 connection.signal = nil
  200. end
  201. end
  202. # Adds synchronization to request operations which may buffer payloads from different
  203. # threads.
  204. 23 module RequestMethods
  205. 23 attr_accessor :headers_sent
  206. 23 def initialize(*)
  207. 81 super
  208. 72 @headers_sent = false
  209. 72 @closed = false
  210. 72 @flush_buffer_on_body_cb = nil
  211. 72 @mutex = Thread::Mutex.new
  212. end
  213. 23 def flush_buffer_on_body(&cb)
  214. 90 @flush_buffer_on_body_cb = on(:body, &cb)
  215. end
  216. 23 def closed?
  217. 373 return super unless @options.stream
  218. 373 @closed
  219. end
  220. 23 def can_buffer?
  221. 809 return super unless @options.stream
  222. 791 super && @state != :waiting_for_chunk
  223. end
  224. # overrides state management transitions to introduce an intermediate
  225. # +:waiting_for_chunk+ state, which the request transitions to once payload
  226. # is buffered.
  227. 23 def transition(nextstate)
  228. 1362 return super unless @options.stream
  229. 1326 headers_sent = @headers_sent
  230. 1171 case nextstate
  231. when :idle
  232. 27 headers_sent = false
  233. 27 if @flush_buffer_on_body_cb
  234. 27 callbacks(:body).delete(@flush_buffer_on_body_cb)
  235. 27 @flush_buffer_on_body_cb = nil
  236. end
  237. when :waiting_for_chunk
  238. 339 return unless @state == :body
  239. when :body
  240. 542 case @state
  241. when :headers
  242. 90 headers_sent = true
  243. when :waiting_for_chunk
  244. # HACK: to allow super to pass through
  245. 249 @state = :headers
  246. end
  247. end
  248. 1326 super.tap do
  249. # delay setting this up until after the first transition to :body
  250. 1326 @headers_sent = headers_sent
  251. end
  252. end
  253. 23 def <<(chunk)
  254. 270 @mutex.synchronize do
  255. 270 if @drainer
  256. 250 @body.clear if @body.respond_to?(:clear)
  257. 250 @drainer = nil
  258. end
  259. 270 @body << chunk
  260. 270 transition(:body)
  261. end
  262. end
  263. 23 def close
  264. 63 return super unless @options.stream
  265. 63 @mutex.synchronize do
  266. 63 return if @closed
  267. 63 @closed = true
  268. end
  269. # last chunk to send which ends the stream
  270. 63 self << ""
  271. end
  272. end
  273. 23 module RequestBodyMethods
  274. 23 def initialize(*, **)
  275. 81 super
  276. 81 return unless @options.stream
  277. 72 @headers.delete("content-length")
  278. 72 return unless @body
  279. 72 return if @body.is_a?(Transcoder::Body::Encoder)
  280. 9 raise Error, "bidirectional streams only allow the usage of the `:body` param to set request bodies." \
  281. "You must encode it yourself if you wish to do so."
  282. end
  283. 23 def empty?
  284. 486 return super unless @options.stream
  285. 459 false
  286. end
  287. end
  288. # overrides the declaration of +@write_buffer+, which is now a thread-safe buffer
  289. # responding to the same API.
  290. 23 module ConnectionMethods
  291. 23 attr_writer :signal
  292. 23 def initialize(*)
  293. 63 super
  294. 63 return unless @options.stream
  295. 54 @write_buffer = BidiBuffer.new(@options.buffer_size)
  296. end
  297. # rebuffers the +@write_buffer+ before calculating interests.
  298. 23 def interests
  299. 4030 return super unless @options.stream
  300. 3856 @write_buffer.rebuffer
  301. 3856 super
  302. end
  303. 23 def call
  304. 740 return super unless @options.stream && (error = @signal.error)
  305. on_error(error)
  306. end
  307. 23 private
  308. 23 def set_parser_callbacks(parser)
  309. 99 return super unless @options.stream
  310. 90 super
  311. 90 parser.on(:flush_buffer) do
  312. 249 @signal.wakeup if @signal
  313. end
  314. end
  315. end
  316. end
  317. 23 register_plugin :stream_bidi, StreamBidi
  318. end
  319. end

lib/httpx/plugins/tracing.rb

95.0% lines covered

60 relevant lines. 57 lines covered and 3 lines missed.
    
  1. # frozen_string_literal: true
  2. 29 module HTTPX::Plugins
  3. #
  4. # This plugin adds a simple interface to integrate request tracing SDKs.
  5. #
  6. # An example of such an integration is the datadog adapter.
  7. #
  8. # https://gitlab.com/os85/httpx/wikis/Tracing
  9. #
  10. 29 module Tracing
  11. 29 class Wrapper
  12. 29 attr_reader :tracers
  13. 29 protected :tracers
  14. 29 def initialize(*tracers)
  15. 162 @tracers = tracers.flat_map do |tracer|
  16. 336 case tracer
  17. when Wrapper
  18. 72 tracer.tracers
  19. else
  20. 306 tracer
  21. end
  22. end.uniq
  23. end
  24. 29 def merge(tracer)
  25. 18 Wrapper.new(*@tracers, *tracer.tracers)
  26. end
  27. 29 def freeze
  28. @tracers.each(&:freeze).freeze
  29. super
  30. end
  31. 29 %i[start finish reset enabled?].each do |callback|
  32. 116 class_eval(<<-OUT, __FILE__, __LINE__ + 1)
  33. 4 # proxies ##{callback} calls to wrapper tracers.
  34. 4 def #{callback}(*args) # def start(*args)
  35. 4 @tracers.each { |t| t.#{callback}(*args) } # @tracers.each { |t| t.start(*args) }
  36. end # end
  37. OUT
  38. end
  39. end
  40. # adds support for the following options:
  41. #
  42. # :tracer :: object which responds to #start, #finish and #reset.
  43. 29 module OptionsMethods
  44. 29 private
  45. 29 def option_tracer(tracer)
  46. 326 unless tracer.respond_to?(:start) &&
  47. tracer.respond_to?(:finish) &&
  48. tracer.respond_to?(:reset) &&
  49. tracer.respond_to?(:enabled?)
  50. raise TypeError, "#{tracer} must to respond to `#start(r)`, `#finish` and `#reset` and `#enabled?"
  51. end
  52. 326 tracer = Wrapper.new(@tracer, tracer) if @tracer
  53. 326 tracer
  54. end
  55. end
  56. 29 module RequestMethods
  57. 29 attr_accessor :init_time
  58. # intercepts request initialization to inject the tracing logic.
  59. 29 def initialize(*)
  60. 464 super
  61. 464 @init_time = nil
  62. 464 tracer = @options.tracer
  63. 464 return unless tracer && tracer.enabled?(self)
  64. 206 on(:idle) do
  65. 86 tracer.reset(self)
  66. # request is reset when it's retried.
  67. 86 @init_time = nil
  68. end
  69. 206 on(:headers) do
  70. # the usual request init time (when not including the connection handshake)
  71. # should be the time the request is buffered the first time.
  72. 278 @init_time ||= ::Time.now.utc
  73. 278 tracer.start(self)
  74. end
  75. 484 on(:response) { |response| tracer.finish(self, response) }
  76. end
  77. 29 def response=(*)
  78. # init_time should be set when it's send to a connection.
  79. # However, there are situations where connection initialization fails.
  80. # Example is the :ssrf_filter plugin, which raises an error on
  81. # initialize if the host is an IP which matches against the known set.
  82. # in such cases, we'll just set here right here.
  83. 540 @init_time ||= ::Time.now.utc
  84. 540 super
  85. end
  86. end
  87. # Connection mixin
  88. 29 module ConnectionMethods
  89. 29 def initialize(*)
  90. 403 super
  91. 403 @init_time = ::Time.now.utc
  92. end
  93. 29 def send_request_to_parser(request)
  94. 376 if connecting?
  95. # request span timeframe should include the time it took to connect.
  96. 340 request.init_time ||= @init_time
  97. end
  98. 376 super
  99. end
  100. 29 def idling
  101. 79 super
  102. # time of initial request(s) is accounted from the moment
  103. # the connection is back to :idle, and ready to connect again.
  104. 79 @init_time = ::Time.now.utc
  105. end
  106. 29 private
  107. 29 def ping(request)
  108. # if a connection is probed for liveness, the request timeframe should include
  109. # it too.
  110. 36 request.init_time ||= ::Time.now.utc
  111. 36 super
  112. end
  113. end
  114. end
  115. 29 register_plugin :tracing, Tracing
  116. 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. 23 module HTTPX
  3. 23 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. 23 module Upgrade
  11. 23 class << self
  12. 23 def configure(klass)
  13. 35 klass.plugin(:"upgrade/h2")
  14. end
  15. 23 def extra_options(options)
  16. 35 options.merge(upgrade_handlers: {})
  17. end
  18. end
  19. 23 module OptionsMethods
  20. 23 private
  21. 23 def option_upgrade_handlers(value)
  22. 115 raise TypeError, ":upgrade_handlers must be a Hash" unless value.is_a?(Hash)
  23. 115 value
  24. end
  25. end
  26. 23 module InstanceMethods
  27. 23 def fetch_response(request, selector, options)
  28. 188 response = super
  29. 188 return unless response
  30. 108 return response unless response.is_a?(Response)
  31. 108 return response unless response.headers.key?("upgrade")
  32. 46 upgrade_protocol = response.headers["upgrade"].split(/ *, */).first
  33. 46 return response unless upgrade_protocol && options.upgrade_handlers.key?(upgrade_protocol)
  34. 46 protocol_handler = options.upgrade_handlers[upgrade_protocol]
  35. 46 return response unless protocol_handler
  36. 46 log { "upgrading to #{upgrade_protocol}..." }
  37. 46 connection = find_connection(request.uri, selector, options)
  38. # do not upgrade already upgraded connections
  39. 46 return if connection.upgrade_protocol == upgrade_protocol
  40. 35 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. 35 return fetch_response(request, selector, options) if response.status == 101 && !connection.hijacked
  45. 17 response
  46. end
  47. end
  48. 23 module ConnectionMethods
  49. 23 attr_reader :upgrade_protocol, :hijacked
  50. 23 def initialize(*)
  51. 44 super
  52. 44 @upgrade_protocol = nil
  53. end
  54. 23 def hijack_io
  55. 9 @hijacked = true
  56. # connection is taken away from selector and not given back to the pool.
  57. 9 @current_session.deselect_connection(self, @current_selector, true)
  58. end
  59. end
  60. end
  61. 23 register_plugin(:upgrade, Upgrade)
  62. end
  63. end

lib/httpx/plugins/upgrade/h2.rb

95.65% lines covered

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

lib/httpx/pool.rb

100.0% lines covered

110 relevant lines. 110 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 30 require "httpx/selector"
  3. 30 require "httpx/connection"
  4. 30 require "httpx/connection/http2"
  5. 30 require "httpx/connection/http1"
  6. 30 require "httpx/resolver"
  7. 30 module HTTPX
  8. 30 class Pool
  9. 30 using URIExtensions
  10. 30 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. 30 def initialize(options)
  18. 15526 @max_connections = options.fetch(:max_connections, Float::INFINITY)
  19. 15526 @max_connections_per_origin = options.fetch(:max_connections_per_origin, Float::INFINITY)
  20. 15526 @pool_timeout = options.fetch(:pool_timeout, POOL_TIMEOUT)
  21. 23910 @resolvers = Hash.new { |hs, resolver_type| hs[resolver_type] = [] }
  22. 15526 @resolver_mtx = Thread::Mutex.new
  23. 15526 @connections = []
  24. 15526 @connection_mtx = Thread::Mutex.new
  25. 15526 @connections_counter = 0
  26. 15526 @max_connections_cond = ConditionVariable.new
  27. 15526 @origin_counters = Hash.new(0)
  28. 22852 @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. 30 def pop_connection
  32. 16043 @connection_mtx.synchronize do
  33. 16043 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. 30 def checkout_connection(uri, options)
  41. 11207 return checkout_new_connection(uri, options) if options.io
  42. 11134 @connection_mtx.synchronize do
  43. 11134 acquire_connection(uri, options) || begin
  44. 9873 if @connections_counter == @max_connections
  45. # this takes precedence over per-origin
  46. 18 expires_at = Utils.now + @pool_timeout
  47. 18 loop do
  48. 18 @max_connections_cond.wait(@connection_mtx, @pool_timeout)
  49. 18 if (conn = acquire_connection(uri, options))
  50. 6 return conn
  51. end
  52. # if one can afford to create a new connection, do it
  53. 12 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. 11 if (conn = @connections.find { |c| c.state == :closed })
  56. 1 drop_connection(conn)
  57. 1 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. 9 raise PoolTimeoutError.new(@pool_timeout,
  63. 1 "Timed out after #{@pool_timeout} seconds while waiting for a connection")
  64. end
  65. end
  66. 9858 if @origin_counters[uri.origin] == @max_connections_per_origin
  67. 18 expires_at = Utils.now + @pool_timeout
  68. 18 loop do
  69. 18 @origin_conds[uri.origin].wait(@connection_mtx, @pool_timeout)
  70. 18 if (conn = acquire_connection(uri, options))
  71. 9 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. 9 raise(PoolTimeoutError.new(@pool_timeout,
  77. 1 "Timed out after #{@pool_timeout} seconds while waiting for a connection to #{uri.origin}"))
  78. end
  79. end
  80. 8891 @connections_counter += 1
  81. 9840 @origin_counters[uri.origin] += 1
  82. 9840 checkout_new_connection(uri, options)
  83. end
  84. end
  85. end
  86. 30 def checkin_connection(connection)
  87. 11127 return if connection.options.io
  88. 11078 @connection_mtx.synchronize do
  89. 11078 if connection.coalesced? || connection.state == :idle
  90. # when connections coalesce
  91. 57 drop_connection(connection)
  92. 57 return
  93. end
  94. 11021 @connections << connection
  95. 11021 @max_connections_cond.signal
  96. 11021 @origin_conds[connection.origin.to_s].signal
  97. end
  98. end
  99. 30 def checkout_mergeable_connection(connection)
  100. 9967 return if connection.options.io
  101. 9967 @connection_mtx.synchronize do
  102. 9967 idx = @connections.find_index do |ch|
  103. 368 ch != connection && ch.mergeable?(connection)
  104. end
  105. 9967 @connections.delete_at(idx) if idx
  106. end
  107. end
  108. 30 def reset_resolvers
  109. 19392 @resolver_mtx.synchronize { @resolvers.clear }
  110. end
  111. 30 def checkout_resolver(options)
  112. 9602 resolver_type = options.resolver_class
  113. 9602 @resolver_mtx.synchronize do
  114. 9602 resolvers = @resolvers[resolver_type]
  115. 9602 idx = resolvers.find_index do |res|
  116. 277 res.options.resolver_options_match?(options)
  117. end
  118. 9602 resolvers.delete_at(idx) if idx
  119. end || checkout_new_resolver(resolver_type, options)
  120. end
  121. 30 def checkin_resolver(resolver)
  122. 9384 if resolver.is_a?(Resolver::Multi)
  123. 8737 resolver_class = resolver.resolvers.first.class
  124. else
  125. 647 resolver_class = resolver.class
  126. 647 resolver = resolver.multi
  127. end
  128. # a multi requires all sub-resolvers being closed in order to be
  129. # correctly checked back in.
  130. 9384 return unless resolver.closed?
  131. 9363 @resolver_mtx.synchronize do
  132. 9363 resolvers = @resolvers[resolver_class]
  133. 9363 resolvers << resolver unless resolvers.include?(resolver)
  134. end
  135. end
  136. skipped # :nocov:
  137. skipped def inspect
  138. skipped "#<#{self.class}:#{object_id} " \
  139. skipped "@max_connections=#{@max_connections} " \
  140. skipped "@max_connections_per_origin=#{@max_connections_per_origin} " \
  141. skipped "@pool_timeout=#{@pool_timeout} " \
  142. skipped "@connections=#{@connections.size}>"
  143. skipped end
  144. skipped # :nocov:
  145. 30 private
  146. 30 def acquire_connection(uri, options)
  147. 11170 idx = @connections.find_index do |connection|
  148. 1605 connection.match?(uri, options)
  149. end
  150. 11170 return unless idx
  151. 1276 @connections.delete_at(idx)
  152. end
  153. 30 def checkout_new_connection(uri, options)
  154. 9922 connection = options.connection_class.new(uri, options)
  155. 9990 connection.log(level: 2) { "created connection##{connection.object_id} in pool##{object_id}" }
  156. 9904 connection
  157. end
  158. 30 def checkout_new_resolver(resolver_type, options)
  159. 9325 resolver = if resolver_type.multi?
  160. 9147 Resolver::Multi.new(resolver_type, options)
  161. else
  162. 178 resolver_type.new(options)
  163. end
  164. 9411 resolver.log(level: 2) { "created resolver##{resolver.object_id} in pool##{object_id}" }
  165. 9325 resolver
  166. end
  167. # drops and returns the +connection+ from the connection pool; if +connection+ is <tt>nil</tt> (default),
  168. # the first available connection from the pool will be dropped.
  169. 30 def drop_connection(connection = nil)
  170. 16101 if connection
  171. 58 @connections.delete(connection)
  172. else
  173. 16043 connection = @connections.shift
  174. 16043 return unless connection
  175. end
  176. 5780 @connections_counter -= 1
  177. 6405 @origin_conds.delete(connection.origin) if (@origin_counters[connection.origin.to_s] -= 1).zero?
  178. 6405 connection
  179. end
  180. end
  181. 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. 30 module HTTPX
  3. 30 module Punycode
  4. 30 module_function
  5. begin
  6. 30 require "idnx"
  7. 29 def encode_hostname(hostname)
  8. 36 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

157 relevant lines. 157 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 30 require "delegate"
  3. 30 require "forwardable"
  4. 30 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. 30 class Request
  8. 30 extend Forwardable
  9. 30 include Loggable
  10. 30 include Callbacks
  11. 30 using URIExtensions
  12. 30 ALLOWED_URI_SCHEMES = %w[https http].freeze
  13. # the upcased string HTTP verb for this request.
  14. 30 attr_reader :verb
  15. # the absolute URI object for this request.
  16. 30 attr_reader :uri
  17. # an HTTPX::Headers object containing the request HTTP headers.
  18. 30 attr_reader :headers
  19. # an HTTPX::Request::Body object containing the request body payload (or +nil+, whenn there is none).
  20. 30 attr_reader :body
  21. # a symbol describing which frame is currently being flushed.
  22. 30 attr_reader :state
  23. # an HTTPX::Options object containing request options.
  24. 30 attr_reader :options
  25. # the corresponding HTTPX::Response object, when there is one.
  26. 30 attr_reader :response
  27. # Exception raised during enumerable body writes.
  28. 30 attr_reader :drain_error
  29. # when this request is sent via HTTP/2, it'll use this hash of options to set the priority of the
  30. # respective HTTP/2 frame.
  31. 30 attr_reader :http2_stream_options
  32. # The IP address from the peer server.
  33. 30 attr_accessor :peer_address
  34. # the connection the request is currently being sent to (none if before or after transaction)
  35. 30 attr_writer :connection
  36. # callback triggered when a response (which may not be the final response) was assigned to the request.
  37. 30 attr_writer :on_response_arrived
  38. 30 attr_writer :persistent
  39. 30 attr_reader :active_timeouts
  40. # will be +true+ when request body has been completely flushed.
  41. 30 def_delegator :@body, :empty?
  42. # closes the body
  43. 30 def_delegator :@body, :close
  44. # initializes the instance with the given +verb+ (an upppercase String, ex. 'GEt'),
  45. # an absolute or relative +uri+ (either as String or URI::HTTP object), the
  46. # request +options+ (instance of HTTPX::Options) and an optional Hash of +params+.
  47. #
  48. # Besides any of the options documented in HTTPX::Options (which would override or merge with what
  49. # +options+ sets), it accepts also the following:
  50. #
  51. # :params :: hash or array of key-values which will be encoded and set in the query string of request uris.
  52. # :body :: to be encoded in the request body payload. can be a String, an IO object (i.e. a File), or an Enumerable.
  53. # :form :: hash of array of key-values which will be form-urlencoded- or multipart-encoded in requests body payload.
  54. # :json :: hash of array of key-values which will be JSON-encoded in requests body payload.
  55. # :xml :: Nokogiri XML nodes which will be encoded in requests body payload.
  56. # :http2_stream_options :: hash of options to be used to set the HTTP/2 priority by sending an initial PRIORITY frame.
  57. #
  58. # :body, :form, :json and :xml are all mutually exclusive, i.e. only one of them gets picked up.
  59. 30 def initialize(verb, uri, options, params = EMPTY_HASH)
  60. 13586 @verb = verb.to_s.upcase
  61. 13586 @uri = Utils.to_uri(uri)
  62. 13585 @headers = options.headers.dup
  63. 13585 merge_headers(params.delete(:headers)) if params.key?(:headers)
  64. 13585 @query_params = params.delete(:params) if params.key?(:params)
  65. 13585 @http2_stream_options = params.key?(:http2_stream_options) ? params.delete(:http2_stream_options) : EMPTY_HASH
  66. 13585 @body = options.request_body_class.new(@headers, options, **params)
  67. 13567 @options = @body.options
  68. 13567 if @uri.relative? || @uri.host.nil?
  69. 712 origin = @options.origin
  70. 712 raise(Error, "invalid URI: #{@uri}") unless origin
  71. 684 base_path = @options.base_path
  72. 684 @uri = origin.merge("#{base_path}#{@uri}")
  73. end
  74. 13539 raise UnsupportedSchemeError, "#{@uri}: #{@uri.scheme}: unsupported URI scheme" unless ALLOWED_URI_SCHEMES.include?(@uri.scheme)
  75. 13525 @state = :idle
  76. 2528 @connection = @response =
  77. @drainer = @peer_address =
  78. 10997 @informational_status = @on_response_arrived = nil
  79. 13525 @ping = @started = false
  80. 13525 @persistent = @options.persistent
  81. 13525 @active_timeouts = []
  82. end
  83. # dupped initialization
  84. 30 def initialize_dup(orig)
  85. 1757 super
  86. 1757 @uri = orig.instance_variable_get(:@uri).dup
  87. 1757 @headers = orig.instance_variable_get(:@headers).dup
  88. 1757 @body = orig.instance_variable_get(:@body).dup
  89. end
  90. 30 def complete!(response = @response)
  91. 10592 emit(:complete, response)
  92. end
  93. # whether request has been buffered with a ping
  94. 30 def ping?
  95. 1017 @ping
  96. end
  97. # marks the request as having been buffered with a ping
  98. 30 def ping!
  99. 127 @ping = true
  100. end
  101. # the read timeout defined for this request.
  102. 30 def read_timeout
  103. 22555 @options.timeout[:read_timeout]
  104. end
  105. # the write timeout defined for this request.
  106. 30 def write_timeout
  107. 22555 @options.timeout[:write_timeout]
  108. end
  109. # the request timeout defined for this request.
  110. 30 def request_timeout
  111. 22182 @options.timeout[:request_timeout]
  112. end
  113. # the total request timeout defined for this request.
  114. 30 def total_request_timeout
  115. 21258 @options.timeout[:total_request_timeout]
  116. end
  117. 30 def persistent?
  118. 6572 @persistent
  119. end
  120. # if the request contains trailer headers
  121. 30 def trailers?
  122. 3618 defined?(@trailers)
  123. end
  124. # returns an instance of HTTPX::Headers containing the trailer headers
  125. 30 def trailers
  126. 99 @trailers ||= @options.headers_class.new
  127. end
  128. # returns +:r+ or +:w+, depending on whether the request is waiting for a response or flushing.
  129. 30 def interests
  130. 55160 return :r if @state == :done || @state == :expect
  131. 5904 :w
  132. end
  133. 30 def can_buffer?
  134. 35382 @state != :done
  135. end
  136. 30 def started?
  137. 22555 @started
  138. end
  139. # merges +h+ into the instance of HTTPX::Headers of the request.
  140. 30 def merge_headers(h)
  141. 1388 @headers = @headers.merge(h)
  142. 1388 return unless @headers.key?("range")
  143. 18 @headers.delete("accept-encoding")
  144. end
  145. # the URI scheme of the request +uri+.
  146. 30 def scheme
  147. 5251 @uri.scheme
  148. end
  149. # sets the +response+ on this request.
  150. 30 def response=(response)
  151. 13243 return unless response
  152. 11997 case response
  153. when Response
  154. 11178 if response.status < 200
  155. # deal with informational responses
  156. 164 if response.status == 100 && @headers.key?("expect")
  157. 137 @informational_status = response.status
  158. 137 return
  159. end
  160. # 103 Early Hints advertises resources in document to browsers.
  161. # not very relevant for an HTTP client, discard.
  162. 27 return if response.status >= 103
  163. end
  164. when ErrorResponse
  165. 2065 response.error.connection = nil if response.error.respond_to?(:connection=)
  166. end
  167. 13106 @response = response
  168. 13106 emit(:response_started, response)
  169. end
  170. # returnns the URI path of the request +uri+.
  171. 30 def path
  172. 12547 path = uri.path.dup
  173. 12547 path = +"" if path.nil?
  174. 12547 path << "/" if path.empty?
  175. 12547 path << "?#{query}" unless query.empty?
  176. 12547 path
  177. end
  178. # returs the URI authority of the request.
  179. #
  180. # session.build_request("GET", "https://google.com/query").authority #=> "google.com"
  181. # session.build_request("GET", "http://internal:3182/a").authority #=> "internal:3182"
  182. 30 def authority
  183. 12093 @uri.authority
  184. end
  185. # returs the URI origin of the request.
  186. #
  187. # session.build_request("GET", "https://google.com/query").authority #=> "https://google.com"
  188. # session.build_request("GET", "http://internal:3182/a").authority #=> "http://internal:3182"
  189. 30 def origin
  190. 5580 @uri.origin
  191. end
  192. # returs the URI query string of the request (when available).
  193. #
  194. # session.build_request("GET", "https://search.com").query #=> ""
  195. # session.build_request("GET", "https://search.com?q=a").query #=> "q=a"
  196. # session.build_request("GET", "https://search.com", params: { q: "a"}).query #=> "q=a"
  197. # session.build_request("GET", "https://search.com?q=a", params: { foo: "bar"}).query #=> "q=a&foo&bar"
  198. 30 def query
  199. 13838 return @query if defined?(@query)
  200. 11239 query = []
  201. 11239 if (q = @query_params) && !q.empty?
  202. 190 query << Transcoder::Form.encode(q)
  203. end
  204. 11239 query << @uri.query if @uri.query
  205. 11239 @query = query.join("&")
  206. end
  207. # consumes and returns the next available chunk of request body that can be sent
  208. 30 def drain_body
  209. 11417 return nil if @body.nil?
  210. 11417 @drainer ||= @body.each
  211. 11417 chunk = @drainer.next.dup
  212. 7453 emit(:body_chunk, chunk)
  213. 7453 chunk
  214. rescue StopIteration
  215. 3936 nil
  216. rescue StandardError => e
  217. 28 @drain_error = e
  218. 28 nil
  219. end
  220. skipped # :nocov:
  221. skipped def inspect
  222. skipped "#<#{self.class}:#{object_id} " \
  223. skipped "#{@verb} " \
  224. skipped "#{uri} " \
  225. skipped "@headers=#{@headers} " \
  226. skipped "@body=#{@body}>"
  227. skipped end
  228. skipped # :nocov:
  229. # moves on to the +nextstate+ of the request state machine (when all preconditions are met)
  230. 30 def transition(nextstate)
  231. 62475 case nextstate
  232. when :idle
  233. 12285 @body.rewind
  234. 12285 @ping = false
  235. 12285 @response = @drainer = nil
  236. 12285 @active_timeouts.clear
  237. when :headers
  238. 15214 return unless @state == :idle
  239. 12070 @started = true
  240. when :body
  241. 15466 return unless @state == :headers ||
  242. @state == :expect
  243. 12707 if @headers.key?("expect")
  244. 534 if @informational_status && @informational_status == 100
  245. # check for 100 Continue response, and deallocate the var
  246. # if @informational_status == 100
  247. # @response = nil
  248. # end
  249. else
  250. 406 return if @state == :expect # do not re-set it
  251. 146 nextstate = :expect
  252. end
  253. end
  254. when :trailers
  255. 12362 return unless @state == :body
  256. when :done
  257. 12371 return if @state == :expect
  258. end
  259. 61306 log(level: 3) { "#{@state}] -> #{nextstate}" }
  260. 61050 @state = nextstate
  261. 61050 emit(@state, self)
  262. 33934 nil
  263. end
  264. # whether the request supports the 100-continue handshake and already processed the 100 response.
  265. 30 def expects?
  266. 10798 @headers["expect"] == "100-continue" && @informational_status == 100 && !@response
  267. end
  268. 30 def set_timeout_callback(event, &callback)
  269. 82239 clb = once(event, &callback)
  270. # reset timeout callbacks when requests get rerouted to a different connection
  271. 82239 once(:idle) do
  272. 29025 callbacks(event).delete(clb)
  273. end
  274. end
  275. 30 def handle_error(error)
  276. 829 if (connection = @connection)
  277. 793 connection.on_error(error, self)
  278. else
  279. 36 response = ErrorResponse.new(self, error)
  280. 36 self.response = response
  281. 36 emit_response(response)
  282. end
  283. end
  284. 30 def emit_response(response)
  285. 12753 emit(:response, response)
  286. 12735 return unless @on_response_arrived
  287. 12094 @on_response_arrived.call
  288. end
  289. end
  290. end
  291. 30 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. 30 module HTTPX
  3. # Implementation of the HTTP Request body as a delegator which iterates (responds to +each+) payload chunks.
  4. 30 class Request::Body < SimpleDelegator
  5. 30 class << self
  6. 30 def new(_, options, body: nil, **params)
  7. 13594 if body.is_a?(self)
  8. # request derives its options from body
  9. 18 body.options = options.merge(params)
  10. 16 return body
  11. end
  12. 13576 super
  13. end
  14. end
  15. 30 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. 30 def initialize(h, options, **params)
  25. 13576 @headers = h
  26. 13576 @body = self.class.initialize_body(params)
  27. 13576 @options = options.merge(params)
  28. 13576 if @body
  29. 3865 if @options.compress_request_body && @headers.key?("content-encoding")
  30. 109 @headers.get("content-encoding").each do |encoding|
  31. 109 @body = self.class.initialize_deflater_body(@body, encoding)
  32. end
  33. end
  34. 3865 @headers["content-type"] ||= @body.content_type
  35. 3865 @headers["content-length"] = @body.bytesize unless unbounded_body?
  36. end
  37. 13567 super(@body)
  38. end
  39. # consumes and yields the request payload in chunks.
  40. 30 def each(&block)
  41. 8203 return enum_for(__method__) unless block
  42. 4106 return if @body.nil?
  43. 4025 body = stream(@body)
  44. 4025 if body.respond_to?(:read)
  45. 6539 while (chunk = body.read(16_384))
  46. 4005 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. 2383 elsif body.respond_to?(:each)
  51. 838 body.each(&block)
  52. else
  53. 1546 block[body.to_s]
  54. end
  55. end
  56. 30 def close
  57. 531 @body.close if @body.respond_to?(:close)
  58. end
  59. # if the +@body+ is rewindable, it rewinnds it.
  60. 30 def rewind
  61. 12357 return if empty?
  62. 242 @body.rewind if @body.respond_to?(:rewind)
  63. end
  64. # return +true+ if the +body+ has been fully drained (or does nnot exist).
  65. 30 def empty?
  66. 37713 return true if @body.nil?
  67. 10219 return false if chunked?
  68. 10111 @body.bytesize.zero?
  69. end
  70. # returns the +@body+ payload size in bytes.
  71. 30 def bytesize
  72. 4872 return 0 if @body.nil?
  73. 144 @body.bytesize
  74. end
  75. # sets the body to yield using chunked trannsfer encoding format.
  76. 30 def stream(body)
  77. 4025 return body unless chunked?
  78. 108 Transcoder::Chunker.encode(body.enum_for(:each))
  79. end
  80. # returns whether the body yields infinitely.
  81. 30 def unbounded_body?
  82. 4500 return @unbounded_body if defined?(@unbounded_body)
  83. 3946 @unbounded_body = !@body.nil? && (chunked? || @body.bytesize == Float::INFINITY)
  84. end
  85. # returns whether the chunked transfer encoding header is set.
  86. 30 def chunked?
  87. 24944 @headers["transfer-encoding"] == "chunked"
  88. end
  89. # sets the chunked transfer encoding header.
  90. 30 def chunk!
  91. 36 @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. 30 class << self
  100. 30 def initialize_body(params)
  101. 13396 if (body = params.delete(:body))
  102. # @type var body: bodyIO
  103. 1761 Transcoder::Body.encode(body)
  104. 11635 elsif (form = params.delete(:form))
  105. 1822 if Transcoder::Multipart.multipart?(form)
  106. # @type var form: Transcoder::Multipart::multipart_input
  107. 1142 Transcoder::Multipart.encode(form)
  108. else
  109. # @type var form: Transcoder::urlencoded_input
  110. 680 Transcoder::Form.encode(form)
  111. end
  112. 9813 elsif (json = params.delete(:json))
  113. # @type var body: _ToJson
  114. 102 Transcoder::JSON.encode(json)
  115. end
  116. end
  117. # returns the +body+ wrapped with the correct deflater accordinng to the given +encodisng+.
  118. 30 def initialize_deflater_body(body, encoding)
  119. 100 case encoding
  120. when "gzip"
  121. 55 Transcoder::GZIP.encode(body)
  122. when "deflate"
  123. 27 Transcoder::Deflate.encode(body)
  124. when "identity"
  125. 18 body
  126. else
  127. 9 body
  128. end
  129. end
  130. end
  131. end
  132. end

lib/httpx/resolver.rb

91.67% lines covered

60 relevant lines. 55 lines covered and 5 lines missed.
    
  1. # frozen_string_literal: true
  2. 30 require "socket"
  3. 30 require "resolv"
  4. 30 module HTTPX
  5. 30 module Resolver
  6. 30 extend self
  7. 30 RESOLVE_TIMEOUT = [2, 3].freeze
  8. 30 require "httpx/resolver/entry"
  9. 30 require "httpx/resolver/cache"
  10. 30 require "httpx/resolver/resolver"
  11. 30 require "httpx/resolver/system"
  12. 30 require "httpx/resolver/native"
  13. 30 require "httpx/resolver/https"
  14. 30 require "httpx/resolver/multi"
  15. 30 @identifier_mutex = Thread::Mutex.new
  16. 30 @identifier = 1
  17. 30 def supported_ip_families
  18. 9666 if Utils.in_ractor?
  19. Ractor.store_if_absent(:httpx_supported_ip_families) { find_supported_ip_families }
  20. else
  21. 9666 @supported_ip_families ||= find_supported_ip_families
  22. end
  23. end
  24. 30 def generate_id
  25. 1056 if Utils.in_ractor?
  26. identifier = Ractor.store_if_absent(:httpx_resolver_identifier) { -1 }
  27. Ractor.current[:httpx_resolver_identifier] = (identifier + 1) & 0xFFFF
  28. else
  29. 2112 id_synchronize { @identifier = (@identifier + 1) & 0xFFFF }
  30. end
  31. end
  32. 30 def encode_dns_query(hostname, type: Resolv::DNS::Resource::IN::A, message_id: generate_id)
  33. 1003 Resolv::DNS::Message.new(message_id).tap do |query|
  34. 1056 query.rd = 1
  35. 1056 query.add_question(hostname, type)
  36. 105 end.encode
  37. end
  38. 30 def decode_dns_answer(payload)
  39. 52 begin
  40. 808 message = Resolv::DNS::Message.decode(payload)
  41. rescue Resolv::DNS::DecodeError => e
  42. 7 return :decode_error, e
  43. end
  44. # no domain was found
  45. 801 return :no_domain_found if message.rcode == Resolv::DNS::RCode::NXDomain
  46. 357 return :message_truncated if message.tc == 1
  47. 343 if message.rcode != Resolv::DNS::RCode::NoError
  48. 14 case message.rcode
  49. when Resolv::DNS::RCode::ServFail
  50. 7 return :retriable_error, message.rcode
  51. else
  52. 7 return :dns_error, message.rcode
  53. end
  54. end
  55. 329 addresses = []
  56. 329 now = Utils.now
  57. 329 message.each_answer do |question, _, value|
  58. 1231 case value
  59. when Resolv::DNS::Resource::IN::CNAME
  60. 21 addresses << {
  61. "name" => question.to_s,
  62. 21 "TTL" => (now + value.ttl),
  63. "alias" => value.name.to_s,
  64. }
  65. when Resolv::DNS::Resource::IN::A,
  66. Resolv::DNS::Resource::IN::AAAA
  67. 1233 addresses << {
  68. 26 "name" => question.to_s,
  69. 1233 "TTL" => (now + value.ttl),
  70. "data" => value.address.to_s,
  71. }
  72. end
  73. end
  74. 329 [:ok, addresses]
  75. end
  76. 30 private
  77. 30 def id_synchronize(&block)
  78. 1056 @identifier_mutex.synchronize(&block)
  79. end
  80. 30 def find_supported_ip_families
  81. 30 list = Socket.ip_address_list
  82. 1 begin
  83. 120 if list.any? { |a| a.ipv6? && !a.ipv6_loopback? && !a.ipv6_linklocal? }
  84. [Socket::AF_INET6, Socket::AF_INET]
  85. else
  86. 30 [Socket::AF_INET]
  87. end
  88. rescue NotImplementedError
  89. [Socket::AF_INET]
  90. 11 end.freeze
  91. end
  92. end
  93. end

lib/httpx/resolver/cache.rb

100.0% lines covered

4 relevant lines. 4 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 30 require "httpx/resolver/cache/base"
  3. 30 require "httpx/resolver/cache/memory"
  4. 30 module HTTPX::Resolver
  5. # The internal resolvers cache adapters are defined under this namespace.
  6. #
  7. # Adapters must comply with the Resolver Cache Adapter API and implement the following methods:
  8. #
  9. # * #resolve: (String hostname) -> Array[HTTPX::Entry]? => resolves hostname to a list of cached IPs (if found in cache or system)
  10. # * #get: (String hostname) -> Array[HTTPX::Entry]? => resolves hostname to a list of cached IPs (if found in cache)
  11. # * #set: (String hostname, Integer ip_family, Array[dns_result]) -> void => stores the set of results in the cache indexes for
  12. # the hostname and the IP family
  13. # * #evict: (String hostname, _ToS ip) -> void => evicts the ip for the hostname from the cache (usually done when no longer reachable)
  14. 30 module Cache
  15. end
  16. end

lib/httpx/resolver/cache/base.rb

98.41% lines covered

63 relevant lines. 62 lines covered and 1 lines missed.
    
  1. # frozen_string_literal: true
  2. 30 require "resolv"
  3. 30 module HTTPX
  4. 30 module Resolver::Cache
  5. # Base class of the Resolver Cache adapter implementations.
  6. #
  7. # While resolver caches are not required to inherit from this class, it nevertheless provides
  8. # common useful functions for desired functionality, such as singleton object ractor-safe access,
  9. # or a default #resolve implementation which deals with IPs and the system hosts file.
  10. #
  11. 30 class Base
  12. 30 MAX_CACHE_SIZE = 512
  13. 30 CACHE_MUTEX = Thread::Mutex.new
  14. 30 HOSTS = Resolv::Hosts.new
  15. 30 @cache = nil
  16. 30 class << self
  17. 30 attr_reader :hosts_resolver
  18. # returns the singleton instance to be used within the current ractor.
  19. 30 def cache(label)
  20. 16293 return Ractor.store_if_absent(:"httpx_resolver_cache_#{label}") { new } if Utils.in_ractor?
  21. 16293 @cache ||= CACHE_MUTEX.synchronize do
  22. 25 @cache || new
  23. end
  24. end
  25. end
  26. # resolves +hostname+ into an instance of HTTPX::Resolver::Entry if +hostname+ is an IP,
  27. # or can be found in the cache, or can be found in the system hosts file.
  28. 30 def resolve(hostname)
  29. 9232 ip_resolve(hostname) || get(hostname) || hosts_resolve(hostname)
  30. end
  31. 30 private
  32. # tries to convert +hostname+ into an IPAddr, returns <tt>nil</tt> otherwise.
  33. 30 def ip_resolve(hostname)
  34. 9232 [Resolver::Entry.new(hostname)]
  35. rescue ArgumentError
  36. end
  37. # matches +hostname+ to entries in the hosts file, returns <tt>nil</nil> if none is
  38. # found, or there is no hosts file.
  39. 30 def hosts_resolve(hostname)
  40. 924 ips = if Utils.in_ractor?
  41. Ractor.store_if_absent(:httpx_hosts_resolver) { Resolv::Hosts.new }
  42. else
  43. 924 HOSTS
  44. end.getaddresses(hostname)
  45. 924 return if ips.empty?
  46. 1572 ips.map { |ip| Resolver::Entry.new(ip) }
  47. rescue IOError
  48. end
  49. # not to be used directly!
  50. 30 def _get(hostname, lookups, hostnames, ttl)
  51. 8524 return unless lookups.key?(hostname)
  52. 7583 entries = lookups[hostname]
  53. 7583 return unless entries
  54. 7583 entries.delete_if do |address|
  55. 19866 address["TTL"] < ttl
  56. end
  57. 7583 if entries.empty?
  58. 37 lookups.delete(hostname)
  59. 37 hostnames.delete(hostname)
  60. end
  61. 7583 ips = entries.flat_map do |address|
  62. 19829 if (als = address["alias"])
  63. 18 _get(als, lookups, hostnames, ttl)
  64. else
  65. 19811 Resolver::Entry.new(address["data"], address["TTL"])
  66. end
  67. end.compact
  68. 7583 ips unless ips.empty?
  69. end
  70. 30 def _set(hostname, family, entries, lookups, hostnames)
  71. # lru cleanup
  72. 13285 while lookups.size >= MAX_CACHE_SIZE
  73. 3384 hs = hostnames.shift
  74. 3384 lookups.delete(hs)
  75. end
  76. 12909 hostnames << hostname
  77. 12909 lookups[hostname] ||= [] # when there's no default proc
  78. 11481 case family
  79. when Socket::AF_INET6
  80. 90 lookups[hostname].concat(entries)
  81. when Socket::AF_INET
  82. 12819 lookups[hostname].unshift(*entries)
  83. end
  84. 12909 entries.each do |entry|
  85. 12994 name = entry["name"]
  86. 12994 next unless name != hostname
  87. 231 lookups[name] ||= []
  88. 213 case family
  89. when Socket::AF_INET6
  90. 18 lookups[name] << entry
  91. when Socket::AF_INET
  92. 213 lookups[name].unshift(entry)
  93. end
  94. end
  95. end
  96. 30 def _evict(hostname, ip, lookups, hostnames)
  97. 25 return unless lookups.key?(hostname)
  98. 18 entries = lookups[hostname]
  99. 18 return unless entries
  100. 36 entries.delete_if { |entry| entry["data"] == ip }
  101. 18 return unless entries.empty?
  102. 18 lookups.delete(hostname)
  103. 18 hostnames.delete(hostname)
  104. end
  105. end
  106. end
  107. end

lib/httpx/resolver/cache/file.rb

100.0% lines covered

30 relevant lines. 30 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 9 require "pstore"
  3. 9 require "tmpdir"
  4. 9 module HTTPX
  5. 9 module Resolver::Cache
  6. # Implementation of a file resolver cache.
  7. 9 class File < Base
  8. # default path where the resolver cache is stored. It's versioned, as the file may
  9. # change format in-between releases, and it'd signal it as corrupted.
  10. 9 DEFAULT_PATH = ::File.join(Dir.tmpdir, "httpx-ruby-#{VERSION}.cache")
  11. 9 def initialize(path = DEFAULT_PATH)
  12. 18 super()
  13. 18 @store = PStore.new(path, true)
  14. end
  15. 9 def get(hostname)
  16. 99 now = Utils.now
  17. 99 @store.transaction do
  18. 99 lookups = @store[:lookups] || EMPTY_HASH
  19. 99 hostnames = @store[:hostnames] || EMPTY
  20. 99 _get(hostname, lookups, hostnames, now)
  21. end
  22. end
  23. 9 def set(hostname, family, entries)
  24. 6363 @store.transaction do
  25. 6363 lookups = @store[:lookups] || {}
  26. 6363 hostnames = @store[:hostnames] || []
  27. 6363 _set(hostname, family, entries, lookups, hostnames)
  28. 5656 @store[:lookups] = lookups
  29. 5656 @store[:hostnames] = hostnames
  30. end
  31. end
  32. 9 def evict(hostname, ip)
  33. 9 ip = ip.to_s
  34. 9 @store.transaction do
  35. 9 lookups = @store[:lookups] || EMPTY_HASH
  36. 9 hostnames = @store[:hostnames] || EMPTY
  37. 9 _evict(hostname, ip, lookups, hostnames)
  38. 8 @store[:lookups] = lookups
  39. 8 @store[:hostnames] = hostnames
  40. end
  41. end
  42. end
  43. end
  44. end

lib/httpx/resolver/cache/memory.rb

100.0% lines covered

22 relevant lines. 22 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 30 module HTTPX
  3. 30 module Resolver::Cache
  4. # Implementation of a thread-safe in-memory LRU resolver cache.
  5. 30 class Memory < Base
  6. 30 def initialize
  7. 44 super
  8. 44 @hostnames = []
  9. 5972 @lookups = Hash.new { |h, k| h[k] = [] }
  10. 44 @lookup_mutex = Thread::Mutex.new
  11. end
  12. 30 def get(hostname)
  13. 8407 now = Utils.now
  14. 8407 synchronize do |lookups, hostnames|
  15. 8407 _get(hostname, lookups, hostnames, now)
  16. end
  17. end
  18. 30 def set(hostname, family, entries)
  19. 6546 synchronize do |lookups, hostnames|
  20. 6546 _set(hostname, family, entries, lookups, hostnames)
  21. end
  22. end
  23. 30 def evict(hostname, ip)
  24. 16 ip = ip.to_s
  25. 16 synchronize do |lookups, hostnames|
  26. 16 _evict(hostname, ip, lookups, hostnames)
  27. end
  28. end
  29. 30 private
  30. 30 def synchronize
  31. 29938 @lookup_mutex.synchronize { yield(@lookups, @hostnames) }
  32. end
  33. end
  34. end
  35. 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. 30 require "ipaddr"
  3. 30 module HTTPX
  4. 30 module Resolver
  5. 30 class Entry < SimpleDelegator
  6. 30 attr_reader :address
  7. 30 def self.convert(address)
  8. 56 new(address, rescue_on_convert: true)
  9. end
  10. 30 def initialize(address, expires_in = Float::INFINITY, rescue_on_convert: false)
  11. 31710 @expires_in = expires_in
  12. 31710 @address = address.is_a?(IPAddr) ? address : IPAddr.new(address.to_s)
  13. 23378 super(@address)
  14. rescue IPAddr::InvalidAddressError
  15. 8332 raise unless rescue_on_convert
  16. 24 @address = address.to_s
  17. 24 super(@address)
  18. end
  19. 30 def expired?
  20. 3374 @expires_in < Utils.now
  21. end
  22. end
  23. end
  24. end

lib/httpx/resolver/https.rb

84.49% lines covered

187 relevant lines. 158 lines covered and 29 lines missed.
    
  1. # frozen_string_literal: true
  2. 30 require "resolv"
  3. 30 require "uri"
  4. 30 require "forwardable"
  5. 30 require "httpx/base64"
  6. 30 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. 30 class Resolver::HTTPS < Resolver::Resolver
  12. 30 extend Forwardable
  13. 30 using URIExtensions
  14. 30 module DNSExtensions
  15. 30 refine Resolv::DNS do
  16. 30 def generate_candidates(name)
  17. 133 @config.generate_candidates(name)
  18. end
  19. end
  20. end
  21. 30 using DNSExtensions
  22. 30 NAMESERVER = "https://1.1.1.1/dns-query"
  23. 2 DEFAULTS = {
  24. 28 uri: NAMESERVER,
  25. use_get: false,
  26. }.freeze
  27. 30 def_delegators :@resolver_connection, :connecting?, :to_io, :call, :close,
  28. :closed?, :deactivate, :terminate, :inflight?, :handle_socket_timeout
  29. 30 def initialize(_, options)
  30. 149 super
  31. 149 @resolver_options = DEFAULTS.merge(@options.resolver_options)
  32. 149 @queries = {}
  33. 149 @requests = {}
  34. 149 @_timeouts = Array(@resolver_options[:timeouts])
  35. 296 @timeouts = Hash.new { |timeouts, host| timeouts[host] = @_timeouts.dup }
  36. 149 @uri = URI(@resolver_options[:uri])
  37. 149 @name = @uri_addresses = nil
  38. 149 @resolver = Resolv::DNS.new
  39. 149 @resolver.timeouts = @_timeouts.empty? ? Resolver::RESOLVE_TIMEOUT : @_timeouts
  40. 149 @resolver.lazy_initialize
  41. end
  42. 30 def state
  43. 28 @resolver_connection ? @resolver_connection.state : :idle
  44. end
  45. 30 def <<(connection)
  46. 140 return if @uri.origin == connection.peer.to_s
  47. 140 @uri_addresses ||= @options.resolver_cache.resolve(@uri.host) || @resolver.getaddresses(@uri.host)
  48. 140 if @uri_addresses.empty?
  49. 7 ex = ResolveError.new("Can't resolve DNS server #{@uri.host}")
  50. 7 ex.set_backtrace(caller)
  51. 7 connection.force_close
  52. 7 throw(:resolve_error, ex)
  53. end
  54. 133 resolve(connection)
  55. end
  56. 30 def resolver_connection
  57. # TODO: leaks connection object into the pool
  58. 154 @resolver_connection ||=
  59. @current_session.find_connection(
  60. @uri,
  61. @current_selector,
  62. @options.merge(resolver_class: :system, ssl: { alpn_protocols: %w[h2] })
  63. ).tap do |conn|
  64. 126 emit_addresses(conn, @family, @uri_addresses) unless conn.addresses
  65. 126 conn.on(:force_closed, &method(:force_close))
  66. end
  67. end
  68. 30 private
  69. 30 def resolve(connection = nil, hostname = nil)
  70. 161 @connections.shift until @connections.empty? || @connections.first.state != :closed
  71. 161 connection ||= @connections.first
  72. 161 return unless connection
  73. 161 hostname ||= @queries.key(connection)
  74. 161 if hostname.nil?
  75. 133 hostname = connection.peer.host
  76. log do
  77. "resolver #{FAMILY_TYPES[@record_type]}: resolve IDN #{connection.peer.non_ascii_hostname} as #{hostname}"
  78. 133 end if connection.peer.non_ascii_hostname
  79. 133 hostname = @resolver.generate_candidates(hostname).each do |name|
  80. 399 @queries[name.to_s] = connection
  81. end.first.to_s
  82. else
  83. 28 @queries[hostname] = connection
  84. end
  85. 161 @name = hostname
  86. 161 log { "resolver #{FAMILY_TYPES[@record_type]}: query for #{hostname}" }
  87. 161 send_request(hostname, connection)
  88. end
  89. 30 def send_request(hostname, connection)
  90. 161 request = build_request(hostname)
  91. 154 request.on(:response, &method(:on_response).curry(2)[request])
  92. 154 request.on(:promise, &method(:on_promise))
  93. 154 @requests[request] = hostname
  94. 154 resolver_connection.send(request)
  95. 154 @connections << connection
  96. rescue ResolveError, Resolv::DNS::EncodeError => e
  97. 7 reset_hostname(hostname)
  98. 7 throw(:resolve_error, e) if connection.pending.empty?
  99. emit_resolve_error(connection, connection.peer.host, e)
  100. close_or_resolve
  101. end
  102. 30 def on_response(request, response)
  103. 112 response.raise_for_status
  104. rescue StandardError => e
  105. 21 hostname = @requests.delete(request)
  106. 21 connection = reset_hostname(hostname)
  107. 21 emit_resolve_error(connection, connection.peer.host, e)
  108. 21 close_or_resolve
  109. else
  110. # @type var response: HTTPX::Response
  111. 91 if response.status.between?(300, 399) && response.headers.key?("location")
  112. hostname = @requests[request]
  113. connection = @queries[hostname]
  114. location_uri = URI(response.headers["location"])
  115. location_uri = response.uri.merge(location_uri) if location_uri.relative?
  116. # we assume that the DNS server URI changed permanently and move on
  117. @uri = location_uri
  118. send_request(hostname, connection)
  119. return
  120. end
  121. 91 parse(request, response)
  122. ensure
  123. 112 @requests.delete(request)
  124. end
  125. 30 def on_promise(_, stream)
  126. log(level: 2) { "#{stream.id}: refusing stream!" }
  127. stream.refuse
  128. end
  129. 30 def parse(request, response)
  130. 91 hostname = @name
  131. 91 @name = nil
  132. 91 code, result = decode_response_body(response)
  133. 91 case code
  134. when :ok
  135. 35 parse_addresses(result, request)
  136. when :no_domain_found
  137. # Indicates no such domain was found.
  138. 42 host = @requests.delete(request)
  139. 42 connection = reset_hostname(host, reset_candidates: false)
  140. 42 unless @queries.value?(connection)
  141. 14 emit_resolve_error(connection)
  142. 14 close_or_resolve
  143. 14 return
  144. end
  145. 28 resolve
  146. when :retriable_error
  147. timeouts = @timeouts[hostname]
  148. unless timeouts.empty?
  149. log { "resolver #{FAMILY_TYPES[@record_type]}: failed, but will retry..." }
  150. connection = @queries[hostname]
  151. resolve(connection, hostname)
  152. return
  153. end
  154. host = @requests.delete(request)
  155. connection = reset_hostname(host)
  156. emit_resolve_error(connection)
  157. close_or_resolve
  158. when :dns_error
  159. 7 host = @requests.delete(request)
  160. 7 connection = reset_hostname(host)
  161. 7 emit_resolve_error(connection)
  162. 7 close_or_resolve
  163. when :decode_error
  164. 7 host = @requests.delete(request)
  165. 7 connection = reset_hostname(host)
  166. 7 emit_resolve_error(connection, connection.peer.host, result)
  167. 7 close_or_resolve
  168. end
  169. end
  170. 30 def parse_addresses(answers, request)
  171. 35 if answers.empty?
  172. # no address found, eliminate candidates
  173. 7 host = @requests.delete(request)
  174. 7 connection = reset_hostname(host)
  175. 7 emit_resolve_error(connection)
  176. 7 close_or_resolve
  177. 7 return
  178. else
  179. 63 answers = answers.group_by { |answer| answer["name"] }
  180. 28 answers.each do |hostname, addresses|
  181. 35 addresses = addresses.flat_map do |address|
  182. 35 if address.key?("alias")
  183. 7 alias_address = answers[address["alias"]]
  184. 7 if alias_address.nil?
  185. reset_hostname(address["name"])
  186. if early_resolve(connection, hostname: address["alias"])
  187. @connections.delete(connection)
  188. else
  189. resolve(connection, address["alias"])
  190. return # rubocop:disable Lint/NonLocalExitFromIterator
  191. end
  192. else
  193. 7 alias_address
  194. end
  195. else
  196. 28 address
  197. end
  198. end.compact
  199. 35 next if addresses.empty?
  200. 35 hostname.delete_suffix!(".") if hostname.end_with?(".")
  201. 35 connection = reset_hostname(hostname, reset_candidates: false)
  202. 35 next unless connection # probably a retried query for which there's an answer
  203. 28 @connections.delete(connection)
  204. # eliminate other candidates
  205. 84 @queries.delete_if { |_, conn| connection == conn }
  206. 28 @options.resolver_cache.set(hostname, @family, addresses) if @resolver_options[:cache]
  207. 84 catch(:coalesced) { emit_addresses(connection, @family, addresses.map { |a| Resolver::Entry.new(a["data"], a["TTL"]) }) }
  208. end
  209. end
  210. 28 close_or_resolve(true)
  211. end
  212. 30 def build_request(hostname)
  213. 147 uri = @uri.dup
  214. 147 rklass = @options.request_class
  215. 147 payload = Resolver.encode_dns_query(hostname, type: @record_type)
  216. 147 timeouts = @timeouts[hostname]
  217. 147 request_timeout = timeouts.shift
  218. 147 options = @options.merge(timeout: { request_timeout: request_timeout })
  219. 147 if @resolver_options[:use_get]
  220. 7 params = URI.decode_www_form(uri.query.to_s)
  221. 7 params << ["type", FAMILY_TYPES[@record_type]]
  222. 7 params << ["dns", Base64.urlsafe_encode64(payload, padding: false)]
  223. 7 uri.query = URI.encode_www_form(params)
  224. 7 request = rklass.new("GET", uri, options)
  225. else
  226. 140 request = rklass.new("POST", uri, options, body: [payload])
  227. 140 request.headers["content-type"] = "application/dns-message"
  228. end
  229. 147 request.headers["accept"] = "application/dns-message"
  230. 147 request
  231. end
  232. 30 def decode_response_body(response)
  233. 77 case response.headers["content-type"]
  234. when "application/dns-udpwireformat",
  235. "application/dns-message"
  236. 77 Resolver.decode_dns_answer(response.to_s)
  237. else
  238. raise Error, "unsupported DNS mime-type (#{response.headers["content-type"]})"
  239. end
  240. end
  241. 30 def reset_hostname(hostname, reset_candidates: true)
  242. 126 @timeouts.delete(hostname)
  243. 126 connection = @queries.delete(hostname)
  244. 126 return connection unless connection && reset_candidates
  245. # eliminate other candidates
  246. 147 candidates = @queries.select { |_, conn| connection == conn }.keys
  247. 147 @queries.delete_if { |h, _| candidates.include?(h) }
  248. # reset timeouts
  249. 49 @timeouts.delete_if { |h, _| candidates.include?(h) }
  250. 49 connection
  251. end
  252. 30 def close_or_resolve(should_deactivate = false)
  253. # drop already closed connections
  254. 84 @connections.shift until @connections.empty? || @connections.first.state != :closed
  255. 84 if (@connections - @queries.values).empty?
  256. # the same resolver connection may be serving different https resolvers (AAAA and A).
  257. 84 return if inflight?
  258. 70 if should_deactivate
  259. 25 deactivate
  260. else
  261. 45 disconnect
  262. end
  263. else
  264. resolve
  265. end
  266. end
  267. end
  268. 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. 30 require "forwardable"
  3. 30 require "resolv"
  4. 30 module HTTPX
  5. 30 class Resolver::Multi
  6. 30 attr_reader :resolvers, :options
  7. 30 def initialize(resolver_type, options)
  8. 9147 @current_selector = @current_session = nil
  9. 9147 @options = options
  10. 9147 @resolver_options = @options.resolver_options
  11. 9147 ip_families = options.ip_families || Resolver.supported_ip_families
  12. 9147 @resolvers = ip_families.map do |ip_family|
  13. 9189 resolver = resolver_type.new(ip_family, options)
  14. 9189 resolver.multi = self
  15. 9189 resolver
  16. end
  17. end
  18. 30 def state
  19. 86 @resolvers.map(&:state).uniq.join(",")
  20. end
  21. 30 def current_selector=(s)
  22. 9406 @current_selector = s
  23. 18854 @resolvers.each { |r| r.current_selector = s }
  24. end
  25. 30 def current_session=(s)
  26. 9406 @current_session = s
  27. 18854 @resolvers.each { |r| r.current_session = s }
  28. end
  29. 30 def log(*args, **kwargs, &blk)
  30. 37198 @resolvers.each { |r| r.log(*args, **kwargs, &blk) }
  31. end
  32. 30 def closed?
  33. 9272 @resolvers.all?(&:closed?)
  34. end
  35. 30 def early_resolve(connection)
  36. 9410 hostname = connection.peer.host
  37. 9410 addresses = @resolver_options[:cache] && (connection.addresses || nolookup_resolve(hostname, connection.options))
  38. 9410 return false unless addresses
  39. 8791 ip_families = connection.options.ip_families
  40. 8791 resolved = false
  41. 9315 addresses.group_by(&:family).sort { |(f1, _), (f2, _)| f2 <=> f1 }.each do |family, addrs|
  42. 9296 next unless ip_families.nil? || ip_families.include?(family)
  43. # try to match the resolver by family. However, there are cases where that's not possible, as when
  44. # the system does not have IPv6 connectivity, but it does support IPv6 via loopback/link-local.
  45. 18592 resolver = @resolvers.find { |r| r.family == family } || @resolvers.first
  46. 9296 next unless resolver # this should ever happen
  47. # it does not matter which resolver it is, as early-resolve code is shared.
  48. 9296 resolver.emit_addresses(connection, family, addrs, true)
  49. 9243 resolved = true
  50. end
  51. 8738 resolved
  52. end
  53. 30 def lazy_resolve(connection)
  54. 620 @resolvers.each do |resolver|
  55. 662 resolver.lazy_resolve(connection)
  56. end
  57. end
  58. 30 private
  59. 30 def nolookup_resolve(hostname, options)
  60. 9092 options.resolver_cache.resolve(hostname)
  61. end
  62. end
  63. end

lib/httpx/resolver/native.rb

90.24% lines covered

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

lib/httpx/resolver/resolver.rb

91.18% lines covered

102 relevant lines. 93 lines covered and 9 lines missed.
    
  1. # frozen_string_literal: true
  2. 30 require "resolv"
  3. 30 module HTTPX
  4. # Base class for all internal internet name resolvers. It handles basic blocks
  5. # from the Selectable API.
  6. #
  7. 30 class Resolver::Resolver
  8. 30 include Loggable
  9. 30 using ArrayExtensions::Intersect
  10. 2 RECORD_TYPES = {
  11. 28 Socket::AF_INET6 => Resolv::DNS::Resource::IN::AAAA,
  12. Socket::AF_INET => Resolv::DNS::Resource::IN::A,
  13. }.freeze
  14. 2 FAMILY_TYPES = {
  15. 28 Resolv::DNS::Resource::IN::AAAA => "AAAA",
  16. Resolv::DNS::Resource::IN::A => "A",
  17. }.freeze
  18. 30 class << self
  19. 30 def multi?
  20. 9147 true
  21. end
  22. end
  23. 30 attr_reader :family, :options
  24. 30 attr_writer :current_selector, :current_session
  25. 30 attr_accessor :multi
  26. 30 def initialize(family, options)
  27. 9367 @family = family
  28. 9367 @record_type = RECORD_TYPES[family]
  29. 9367 @options = options
  30. 9367 @connections = []
  31. end
  32. 30 def each_connection(&block)
  33. 484 enum_for(__method__) unless block
  34. 484 return unless @connections
  35. 484 @connections.each(&block)
  36. end
  37. 30 def close; end
  38. 30 alias_method :terminate, :close
  39. 30 def force_close(*args)
  40. 504 while (connection = @connections.shift)
  41. 140 connection.force_close(*args)
  42. end
  43. end
  44. 30 def closed?
  45. true
  46. end
  47. 30 def empty?
  48. 126 true
  49. end
  50. 30 def inflight?
  51. 78 false
  52. end
  53. 30 def emit_addresses(connection, family, addresses, early_resolve = false)
  54. 32702 addresses.map! { |address| address.is_a?(Resolver::Entry) ? address : Resolver::Entry.new(address) }
  55. # double emission check, but allow early resolution to work
  56. 9799 conn_addrs = connection.addresses
  57. 9799 return if !early_resolve && conn_addrs && !conn_addrs.empty? && !addresses.intersect?(conn_addrs)
  58. 9799 log do
  59. 100 "resolver #{FAMILY_TYPES[RECORD_TYPES[family]]}: " \
  60. 8 "answer #{connection.peer.host}: #{addresses.inspect} (early resolve: #{early_resolve})"
  61. end
  62. # do not apply resolution delay for non-dns name resolution
  63. 9799 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. 401 (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. 14 log { "resolver #{FAMILY_TYPES[RECORD_TYPES[family]]}: applying resolution delay..." }
  75. 14 @current_selector.after(0.05) do
  76. # double emission check
  77. 11 unless connection.addresses && addresses.intersect?(connection.addresses)
  78. 11 emit_resolved_connection(connection, addresses, early_resolve)
  79. end
  80. end
  81. else
  82. 9785 emit_resolved_connection(connection, addresses, early_resolve)
  83. end
  84. end
  85. 30 def handle_error(error)
  86. 42 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. 63 while (connection = @connections.shift)
  92. 21 emit_resolve_error(connection, connection.peer.host, error)
  93. end
  94. end
  95. end
  96. 30 def on_io_error(e)
  97. on_error(e)
  98. force_close(true)
  99. end
  100. 30 def on_error(error)
  101. 14 handle_error(error)
  102. 14 disconnect
  103. end
  104. 30 def early_resolve(connection, hostname: connection.peer.host) # rubocop:disable Naming/PredicateMethod
  105. addresses = @resolver_options[:cache] && (connection.addresses || @options.resolver_cache.resolve(hostname))
  106. return false unless addresses
  107. addresses = addresses.select { |addr| addr.family == @family }
  108. return false if addresses.empty?
  109. emit_addresses(connection, @family, addresses, true)
  110. true
  111. end
  112. 30 def lazy_resolve(connection)
  113. 662 return unless @current_session && @current_selector
  114. 662 conn_to_resolve = @current_session.try_clone_connection(connection, @current_selector, @family)
  115. 662 self << conn_to_resolve
  116. 641 return if empty?
  117. # both the resolver and the connection it's resolving must be pinned to the session
  118. 515 @current_session.pin(conn_to_resolve, @current_selector)
  119. 515 @current_session.select_resolver(self, @current_selector)
  120. end
  121. 30 private
  122. 30 def emit_resolved_connection(connection, addresses, early_resolve)
  123. begin
  124. 9796 connection.addresses = addresses
  125. 9726 return if connection.state == :closed
  126. 9724 resolve_connection(connection)
  127. 32 rescue StandardError => e
  128. 70 if early_resolve
  129. 53 connection.force_close
  130. 53 throw(:resolve_error, e)
  131. else
  132. 17 emit_connection_error(connection, e)
  133. end
  134. end
  135. end
  136. 30 def emit_resolve_error(connection, hostname = connection.peer.host, ex = nil)
  137. 268 emit_connection_error(connection, resolve_error(hostname, ex))
  138. end
  139. 30 def resolve_error(hostname, ex = nil)
  140. 268 return ex if ex.is_a?(ResolveError) || ex.is_a?(ResolveTimeoutError)
  141. 84 message = ex ? ex.message : "Can't resolve #{hostname}"
  142. 84 error = ResolveError.new(message)
  143. 84 error.set_backtrace(ex ? ex.backtrace : caller)
  144. 84 error
  145. end
  146. 30 def resolve_connection(connection)
  147. 9724 @current_session.__send__(:on_resolver_connection, connection, @current_selector)
  148. end
  149. 30 def emit_connection_error(connection, error)
  150. 285 return connection.handle_connect_error(error) if connection.connecting?
  151. 7 connection.on_error(error)
  152. end
  153. 30 def disconnect
  154. 669 close
  155. 669 return unless closed?
  156. 669 @current_session.deselect_resolver(self, @current_selector)
  157. end
  158. end
  159. end

lib/httpx/resolver/system.rb

95.45% lines covered

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

lib/httpx/response.rb

99.24% lines covered

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

lib/httpx/response/body.rb

100.0% lines covered

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

lib/httpx/response/buffer.rb

96.72% lines covered

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

lib/httpx/selector.rb

93.92% lines covered

148 relevant lines. 139 lines covered and 9 lines missed.
    
  1. # frozen_string_literal: true
  2. 30 require "io/wait"
  3. 30 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. 30 class Selector
  20. 30 extend Forwardable
  21. 30 READABLE = %i[rw r].freeze
  22. 30 WRITABLE = %i[rw w].freeze
  23. 30 private_constant :READABLE
  24. 30 private_constant :WRITABLE
  25. 30 def_delegator :@timers, :after
  26. 30 def_delegator :@selectables, :each
  27. 30 def initialize
  28. 10343 @timers = Timers.new
  29. 10343 @selectables = []
  30. 10343 @is_timer_interval = false
  31. end
  32. 30 def empty?
  33. 37323 @selectables.empty? && @timers.empty?
  34. end
  35. 30 def next_tick
  36. 37376 catch(:jump_tick) do
  37. 37376 timeout = next_timeout
  38. 37376 if timeout && timeout.negative?
  39. @timers.fire
  40. throw(:jump_tick)
  41. end
  42. 2971 begin
  43. 37376 select(timeout) do |c|
  44. 36021 c.log(level: 2) { "[#{c.state}] selected from selector##{object_id} #{" after #{timeout} secs" unless timeout.nil?}..." }
  45. 35835 c.call
  46. end
  47. 37112 @timers.fire
  48. rescue TimeoutError => e
  49. @timers.fire(e)
  50. end
  51. end
  52. end
  53. 30 def terminate
  54. # array may change during iteration
  55. 10048 selectables = @selectables.reject(&:inflight?)
  56. 10048 selectables.delete_if do |sel|
  57. 3935 sel.terminate
  58. 3926 sel.state == :closed
  59. end
  60. 10046 until selectables.empty?
  61. 53 next_tick
  62. 46 selectables &= @selectables
  63. end
  64. end
  65. 30 def find_resolver(options)
  66. 9579 res = @selectables.find do |c|
  67. 86 c.is_a?(Resolver::Resolver) &&
  68. options.resolver_options_match?(c.options)
  69. end
  70. 9579 res.multi if res
  71. end
  72. 30 def each_connection(&block)
  73. 47162 return enum_for(__method__) unless block
  74. 23581 @selectables.each do |c|
  75. 3410 case c
  76. when Resolver::Resolver
  77. 484 c.each_connection(&block)
  78. when Connection
  79. 3057 yield c
  80. end
  81. end
  82. end
  83. 30 def find_connection(request_uri, options)
  84. 12866 each_connection.find do |connection|
  85. 1827 connection.match?(request_uri, options)
  86. end
  87. end
  88. 30 def find_mergeable_connection(connection)
  89. 10003 each_connection.find do |ch|
  90. 820 ch != connection && ch.mergeable?(connection)
  91. end
  92. end
  93. # deregisters +io+ from selectables.
  94. 30 def deregister(io)
  95. 12146 @selectables.delete(io)
  96. end
  97. # register +io+.
  98. 30 def register(io)
  99. 12702 return if @selectables.include?(io)
  100. 11763 @selectables << io
  101. end
  102. 30 private
  103. 30 def select(interval, &block)
  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. 37376 if @selectables.empty?
  110. begin
  111. 223 sleep(interval)
  112. rescue IOError
  113. # @fiber-switch-guard
  114. # in a fiber scheduler scenario, IOs may be closed by the scheduler and raised in a separate fiber
  115. # on wakeup, which includes a sleep call.
  116. 223 end if interval
  117. 199 return
  118. end
  119. # @type var r: (selectable | Array[selectable])?
  120. # @type var w: (selectable | Array[selectable])?
  121. 37152 r, w = nil
  122. 37152 @selectables.delete_if do |io|
  123. 38625 interests = io.interests
  124. 38623 is_closed = io.state == :closed
  125. 38623 if is_closed
  126. # the process by which io was closed may have already triggered the on_close callback,
  127. # which already deregistered the io. this check prevents it from deleting the wrong io,
  128. # because of https://bugs.ruby-lang.org/issues/22021 .
  129. 121 next(@selectables.include?(io))
  130. end
  131. 38502 if interests
  132. 38192 io.log(level: 2) do
  133. 186 "[#{io.state}] registering in selector##{object_id} for select (#{interests})#{" for #{interval} seconds" unless interval.nil?}"
  134. end
  135. 38192 if READABLE.include?(interests)
  136. 25198 r = r.nil? ? io : (Array(r) << io)
  137. end
  138. 38192 if WRITABLE.include?(interests)
  139. 13525 w = w.nil? ? io : (Array(w) << io)
  140. end
  141. end
  142. 38502 is_closed
  143. end
  144. 34205 case r
  145. when Array
  146. 870 w = Array(w) unless w.nil?
  147. 870 select_many(r, w, interval, &block)
  148. when nil
  149. 11645 case w
  150. when Array
  151. 59 select_many(r, w, interval, &block)
  152. when nil
  153. 217 return unless interval && @selectables.any?
  154. # no selectables
  155. # TODO: replace with sleep?
  156. 113 select_many(r, w, interval, &block)
  157. else
  158. 12558 select_one(w, :w, interval, &block)
  159. end
  160. else
  161. 21764 case w
  162. when Array
  163. 6 select_many(Array(r), w, interval, &block)
  164. when nil
  165. 22693 select_one(r, :r, interval, &block)
  166. else
  167. 747 if r == w
  168. 455 select_one(r, :rw, interval, &block)
  169. else
  170. 292 select_many(Array(r), Array(w), interval, &block)
  171. end
  172. end
  173. end
  174. end
  175. 30 def select_many(r, w, interval, &block)
  176. 78 begin
  177. 1340 readers, writers = ::IO.select(r, w, nil, interval)
  178. rescue IOError => e
  179. (Array(r) + Array(w)).each do |sel|
  180. # TODO: is there a way to cheaply find the IO associated with the error?
  181. sel.on_io_error(e)
  182. end
  183. rescue StandardError => e
  184. (Array(r) + Array(w)).each do |sel|
  185. sel.on_error(e)
  186. end
  187. return
  188. rescue Exception => e # rubocop:disable Lint/RescueException
  189. 42 (Array(r) + Array(w)).each do |sel|
  190. 84 sel.force_close(true)
  191. end
  192. 42 raise e
  193. end
  194. 1298 if readers.nil? && writers.nil? && interval
  195. 146 [*r, *w].each { |io| io.handle_socket_timeout(interval) }
  196. 99 return
  197. end
  198. 1174 if writers
  199. 98 readers.each do |io|
  200. 870 yield io
  201. # so that we don't yield 2 times
  202. 861 writers.delete(io)
  203. 1173 end if readers
  204. 1165 writers.each(&block)
  205. else
  206. readers.each(&block) if readers
  207. end
  208. end
  209. 30 def select_one(io, interests, interval)
  210. 2861 begin
  211. 5684 result =
  212. 30022 case interests
  213. 22693 when :r then io.to_io.wait_readable(interval)
  214. 12558 when :w then io.to_io.wait_writable(interval)
  215. 455 when :rw then rw_wait(io, interval)
  216. end
  217. rescue IOError => e
  218. 54 io.on_io_error(e)
  219. 54 return
  220. rescue StandardError => e
  221. 14 io.on_error(e)
  222. 14 return
  223. rescue Exception => e # rubocop:disable Lint/RescueException
  224. 42 io.force_close(true)
  225. 42 raise e
  226. end
  227. 35596 unless result || interval.nil?
  228. 1059 io.handle_socket_timeout(interval) unless @is_timer_interval
  229. 945 return
  230. end
  231. 34537 yield io
  232. end
  233. 30 def next_timeout
  234. 37376 @is_timer_interval = false
  235. 37376 timer_interval = @timers.wait_interval
  236. 37376 connection_interval = @selectables.filter_map(&:timeout).min
  237. 37376 return connection_interval unless timer_interval
  238. 15440 if connection_interval.nil? || timer_interval <= connection_interval
  239. 15360 @is_timer_interval = true
  240. 14236 return timer_interval
  241. end
  242. 80 connection_interval
  243. end
  244. 30 if RUBY_ENGINE == "jruby"
  245. 1 def rw_wait(io, interval)
  246. 64 io.to_io.wait(interval, :read_write)
  247. end
  248. 29 elsif IO.const_defined?(:READABLE)
  249. 27 def rw_wait(io, interval)
  250. 362 io.to_io.wait(IO::READABLE | IO::WRITABLE, interval)
  251. end
  252. else
  253. 2 def rw_wait(io, interval)
  254. 29 if interval
  255. 27 io.to_io.wait(interval, :read_write)
  256. else
  257. 2 io.to_io.wait(:read_write)
  258. end
  259. end
  260. end
  261. end
  262. end

lib/httpx/session.rb

99.32% lines covered

296 relevant lines. 294 lines covered and 2 lines missed.
    
  1. # frozen_string_literal: true
  2. 30 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. 30 class Session
  8. 30 include Loggable
  9. 30 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. 30 def initialize(options = EMPTY_HASH, &blk)
  15. 15490 @options = self.class.default_options.merge(options)
  16. 15490 @persistent = @options.persistent
  17. 15490 @pool = @options.pool_class.new(@options.pool_options)
  18. 15490 @wrapped = false
  19. 15490 @closing = false
  20. 15490 INSTANCES[self] = self if @persistent && @options.close_on_fork && INSTANCES
  21. 15490 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. 30 def wrap
  29. 928 prev_wrapped = @wrapped
  30. 928 @wrapped = true
  31. 928 was_initialized = false
  32. 928 current_selector = get_current_selector do
  33. 928 selector = Selector.new
  34. 928 set_current_selector(selector)
  35. 928 was_initialized = true
  36. 928 selector
  37. end
  38. 70 begin
  39. 928 yield self
  40. ensure
  41. 928 unless prev_wrapped
  42. 928 if @persistent
  43. 11 deactivate(current_selector)
  44. else
  45. 917 close(current_selector)
  46. end
  47. end
  48. 928 @wrapped = prev_wrapped
  49. 928 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. 30 def close(selector = Selector.new)
  58. # throw resolvers away from the pool
  59. 9696 @pool.reset_resolvers
  60. # preparing to throw away connections
  61. 23198 while (connection = @pool.pop_connection)
  62. 6347 next if connection.state == :closed
  63. 266 select_connection(connection, selector)
  64. end
  65. 9696 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. 30 def request(*args, **params)
  88. 10155 raise ArgumentError, "must perform at least one request" if args.empty?
  89. 10155 requests = args.first.is_a?(Request) ? args : build_requests(*args, params)
  90. 10112 responses = send_requests(*requests)
  91. 9825 return responses.first if responses.size == 1
  92. 351 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. 30 def build_request(verb, uri, params = EMPTY_HASH, options = @options)
  101. 12268 rklass = options.request_class
  102. 12268 request = rklass.new(verb, uri, options, params)
  103. 12216 request.persistent = @persistent
  104. 12216 set_request_callbacks(request)
  105. 12216 request
  106. end
  107. 30 def select_connection(connection, selector)
  108. 12558 pin(connection, selector)
  109. 12558 connection.log(level: 2) do
  110. 100 "registering into selector##{selector.object_id}"
  111. end
  112. 12558 selector.register(connection)
  113. end
  114. 30 def pin(conn_or_resolver, selector)
  115. 32286 conn_or_resolver.current_session = self
  116. 32286 conn_or_resolver.current_selector = selector
  117. end
  118. 30 alias_method :select_resolver, :select_connection
  119. 30 def deselect_connection(connection, selector, cloned = false)
  120. 11396 connection.log(level: 2) do
  121. 86 "deregistering connection##{connection.object_id}(#{connection.state}) from selector##{selector.object_id}"
  122. end
  123. 11396 selector.deregister(connection)
  124. # do not check-in connections only created for Happy Eyeballs
  125. 11396 return if cloned
  126. 11373 return if @closing && connection.state == :closed && !connection.used?
  127. 11208 connection.log(level: 2) { "check-in connection##{connection.object_id}(#{connection.state}) in pool##{@pool.object_id}" }
  128. 11122 @pool.checkin_connection(connection)
  129. end
  130. 30 def deselect_resolver(resolver, selector)
  131. 669 resolver.log(level: 2) do
  132. "deregistering resolver##{resolver.object_id}(#{resolver.state}) from selector##{selector.object_id}"
  133. end
  134. 669 selector.deregister(resolver)
  135. 669 return if @closing && resolver.closed?
  136. 647 resolver.log(level: 2) { "check-in resolver##{resolver.object_id}(#{resolver.state}) in pool##{@pool.object_id}" }
  137. 647 @pool.checkin_resolver(resolver)
  138. end
  139. 30 def try_clone_connection(connection, selector, family)
  140. 662 connection.family ||= family
  141. 662 return connection if connection.family == family
  142. 42 new_connection = connection.class.new(connection.origin, connection.options)
  143. 42 new_connection.family = family
  144. 42 connection.sibling = new_connection
  145. 42 do_init_connection(new_connection, selector)
  146. 42 new_connection
  147. end
  148. # returns the HTTPX::Connection through which the +request+ should be sent through.
  149. 30 def find_connection(request_uri, selector, options)
  150. 12866 if (connection = selector.find_connection(request_uri, options))
  151. 1659 connection.idling if connection.state == :closed
  152. 1659 log(level: 2) { "found connection##{connection.object_id}(#{connection.state}) in selector##{selector.object_id}" }
  153. 1555 return connection
  154. end
  155. 11207 connection = @pool.checkout_connection(request_uri, options)
  156. 11171 log(level: 2) { "found connection##{connection.object_id}(#{connection.state}) in pool##{@pool.object_id}" }
  157. 10084 case connection.state
  158. when :idle
  159. 9822 do_init_connection(connection, selector)
  160. when :open
  161. # external io
  162. 73 select_connection(connection, selector)
  163. when :closing, :closed
  164. 1217 connection.idling
  165. 1217 if connection.addresses?
  166. 1209 select_connection(connection, selector)
  167. else
  168. # if addresses expired, resolve again
  169. 8 resolve_connection(connection, selector)
  170. end
  171. else
  172. 59 pin(connection, selector)
  173. end
  174. 11097 connection
  175. end
  176. 30 private
  177. 30 def selector_close(selector)
  178. begin
  179. 10048 @closing = true
  180. 10048 selector.terminate
  181. ensure
  182. 10048 @closing = false
  183. end
  184. end
  185. # tries deactivating connections in the +selector+, deregistering the ones that have been deactivated.
  186. 30 def deactivate(selector)
  187. 712 selector.each_connection.to_a.each(&:deactivate)
  188. end
  189. # callback executed when an HTTP/2 promise frame has been received.
  190. 30 def on_promise(_, stream)
  191. 9 log(level: 2) { "#{stream.id}: refusing stream!" }
  192. 9 stream.refuse
  193. end
  194. # returns the corresponding HTTP::Response to the given +request+ if it has been received.
  195. 30 def fetch_response(request, _selector, _options)
  196. 24556 response = request.response
  197. 24556 return unless response && response.finished?
  198. 12508 log(level: 2) { "response##{response.object_id} fetched" }
  199. 12508 response
  200. end
  201. # sends the +request+ to the corresponding HTTPX::Connection
  202. 30 def send_request(request, selector, options = request.options)
  203. 2358 error = begin
  204. 12685 catch(:resolve_error) do
  205. 12685 log(level: 2) { "finding connection for request##{request.object_id}..." }
  206. 12685 connection = find_connection(request.uri, selector, options)
  207. 12548 connection.send(request)
  208. end
  209. rescue StandardError => e
  210. 45 e
  211. end
  212. 12678 return unless error && error.is_a?(Exception)
  213. 137 raise error unless error.is_a?(Error)
  214. 130 response = ErrorResponse.new(request, error)
  215. 130 request.response = response
  216. 130 request.emit_response(response)
  217. end
  218. # returns a set of HTTPX::Request objects built from the given +args+ and +options+.
  219. 30 def build_requests(*args, params)
  220. 9300 requests = if args.size == 1
  221. 88 reqs = args.first
  222. 88 reqs.map do |verb, uri, ps = EMPTY_HASH|
  223. 176 request_params = params
  224. 176 request_params = request_params.merge(ps) unless ps.empty?
  225. 176 build_request(verb, uri, request_params)
  226. end
  227. else
  228. 9212 verb, uris = args
  229. 9212 if uris.respond_to?(:each)
  230. 8942 uris.enum_for(:each).map do |uri, ps = EMPTY_HASH|
  231. 9908 request_params = params
  232. 9908 request_params = request_params.merge(ps) unless ps.empty?
  233. 9908 build_request(verb, uri, request_params)
  234. end
  235. else
  236. 270 [build_request(verb, uris, params)]
  237. end
  238. end
  239. 9257 raise ArgumentError, "wrong number of URIs (given 0, expect 1..+1)" if requests.empty?
  240. 9257 requests
  241. end
  242. 30 def set_request_callbacks(request)
  243. 11982 request.on(:promise, &method(:on_promise))
  244. end
  245. 30 def do_init_connection(connection, selector)
  246. 9864 resolve_connection(connection, selector) unless connection.family
  247. end
  248. # sends an array of HTTPX::Request +requests+, returns the respective array of HTTPX::Response objects.
  249. 30 def send_requests(*requests)
  250. 19284 selector = get_current_selector { Selector.new }
  251. 988 begin
  252. 10258 receive_requests(requests, selector)
  253. ensure
  254. 10213 unless @wrapped
  255. 9091 if @persistent
  256. 701 deactivate(selector)
  257. else
  258. 8390 close(selector)
  259. end
  260. end
  261. end
  262. end
  263. # returns the array of HTTPX::Response objects corresponding to the array of HTTPX::Request +requests+.
  264. 30 def receive_requests(requests, selector)
  265. 10258 pending_idxs = [] #: Array[Integer]
  266. 10258 pending = 0
  267. 10258 waiting = false
  268. 10258 responses = requests.each_with_index.map do |request, idx|
  269. 11295 send_request(request, selector)
  270. 11281 fetch_response(request, selector, request.options).tap do |response|
  271. 11281 if response.nil?
  272. 9832 pending += 1
  273. 10856 request.on_response_arrived = lambda do
  274. 12094 pending_idxs << idx if waiting
  275. end
  276. end
  277. end
  278. end
  279. 13188 until pending.zero? || selector.empty?
  280. # loop on selector until at least one response has been received.
  281. 37323 waiting = true
  282. 74646 catch(:coalesced) { selector.next_tick }
  283. 37059 waiting = false
  284. 79152 while (idx = pending_idxs.shift)
  285. 12065 request = requests[idx]
  286. 12065 response = fetch_response(request, selector, request.options)
  287. # stop on first pending response. this avoids traversing pending idxs all the way
  288. # (which is more expensive in the beginning, when the array is larger and N) while
  289. # making the next loop cheaper (because we're dropping).
  290. 12065 next unless response
  291. 10592 request.complete!(response)
  292. 9588 responses[idx] = response
  293. 10592 request.on_response_arrived = nil
  294. 9588 pending -= 1
  295. end
  296. end
  297. 9980 raise Error, "something went wrong, responses not found and requests not resent" unless pending.zero?
  298. 9980 responses
  299. end
  300. 30 def resolve_connection(connection, selector)
  301. 9858 if connection.addresses? || connection.open?
  302. #
  303. # there are two cases in which we want to activate initialization of
  304. # connection immediately:
  305. #
  306. # 1. when the connection already has addresses, i.e. it doesn't need to
  307. # resolve a name (not the same as name being an IP, yet)
  308. # 2. when the connection is initialized with an external already open IO.
  309. #
  310. 279 on_resolver_connection(connection, selector)
  311. 277 return
  312. end
  313. 9579 resolver = find_resolver_for(connection, selector)
  314. 9579 pin(connection, selector)
  315. 9579 if early_resolve(resolver, connection)
  316. 8737 @pool.checkin_resolver(resolver)
  317. else
  318. 789 resolver.lazy_resolve(connection)
  319. end
  320. end
  321. 30 def early_resolve(resolver, connection)
  322. 9579 resolver.early_resolve(connection)
  323. end
  324. 30 def on_resolver_connection(connection, selector)
  325. 10003 from_pool = false
  326. 10003 found_connection = selector.find_mergeable_connection(connection) || begin
  327. 9967 from_pool = true
  328. 9967 connection.log(level: 2) do
  329. 100 "try finding a mergeable connection in pool##{@pool.object_id}"
  330. end
  331. 9967 @pool.checkout_mergeable_connection(connection)
  332. end
  333. 10003 return select_connection(connection, selector) unless found_connection
  334. 57 connection.log(level: 2) do
  335. "try coalescing from #{from_pool ? "pool##{@pool.object_id}" : "selector##{selector.object_id}"} " \
  336. "(connection##{found_connection.object_id}[#{found_connection.origin}])"
  337. end
  338. 57 coalesce_connections(found_connection, connection, selector, from_pool)
  339. end
  340. 30 def find_resolver_for(connection, selector)
  341. 9579 if (resolver = selector.find_resolver(connection.options))
  342. 4 resolver.log(level: 2) { "found resolver##{resolver.object_id}(#{resolver.state}) in selector##{selector.object_id}" }
  343. 4 return resolver
  344. end
  345. 9575 resolver = @pool.checkout_resolver(connection.options)
  346. 9661 resolver.log(level: 2) { "found resolver##{resolver.object_id}(#{resolver.state}) in pool##{@pool.object_id}" }
  347. 9575 pin(resolver, selector)
  348. 9575 resolver
  349. end
  350. # coalesces +conn2+ into +conn1+. if +conn1+ was loaded from the connection pool
  351. # (it is known via +from_pool+), then it adds its to the +selector+.
  352. 30 def coalesce_connections(conn1, conn2, selector, from_pool)
  353. 57 unless conn1.coalescable?(conn2)
  354. 28 conn2.log(level: 2) { "not coalescing with conn##{conn1.object_id}[#{conn1.origin}])" }
  355. 28 select_connection(conn2, selector)
  356. 28 if from_pool
  357. 7 conn1.log(level: 2) { "check-in connection##{conn1.object_id}(#{conn1.state}) in pool##{@pool.object_id}" }
  358. 7 @pool.checkin_connection(conn1)
  359. end
  360. 28 return
  361. end
  362. 29 conn2.log(level: 2) { "coalescing with connection##{conn1.object_id}[#{conn1.origin}])" }
  363. 29 select_connection(conn1, selector) if from_pool
  364. 29 conn2.coalesce!(conn1)
  365. 29 conn2.disconnect
  366. end
  367. 30 def get_current_selector
  368. 11214 selector_store[self] || (yield if block_given?)
  369. end
  370. 30 def set_current_selector(selector)
  371. 2481 if selector
  372. 1434 selector_store[self] = selector
  373. else
  374. 928 selector_store.delete(self)
  375. end
  376. end
  377. 30 def selector_store
  378. 13695 th_current = Thread.current
  379. 13695 thread_selector_store(th_current) || begin
  380. 248 {}.compare_by_identity.tap do |store|
  381. 248 th_current.thread_variable_set(:httpx_persistent_selector_store, store)
  382. end
  383. end
  384. end
  385. 30 def thread_selector_store(th)
  386. 18890 th.thread_variable_get(:httpx_persistent_selector_store)
  387. end
  388. 30 Options.freeze
  389. 30 @default_options = Options.new
  390. 30 @default_options.freeze
  391. 30 @plugins = []
  392. 30 class << self
  393. 30 attr_reader :default_options
  394. 30 def inherited(klass)
  395. 8140 super
  396. 8140 klass.instance_variable_set(:@default_options, @default_options)
  397. 8140 klass.instance_variable_set(:@plugins, @plugins.dup)
  398. 8140 klass.instance_variable_set(:@callbacks, @callbacks.dup)
  399. end
  400. # returns a new HTTPX::Session instance, with the plugin pointed by +pl+ loaded.
  401. #
  402. # session_with_retries = session.plugin(:retries)
  403. # session_with_custom = session.plugin(CustomPlugin)
  404. #
  405. 30 def plugin(pl, options = nil, &block)
  406. 12407 label = pl
  407. 12407 pl = Plugins.load_plugin(pl) if pl.is_a?(Symbol)
  408. 12407 raise ArgumentError, "Invalid plugin type: #{pl.class.inspect}" unless pl.is_a?(Module)
  409. 12399 if !@plugins.include?(pl)
  410. 12088 @plugins << pl
  411. 12088 pl.load_dependencies(self, &block) if pl.respond_to?(:load_dependencies)
  412. 12088 @default_options = @default_options.dup
  413. 12088 include(pl::InstanceMethods) if defined?(pl::InstanceMethods)
  414. 12088 extend(pl::ClassMethods) if defined?(pl::ClassMethods)
  415. 12088 opts = @default_options
  416. 12088 opts.extend_with_plugin_classes(pl)
  417. 12088 if defined?(pl::OptionsMethods)
  418. # when a class gets dup'ed, the #initialize_dup callbacks isn't triggered.
  419. # moreover, and because #method_added does not get triggered on mixin include,
  420. # the callback is also forcefully manually called here.
  421. 5806 opts.options_class.instance_variable_set(:@options_names, opts.options_class.options_names.dup)
  422. 5806 (pl::OptionsMethods.instance_methods + pl::OptionsMethods.private_instance_methods - Object.instance_methods).each do |meth|
  423. 19827 opts.options_class.method_added(meth)
  424. end
  425. 5806 @default_options = opts.options_class.new(opts)
  426. end
  427. 12088 @default_options = pl.extra_options(@default_options) if pl.respond_to?(:extra_options)
  428. 12088 @default_options = @default_options.merge(options) if options
  429. 12088 if pl.respond_to?(:subplugins)
  430. 2248 pl.subplugins.transform_keys(&Plugins.method(:load_plugin)).each do |main_pl, sub_pl|
  431. # in case the main plugin has already been loaded, then apply subplugin functionality
  432. # immediately
  433. 2883 next unless @plugins.include?(main_pl)
  434. 116 plugin(sub_pl, options, &block)
  435. end
  436. end
  437. 12088 pl.configure(self, &block) if pl.respond_to?(:configure)
  438. 12088 if label.is_a?(Symbol)
  439. # in case an already-loaded plugin complements functionality of
  440. # the plugin currently being loaded, loaded it now
  441. 9101 @plugins.each do |registered_pl|
  442. 24575 next if registered_pl == pl
  443. 15474 next unless registered_pl.respond_to?(:subplugins)
  444. 4096 sub_pl = registered_pl.subplugins[label]
  445. 4096 next unless sub_pl
  446. 203 plugin(sub_pl, options, &block)
  447. end
  448. end
  449. 12088 @default_options.freeze
  450. 12088 set_temporary_name("#{superclass}/#{pl}") if respond_to?(:set_temporary_name) # ruby 3.4 only
  451. 310 elsif options
  452. # this can happen when two plugins are loaded, an one of them calls the other under the hood,
  453. # albeit changing some default.
  454. 26 @default_options = pl.extra_options(@default_options) if pl.respond_to?(:extra_options)
  455. 26 @default_options = @default_options.merge(options) if options
  456. 18 @default_options.freeze
  457. end
  458. 12391 self
  459. end
  460. end
  461. # setup of the support for close_on_fork sessions.
  462. # adapted from https://github.com/mperham/connection_pool/blob/main/lib/connection_pool.rb#L48
  463. 30 if Process.respond_to?(:fork)
  464. 28 INSTANCES = ObjectSpace::WeakMap.new
  465. 28 private_constant :INSTANCES
  466. 28 def self.after_fork
  467. 1 INSTANCES.each_value(&:close)
  468. 1 nil
  469. end
  470. 28 if ::Process.respond_to?(:_fork)
  471. 24 module ForkTracker
  472. 24 def _fork
  473. 1 pid = super
  474. 1 Session.after_fork if pid.zero?
  475. 1 pid
  476. end
  477. end
  478. 24 Process.singleton_class.prepend(ForkTracker)
  479. end
  480. else
  481. 2 INSTANCES = nil
  482. 2 private_constant :INSTANCES
  483. 2 def self.after_fork
  484. # noop
  485. end
  486. end
  487. end
  488. # session may be overridden by certain adapters.
  489. 30 S = Session
  490. 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. 30 module HTTPX
  3. 30 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. 30 module HTTPX
  3. 30 class Timers
  4. 30 def initialize
  5. 10343 @intervals = []
  6. end
  7. 30 def empty?
  8. 223 @intervals.empty?
  9. end
  10. 30 def after(interval_in_secs, cb = nil, &blk)
  11. 25535 callback = cb || blk
  12. 25535 raise Error, "timer must have a callback" unless callback
  13. # I'm assuming here that most requests will have the same
  14. # request timeout, as in most cases they share common set of
  15. # options. A user setting different request timeouts for 100s of
  16. # requests will already have a hard time dealing with that.
  17. 42119 unless (interval = @intervals.bsearch { |t| t.interval == interval_in_secs })
  18. 13145 interval = Interval.new(interval_in_secs)
  19. 13145 @intervals << interval
  20. 13145 @intervals.sort!
  21. end
  22. 25535 interval << callback
  23. 25535 @next_interval_at = nil
  24. 25535 Timer.new(interval, callback)
  25. end
  26. 30 def wait_interval
  27. 37376 return if @intervals.empty?
  28. 15440 first_interval = @intervals.first
  29. 15440 drop_elapsed!(0) if first_interval.elapsed?(0)
  30. 15440 @next_interval_at = Utils.now
  31. 15440 first_interval.interval
  32. end
  33. 30 def fire(error = nil)
  34. 37112 raise error if error && error.timeout != @intervals.first
  35. 37112 return if @intervals.empty? || !@next_interval_at
  36. 14011 elapsed_time = Utils.elapsed_time(@next_interval_at)
  37. 14011 drop_elapsed!(elapsed_time)
  38. 14011 @next_interval_at = nil if @intervals.empty?
  39. end
  40. 30 private
  41. 30 def drop_elapsed!(elapsed_time)
  42. 30520 @intervals = @intervals.drop_while { |interval| interval.elapse(elapsed_time) <= 0 }
  43. end
  44. 30 class Timer
  45. 30 def initialize(interval, callback)
  46. 25535 @interval = interval
  47. 25535 @callback = callback
  48. end
  49. 30 def cancel
  50. 35597 @interval.delete(@callback)
  51. end
  52. end
  53. 30 class Interval
  54. 30 include Comparable
  55. 30 attr_reader :interval
  56. 30 def initialize(interval)
  57. 13145 @interval = interval
  58. 13145 @callbacks = []
  59. end
  60. 30 def <=>(other)
  61. 2345 @interval <=> other.interval
  62. end
  63. 30 def ==(other)
  64. return @interval == other if other.is_a?(Numeric)
  65. @interval == other.to_f # rubocop:disable Lint/FloatComparison
  66. end
  67. 30 def to_f
  68. Float(@interval)
  69. end
  70. 30 def <<(callback)
  71. 25535 @callbacks << callback
  72. end
  73. 30 def delete(callback)
  74. 35597 @callbacks.delete(callback)
  75. end
  76. 30 def no_callbacks?
  77. @callbacks.empty?
  78. end
  79. 30 def elapsed?(elapsed = 0)
  80. 15440 (@interval - elapsed) <= 0 || @callbacks.empty?
  81. end
  82. 30 def elapse(elapsed)
  83. # same as elapsing
  84. 15870 return 0 if @callbacks.empty?
  85. 5158 @interval -= elapsed
  86. 5663 if @interval <= 0
  87. 1292 cb = @callbacks.dup
  88. 1292 cb.each(&:call)
  89. end
  90. 5663 @interval
  91. end
  92. end
  93. 30 private_constant :Interval
  94. end
  95. end

lib/httpx/transcoder.rb

100.0% lines covered

50 relevant lines. 50 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 30 module HTTPX
  3. 30 module Transcoder
  4. 30 module_function
  5. 30 def normalize_keys(key, value, transcoder = self, &block)
  6. 2882 if value.respond_to?(:to_ary)
  7. 511 if value.empty?
  8. 144 block.call("#{key}[]")
  9. else
  10. 367 value.to_ary.each do |element|
  11. 590 transcoder.normalize_keys("#{key}[]", element, transcoder, &block)
  12. end
  13. end
  14. 2370 elsif value.respond_to?(:to_hash)
  15. 648 value.to_hash.each do |child_key, child_value|
  16. 648 transcoder.normalize_keys("#{key}[#{child_key}]", child_value, transcoder, &block)
  17. end
  18. else
  19. 1723 block.call(key.to_s, value)
  20. end
  21. end
  22. # based on https://github.com/rack/rack/blob/d15dd728440710cfc35ed155d66a98dc2c07ae42/lib/rack/query_parser.rb#L82
  23. 30 def normalize_query(params, name, v, depth)
  24. 207 raise Error, "params depth surpasses what's supported" if depth <= 0
  25. 207 name =~ /\A[\[\]]*([^\[\]]+)\]*/
  26. 207 k = Regexp.last_match(1) || ""
  27. 207 after = Regexp.last_match ? Regexp.last_match.post_match : ""
  28. 207 if k.empty?
  29. 18 return Array(v) if !v.empty? && name == "[]"
  30. 8 return
  31. end
  32. 168 case after
  33. when ""
  34. 56 params[k] = v
  35. when "["
  36. 8 params[name] = v
  37. when "[]"
  38. 18 params[k] ||= []
  39. 18 raise Error, "expected Array (got #{params[k].class}) for param '#{k}'" unless params[k].is_a?(Array)
  40. 18 params[k] << v
  41. when /^\[\]\[([^\[\]]+)\]$/, /^\[\](.+)$/
  42. 36 child_key = Regexp.last_match(1)
  43. 36 params[k] ||= []
  44. 36 raise Error, "expected Array (got #{params[k].class}) for param '#{k}'" unless params[k].is_a?(Array)
  45. 36 if params[k].last.is_a?(Hash) && !params_hash_has_key?(params[k].last, child_key)
  46. 9 normalize_query(params[k].last, child_key, v, depth - 1)
  47. else
  48. 27 params[k] << normalize_query({}, child_key, v, depth - 1)
  49. end
  50. else
  51. 63 params[k] ||= {}
  52. 63 raise Error, "expected Hash (got #{params[k].class}) for param '#{k}'" unless params[k].is_a?(Hash)
  53. 56 params[k] = normalize_query(params[k], after, v, depth - 1)
  54. end
  55. 189 params
  56. end
  57. 30 def params_hash_has_key?(hash, key)
  58. 18 return false if key.include?("[]")
  59. 18 key.split(/[\[\]]+/).inject(hash) do |h, part|
  60. 18 next h if part == ""
  61. 18 return false unless h.is_a?(Hash) && h.key?(part)
  62. 9 h[part]
  63. end
  64. 9 true
  65. end
  66. end
  67. end
  68. 30 require "httpx/transcoder/body"
  69. 30 require "httpx/transcoder/form"
  70. 30 require "httpx/transcoder/json"
  71. 30 require "httpx/transcoder/chunker"
  72. 30 require "httpx/transcoder/deflate"
  73. 30 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. 30 require "delegate"
  3. 30 module HTTPX::Transcoder
  4. 30 module Body
  5. 30 class Error < HTTPX::Error; end
  6. 30 module_function
  7. 30 class Encoder < SimpleDelegator
  8. 30 def initialize(body)
  9. 1761 body = body.open(File::RDONLY, encoding: Encoding::BINARY) if Object.const_defined?(:Pathname) && body.is_a?(Pathname)
  10. 1761 @body = body
  11. 1761 super
  12. end
  13. 30 def bytesize
  14. 6761 if @body.respond_to?(:bytesize)
  15. 2859 @body.bytesize
  16. 3901 elsif @body.respond_to?(:to_ary)
  17. 1475 @body.sum(&:bytesize)
  18. 2426 elsif @body.respond_to?(:size)
  19. 1689 @body.size || Float::INFINITY
  20. 737 elsif @body.respond_to?(:length)
  21. 405 @body.length || Float::INFINITY
  22. 332 elsif @body.respond_to?(:each)
  23. 324 Float::INFINITY
  24. else
  25. 9 raise Error, "cannot determine size of body: #{@body.inspect}"
  26. end
  27. end
  28. 30 def content_type
  29. 1628 "application/octet-stream"
  30. end
  31. end
  32. 30 def encode(body)
  33. 1761 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. 30 require "forwardable"
  3. 30 module HTTPX::Transcoder
  4. 30 module Chunker
  5. 30 class Error < HTTPX::Error; end
  6. 30 CRLF = "\r\n".b
  7. 30 class Encoder
  8. 30 extend Forwardable
  9. 30 def initialize(body)
  10. 108 @raw = body
  11. end
  12. 30 def each
  13. 108 return enum_for(__method__) unless block_given?
  14. 108 @raw.each do |chunk|
  15. 504 yield "#{chunk.bytesize.to_s(16)}#{CRLF}#{chunk}#{CRLF}"
  16. end
  17. 108 yield "0#{CRLF}"
  18. end
  19. 30 def respond_to_missing?(meth, *args)
  20. 120 @raw.respond_to?(meth, *args) || super
  21. end
  22. end
  23. 30 class Decoder
  24. 30 extend Forwardable
  25. 30 def_delegator :@buffer, :empty?
  26. 30 def_delegator :@buffer, :<<
  27. 30 def_delegator :@buffer, :clear
  28. 30 def initialize(buffer, trailers = false)
  29. 171 @buffer = buffer
  30. 171 @chunk_buffer = "".b
  31. 171 @finished = false
  32. 171 @state = :length
  33. 171 @trailers = trailers
  34. end
  35. 30 def to_s
  36. 171 @buffer
  37. end
  38. 30 def each
  39. 231 loop do
  40. 1456 case @state
  41. when :length
  42. 477 index = @buffer.index(CRLF)
  43. 477 return unless index && index.positive?
  44. # Read hex-length
  45. 477 hexlen = @buffer.byteslice(0, index)
  46. 477 @buffer = @buffer.byteslice(index..-1) || "".b
  47. 477 hexlen[/\h/] || raise(Error, "wrong chunk size line: #{hexlen}")
  48. 477 @chunk_length = hexlen.hex
  49. # check if is last chunk
  50. 477 @finished = @chunk_length.zero?
  51. 477 nextstate(:crlf)
  52. when :crlf
  53. 792 crlf_size = @finished && !@trailers ? 4 : 2
  54. # consume CRLF
  55. 792 return if @buffer.bytesize < crlf_size
  56. 792 raise Error, "wrong chunked encoding format" unless @buffer.start_with?(CRLF * (crlf_size / 2))
  57. 792 @buffer = @buffer.byteslice(crlf_size..-1)
  58. 792 if @chunk_length.nil?
  59. 315 nextstate(:length)
  60. else
  61. 477 return if @finished
  62. 333 nextstate(:data)
  63. end
  64. when :data
  65. 369 chunk = @buffer.byteslice(0, @chunk_length)
  66. 369 @buffer = @buffer.byteslice(@chunk_length..-1) || "".b
  67. 369 @chunk_buffer << chunk
  68. 328 @chunk_length -= chunk.bytesize
  69. 369 if @chunk_length.zero?
  70. 333 yield @chunk_buffer unless @chunk_buffer.empty?
  71. 315 @chunk_buffer.clear
  72. 315 @chunk_length = nil
  73. 315 nextstate(:crlf)
  74. end
  75. end
  76. 1476 break if @buffer.empty?
  77. end
  78. end
  79. 30 def finished?
  80. 213 @finished
  81. end
  82. 30 private
  83. 30 def nextstate(state)
  84. 1440 @state = state
  85. end
  86. end
  87. 30 module_function
  88. 30 def encode(chunks)
  89. 108 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. 30 require "zlib"
  3. 30 require_relative "utils/deflater"
  4. 30 module HTTPX
  5. 30 module Transcoder
  6. 30 module Deflate
  7. 30 class Deflater < Transcoder::Deflater
  8. 30 def deflate(chunk)
  9. 78 @deflater ||= Zlib::Deflate.new
  10. 78 unless chunk.nil?
  11. 27 chunk = @deflater.deflate(chunk)
  12. # deflate call may return nil, while still
  13. # retaining the last chunk in the deflater.
  14. 27 return chunk unless chunk.empty?
  15. end
  16. 54 return if @deflater.closed?
  17. 27 last = @deflater.finish
  18. 27 @deflater.close
  19. 27 last unless last.empty?
  20. end
  21. end
  22. 30 module_function
  23. 30 def encode(body)
  24. 27 Deflater.new(body)
  25. end
  26. 30 def decode(response, bytesize: nil)
  27. 18 bytesize ||= response.headers.key?("content-length") ? response.headers["content-length"].to_i : Float::INFINITY
  28. 18 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. 30 require "forwardable"
  3. 30 require "uri"
  4. 30 require_relative "multipart"
  5. 30 module HTTPX
  6. 30 module Transcoder
  7. 30 module Form
  8. 30 module_function
  9. 30 PARAM_DEPTH_LIMIT = 32
  10. 30 class Encoder
  11. 30 extend Forwardable
  12. 30 def_delegator :@raw, :to_s
  13. 30 def_delegator :@raw, :to_str
  14. 30 def_delegator :@raw, :bytesize
  15. 30 def_delegator :@raw, :==
  16. 30 def initialize(form)
  17. 870 @raw = form.each_with_object("".b) do |(key, val), buf|
  18. 1500 HTTPX::Transcoder.normalize_keys(key, val) do |k, v|
  19. 1723 buf << "&" unless buf.empty?
  20. 1723 buf << URI.encode_www_form_component(k)
  21. 1723 buf << "=#{URI.encode_www_form_component(v.to_s)}" unless v.nil?
  22. end
  23. end
  24. end
  25. 30 def content_type
  26. 680 "application/x-www-form-urlencoded"
  27. end
  28. end
  29. 30 module Decoder
  30. 30 module_function
  31. 30 def call(response, *)
  32. 45 URI.decode_www_form(response.to_s).each_with_object({}) do |(field, value), params|
  33. 108 HTTPX::Transcoder.normalize_query(params, field, value, PARAM_DEPTH_LIMIT)
  34. end
  35. end
  36. end
  37. 30 def encode(form)
  38. 870 Encoder.new(form)
  39. end
  40. 30 def decode(response)
  41. 72 content_type = response.content_type.mime_type
  42. 64 case content_type
  43. when "application/x-www-form-urlencoded"
  44. 45 Decoder
  45. when "multipart/form-data"
  46. 18 Multipart::Decoder.new(response)
  47. else
  48. 9 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

44 relevant lines. 44 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 30 require "zlib"
  3. 30 module HTTPX
  4. 30 module Transcoder
  5. 30 module GZIP
  6. 30 class Deflater < Transcoder::Deflater
  7. 30 def initialize(body)
  8. 55 @compressed_chunk = "".b
  9. 55 @deflater = nil
  10. 55 super
  11. end
  12. 30 def deflate(chunk)
  13. 110 @deflater ||= Zlib::GzipWriter.new(self)
  14. 110 if chunk.nil?
  15. 55 unless @deflater.closed?
  16. 55 @deflater.flush
  17. 55 @deflater.close
  18. 55 compressed_chunk
  19. end
  20. else
  21. 55 @deflater.write(chunk)
  22. 55 compressed_chunk
  23. end
  24. end
  25. 30 private
  26. 30 def write(*chunks)
  27. 165 chunks.sum do |chunk|
  28. 165 chunk = chunk.to_s
  29. 165 @compressed_chunk << chunk
  30. 165 chunk.bytesize
  31. end
  32. end
  33. 30 def compressed_chunk
  34. 110 @compressed_chunk.dup
  35. ensure
  36. 110 @compressed_chunk.clear
  37. end
  38. end
  39. 30 class Inflater
  40. 30 def initialize(bytesize)
  41. 201 @inflater = Zlib::Inflate.new(Zlib::MAX_WBITS + 32)
  42. 201 @bytesize = bytesize
  43. end
  44. 30 def call(chunk)
  45. 543 buffer = @inflater.inflate(chunk)
  46. 498 @bytesize -= chunk.bytesize
  47. 543 if @bytesize <= 0
  48. 139 buffer << @inflater.finish
  49. 139 @inflater.close
  50. end
  51. 543 buffer
  52. end
  53. end
  54. 30 module_function
  55. 30 def encode(body)
  56. 55 Deflater.new(body)
  57. end
  58. 30 def decode(response, bytesize: nil)
  59. 183 bytesize ||= response.headers.key?("content-length") ? response.headers["content-length"].to_i : Float::INFINITY
  60. 183 Inflater.new(bytesize)
  61. end
  62. end
  63. end
  64. 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. 30 require "forwardable"
  3. 30 module HTTPX::Transcoder
  4. 30 module JSON
  5. 30 module_function
  6. 30 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. 30 class Encoder
  20. 30 extend Forwardable
  21. 30 def_delegator :@raw, :to_s
  22. 30 def_delegator :@raw, :bytesize
  23. 30 def_delegator :@raw, :==
  24. 30 def initialize(json)
  25. 102 @raw = JSON.json_dump(json)
  26. 102 @charset = @raw.encoding.name.downcase
  27. end
  28. 30 def content_type
  29. 102 "application/json; charset=#{@charset}"
  30. end
  31. end
  32. 30 def encode(json)
  33. 102 Encoder.new(json)
  34. end
  35. 30 def decode(response)
  36. 234 content_type = response.content_type.mime_type
  37. 234 raise HTTPX::Error, "invalid json mime type (#{content_type})" unless JSON_REGEX.match?(content_type)
  38. 216 method(:json_load)
  39. end
  40. # rubocop:disable Style/SingleLineMethods
  41. 30 if defined?(MultiJson)
  42. 5 def json_load(*args); MultiJson.load(*args); end
  43. 3 def json_dump(*args); MultiJson.dump(*args); end
  44. 27 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. 25 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. 25 require "json"
  52. 209 def json_load(*args); ::JSON.parse(*args); end
  53. 113 def json_dump(*args); ::JSON.generate(*args); end
  54. end
  55. # rubocop:enable Style/SingleLineMethods
  56. end
  57. end

lib/httpx/transcoder/multipart.rb

100.0% lines covered

22 relevant lines. 22 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 30 require_relative "multipart/encoder"
  3. 30 require_relative "multipart/decoder"
  4. 30 require_relative "multipart/part"
  5. 30 require_relative "multipart/mime_type_detector"
  6. 30 module HTTPX::Transcoder
  7. 30 module Multipart
  8. 30 module_function
  9. 30 def multipart?(form_data)
  10. 1822 form_data.any? do |_, v|
  11. 2380 multipart_value?(v) ||
  12. 2246 (v.respond_to?(:to_ary) && v.to_ary.any? { |av| multipart_value?(av) }) ||
  13. 2246 (v.respond_to?(:to_hash) && v.to_hash.any? { |_, e| multipart_value?(e) })
  14. end
  15. end
  16. 30 def multipart_value?(value)
  17. 5379 value.respond_to?(:read) ||
  18. 3834 (value.is_a?(Hash) &&
  19. value.key?(:body) &&
  20. 868 (value.key?(:filename) || value.key?(:content_type)))
  21. end
  22. 30 def normalize_keys(key, value, transcoder = self, &block)
  23. 1991 if multipart_value?(value)
  24. 1271 block.call(key.to_s, value)
  25. else
  26. 720 HTTPX::Transcoder.normalize_keys(key, value, transcoder, &block)
  27. end
  28. end
  29. 30 def encode(form_data)
  30. 1142 Encoder.new(form_data)
  31. end
  32. end
  33. end

lib/httpx/transcoder/multipart/decoder.rb

94.05% lines covered

84 relevant lines. 79 lines covered and 5 lines missed.
    
  1. # frozen_string_literal: true
  2. 30 require "tempfile"
  3. 30 require "delegate"
  4. 30 module HTTPX
  5. 30 module Transcoder
  6. 30 module Multipart
  7. 30 class FilePart < SimpleDelegator
  8. 30 attr_reader :original_filename, :content_type
  9. 30 def initialize(filename, content_type)
  10. 36 @original_filename = filename
  11. 36 @content_type = content_type
  12. 36 @file = Tempfile.new("httpx", encoding: Encoding::BINARY, mode: File::RDWR)
  13. 36 super(@file)
  14. end
  15. end
  16. 30 class Decoder
  17. 30 include HTTPX::Utils
  18. 30 CRLF = "\r\n"
  19. 30 BOUNDARY_RE = /;\s*boundary=([^;]+)/i.freeze
  20. 30 MULTIPART_CONTENT_TYPE = /Content-Type: (.*)#{CRLF}/ni.freeze
  21. 30 MULTIPART_CONTENT_DISPOSITION = /Content-Disposition:.*;\s*name=(#{VALUE})/ni.freeze
  22. 30 MULTIPART_CONTENT_ID = /Content-ID:\s*([^#{CRLF}]*)/ni.freeze
  23. 30 WINDOW_SIZE = 2 << 14
  24. 30 def initialize(response)
  25. 2 @boundary = begin
  26. 18 m = response.headers["content-type"].to_s[BOUNDARY_RE, 1]
  27. 18 raise Error, "no boundary declared in content-type header" unless m
  28. 18 m.strip
  29. end
  30. 18 @buffer = "".b
  31. 18 @parts = {}
  32. 18 @intermediate_boundary = "--#{@boundary}"
  33. 18 @state = :idle
  34. 18 @current = nil
  35. end
  36. 30 def call(response, *)
  37. 18 response.body.each do |chunk|
  38. 18 @buffer << chunk
  39. 18 parse
  40. end
  41. 18 raise Error, "invalid or unsupported multipart format" unless @buffer.empty?
  42. 18 @parts
  43. end
  44. 30 private
  45. 30 def parse
  46. 16 case @state
  47. when :idle
  48. 18 raise Error, "payload does not start with boundary" unless @buffer.start_with?("#{@intermediate_boundary}#{CRLF}")
  49. 18 @buffer = @buffer.byteslice((@intermediate_boundary.bytesize + 2)..-1)
  50. 18 @state = :part_header
  51. when :part_header
  52. 54 idx = @buffer.index("#{CRLF}#{CRLF}")
  53. # raise Error, "couldn't parse part headers" unless idx
  54. 54 return unless idx
  55. # @type var head: String
  56. 54 head = @buffer.byteslice(0..(idx + 4 - 1))
  57. 54 @buffer = @buffer.byteslice(head.bytesize..-1)
  58. 54 content_type = head[MULTIPART_CONTENT_TYPE, 1] || "text/plain"
  59. 96 if (name = head[MULTIPART_CONTENT_DISPOSITION, 1])
  60. 54 name = /\A"(.*)"\Z/ =~ name ? Regexp.last_match(1) : name.dup
  61. 54 name.gsub!(/\\(.)/, "\\1")
  62. 12 name
  63. else
  64. name = head[MULTIPART_CONTENT_ID, 1]
  65. end
  66. 54 filename = HTTPX::Utils.get_filename(head)
  67. 54 name = filename || +"#{content_type}[]" if name.nil? || name.empty?
  68. 54 @current = name
  69. 48 @parts[name] = if filename
  70. 36 FilePart.new(filename, content_type)
  71. else
  72. 18 "".b
  73. end
  74. 54 @state = :part_body
  75. when :part_body
  76. 54 part = @parts[@current]
  77. 54 body_separator = if part.is_a?(FilePart)
  78. 32 "#{CRLF}#{CRLF}"
  79. else
  80. 18 CRLF
  81. end
  82. 54 idx = @buffer.index(body_separator)
  83. 54 if idx
  84. 54 payload = @buffer.byteslice(0..(idx - 1))
  85. 54 @buffer = @buffer.byteslice((idx + body_separator.bytesize)..-1)
  86. 54 part << payload
  87. 54 part.rewind if part.respond_to?(:rewind)
  88. 54 @state = :parse_boundary
  89. else
  90. part << @buffer
  91. @buffer.clear
  92. end
  93. when :parse_boundary
  94. 54 raise Error, "payload does not start with boundary" unless @buffer.start_with?(@intermediate_boundary)
  95. 54 @buffer = @buffer.byteslice(@intermediate_boundary.bytesize..-1)
  96. 54 if @buffer == "--"
  97. 18 @buffer.clear
  98. 18 @state = :done
  99. 18 return
  100. 35 elsif @buffer.start_with?(CRLF)
  101. 36 @buffer = @buffer.byteslice(2..-1)
  102. 36 @state = :part_header
  103. else
  104. return
  105. end
  106. when :done
  107. raise Error, "parsing should have been over by now"
  108. 20 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. 30 module HTTPX
  3. 30 module Transcoder::Multipart
  4. 30 class Encoder
  5. 30 attr_reader :bytesize
  6. 30 def initialize(form)
  7. 1142 @boundary = ("-" * 21) << SecureRandom.hex(21)
  8. 1142 @part_index = 0
  9. 1142 @buffer = "".b
  10. 1142 @form = form
  11. 1142 @bytesize = 0
  12. 1142 @parts = to_parts(form)
  13. end
  14. 30 def content_type
  15. 1142 "multipart/form-data; boundary=#{@boundary}"
  16. end
  17. 30 def to_s
  18. 21 read || ""
  19. ensure
  20. 21 rewind
  21. end
  22. 30 def read(length = nil, outbuf = nil)
  23. 4245 data = String(outbuf).clear.force_encoding(Encoding::BINARY) if outbuf
  24. 4245 data ||= "".b
  25. 4245 read_chunks(data, length)
  26. 4245 data unless length && data.empty?
  27. end
  28. 30 def rewind
  29. 57 form = @form.each_with_object([]) do |(key, val), aux|
  30. 57 if val.respond_to?(:path) && val.respond_to?(:reopen) && val.respond_to?(:closed?) && val.closed?
  31. # @type var val: File
  32. 57 val = val.reopen(val.path, File::RDONLY)
  33. end
  34. 57 val.rewind if val.respond_to?(:rewind)
  35. 57 aux << [key, val]
  36. end
  37. 57 @form = form
  38. 57 @bytesize = 0
  39. 57 @parts = to_parts(form)
  40. 57 @part_index = 0
  41. end
  42. 30 private
  43. 30 def to_parts(form)
  44. 1199 params = form.each_with_object([]) do |(key, val), aux|
  45. 1415 Transcoder::Multipart.normalize_keys(key, val) do |k, v|
  46. 1415 next if v.nil?
  47. 1415 value, content_type, filename = Part.call(v)
  48. 1415 header = header_part(k, content_type, filename)
  49. 1263 @bytesize += header.size
  50. 1415 aux << header
  51. 1263 @bytesize += value.size
  52. 1415 aux << value
  53. 1415 delimiter = StringIO.new("\r\n")
  54. 1263 @bytesize += delimiter.size
  55. 1415 aux << delimiter
  56. end
  57. end
  58. 1199 final_delimiter = StringIO.new("--#{@boundary}--\r\n")
  59. 1071 @bytesize += final_delimiter.size
  60. 1199 params << final_delimiter
  61. 1199 params
  62. end
  63. 30 def header_part(key, content_type, filename)
  64. 1415 header = "--#{@boundary}\r\n".b
  65. 1415 header << "Content-Disposition: form-data; name=#{key.inspect}".b
  66. 1415 header << "; filename=#{filename.inspect}" if filename
  67. 1415 header << "\r\nContent-Type: #{content_type}\r\n\r\n"
  68. 1415 StringIO.new(header)
  69. end
  70. 30 def read_chunks(buffer, length = nil)
  71. 5409 while @part_index < @parts.size
  72. 12629 chunk = read_from_part(length)
  73. 12629 next unless chunk
  74. 7241 buffer << chunk.force_encoding(Encoding::BINARY)
  75. 7241 next unless length
  76. 6376 length -= chunk.bytesize
  77. 7164 break if length.zero?
  78. end
  79. end
  80. # if there's a current part to read from, tries to read a chunk.
  81. 30 def read_from_part(max_length = nil)
  82. 12629 part = @parts[@part_index]
  83. 12629 chunk = part.read(max_length, @buffer)
  84. 12629 return chunk if chunk && !chunk.empty?
  85. 5388 part.close if part.respond_to?(:close)
  86. 4804 @part_index += 1
  87. 2388 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. 30 module HTTPX
  3. 30 module Transcoder::Multipart
  4. 30 module MimeTypeDetector
  5. 30 module_function
  6. 30 DEFAULT_MIMETYPE = "application/octet-stream"
  7. # inspired by https://github.com/shrinerb/shrine/blob/master/lib/shrine/plugins/determine_mime_type.rb
  8. 30 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. 28 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. 27 elsif defined?(MimeMagic)
  24. 1 def call(file, _)
  25. 1 mime = MimeMagic.by_magic(file)
  26. 1 mime.type if mime
  27. end
  28. 26 elsif system("which file", out: File::NULL)
  29. 27 require "open3"
  30. 27 def call(file, _)
  31. 835 return if file.eof? # file command returns "application/x-empty" for empty files
  32. 785 Open3.popen3(*%w[file --mime-type --brief -]) do |stdin, stdout, stderr, thread|
  33. 83 begin
  34. 785 IO.copy_stream(file, stdin.binmode)
  35. rescue Errno::EPIPE
  36. end
  37. 785 file.rewind
  38. 785 stdin.close
  39. 785 status = thread.value
  40. # call to file command failed
  41. 785 if status.nil? || !status.success?
  42. $stderr.print(stderr.read)
  43. else
  44. 785 output = stdout.read.strip
  45. 785 if output.include?("cannot open")
  46. $stderr.print(output)
  47. else
  48. 785 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. 30 module HTTPX
  3. 30 module Transcoder::Multipart
  4. 30 module Part
  5. 30 module_function
  6. 30 def call(value)
  7. # take out specialized objects of the way
  8. 1415 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. 1271 content_type = filename = nil
  12. 1271 if value.is_a?(Hash)
  13. 434 content_type = value[:content_type]
  14. 434 filename = value[:filename]
  15. 434 value = value[:body]
  16. end
  17. 1271 value = value.open(File::RDONLY, encoding: Encoding::BINARY) if Object.const_defined?(:Pathname) && value.is_a?(Pathname)
  18. 1271 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. 839 filename ||= File.basename(value.path)
  21. 839 content_type ||= MimeTypeDetector.call(value, filename) || "application/octet-stream"
  22. 839 [value, content_type, filename]
  23. else
  24. 432 [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. 30 require "stringio"
  3. 30 module HTTPX
  4. 30 module Transcoder
  5. 30 class BodyReader
  6. 30 def initialize(body)
  7. 243 @body = if body.respond_to?(:read)
  8. 23 body.rewind if body.respond_to?(:rewind)
  9. 23 body
  10. 219 elsif body.respond_to?(:each)
  11. 42 body.enum_for(:each)
  12. else
  13. 178 StringIO.new(body.to_s)
  14. end
  15. end
  16. 30 def bytesize
  17. 525 return @body.bytesize if @body.respond_to?(:bytesize)
  18. 483 Float::INFINITY
  19. end
  20. 30 def read(length = nil, outbuf = nil)
  21. 552 return @body.read(length, outbuf) if @body.respond_to?(:read)
  22. begin
  23. 112 chunk = @body.next
  24. 56 if outbuf
  25. outbuf.replace(chunk)
  26. else
  27. 56 outbuf = chunk
  28. end
  29. 56 outbuf unless length && outbuf.empty?
  30. 32 rescue StopIteration
  31. end
  32. end
  33. 30 def close
  34. 55 @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. 30 require_relative "body_reader"
  3. 30 module HTTPX
  4. 30 module Transcoder
  5. 30 class Deflater
  6. 30 attr_reader :content_type
  7. 30 def initialize(body)
  8. 96 @content_type = body.content_type
  9. 96 @body = BodyReader.new(body)
  10. 96 @closed = false
  11. end
  12. 30 def bytesize
  13. 370 buffer_deflate!
  14. 370 @buffer.size
  15. end
  16. 30 def read(length = nil, outbuf = nil)
  17. 486 return @buffer.read(length, outbuf) if @buffer
  18. 285 return if @closed
  19. 230 chunk = @body.read(length)
  20. 230 compressed_chunk = deflate(chunk)
  21. 230 return unless compressed_chunk
  22. 203 if outbuf
  23. 194 outbuf.replace(compressed_chunk)
  24. else
  25. 9 compressed_chunk
  26. end
  27. end
  28. 30 def close
  29. 55 return if @closed
  30. 55 @buffer.close if @buffer
  31. 55 @body.close
  32. 55 @closed = true
  33. end
  34. 30 def rewind
  35. 32 return unless @buffer
  36. 18 @buffer.rewind
  37. end
  38. 30 private
  39. # rubocop:disable Naming/MemoizedInstanceVariableName
  40. 30 def buffer_deflate!
  41. 370 return @buffer if defined?(@buffer)
  42. 96 buffer = Response::Buffer.new(
  43. threshold_size: Options::MAX_BODY_THRESHOLD_SIZE
  44. )
  45. 96 IO.copy_stream(self, buffer)
  46. 96 buffer.rewind if buffer.respond_to?(:rewind)
  47. 96 @buffer = buffer
  48. end
  49. # rubocop:enable Naming/MemoizedInstanceVariableName
  50. end
  51. end
  52. end

lib/httpx/utils.rb

100.0% lines covered

44 relevant lines. 44 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 30 module HTTPX
  3. 30 module Utils
  4. 30 using URIExtensions
  5. 30 TOKEN = %r{[^\s()<>,;:\\"/\[\]?=]+}.freeze
  6. 30 VALUE = /"(?:\\"|[^"])*"|#{TOKEN}/.freeze
  7. 30 FILENAME_REGEX = /\s*filename=(#{VALUE})/.freeze
  8. 30 FILENAME_EXTENSION_REGEX = /\s*filename\*=(#{VALUE})/.freeze
  9. 30 module_function
  10. 30 def now
  11. 50474 Process.clock_gettime(Process::CLOCK_MONOTONIC)
  12. end
  13. 30 def elapsed_time(monotonic_timestamp)
  14. 14714 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. 30 def parse_retry_after(retry_after)
  19. # first: bet on it being an integer
  20. 144 Integer(retry_after)
  21. rescue ArgumentError
  22. # Then it's a datetime
  23. 18 time = Time.httpdate(retry_after)
  24. 18 time - Time.now
  25. end
  26. 30 def get_filename(header, _prefix_regex = nil)
  27. 99 filename = nil
  28. 88 case header
  29. when FILENAME_REGEX
  30. 63 filename = Regexp.last_match(1)
  31. 63 filename = Regexp.last_match(1) if filename =~ /^"(.*)"$/
  32. when FILENAME_EXTENSION_REGEX
  33. 18 filename = Regexp.last_match(1)
  34. 18 encoding, _, filename = filename.split("'", 3)
  35. end
  36. 99 return unless filename
  37. 153 filename = URI::DEFAULT_PARSER.unescape(filename) if filename.scan(/%.?.?/).all? { |s| /%[0-9a-fA-F]{2}/.match?(s) }
  38. 81 filename.scrub!
  39. 81 filename = filename.gsub(/\\(.)/, '\1') unless /\\[^\\"]/.match?(filename)
  40. 81 filename.force_encoding ::Encoding.find(encoding) if encoding
  41. 81 filename
  42. end
  43. 30 URIParser = URI::RFC2396_Parser.new
  44. 30 def to_uri(uri)
  45. 23975 return URI(uri) unless uri.is_a?(String) && !uri.ascii_only?
  46. 37 uri = URI(URIParser.escape(uri))
  47. 37 non_ascii_hostname = URIParser.unescape(uri.host)
  48. 37 non_ascii_hostname.force_encoding(Encoding::UTF_8)
  49. 37 idna_hostname = Punycode.encode_hostname(non_ascii_hostname)
  50. 37 uri.host = idna_hostname
  51. 36 uri.non_ascii_hostname = non_ascii_hostname
  52. 36 uri
  53. end
  54. 30 if defined?(Ractor) &&
  55. # no ractor support for 3.0
  56. RUBY_VERSION >= "3.1.0"
  57. 24 def in_ractor?
  58. 16343 Ractor.main != Ractor.current
  59. end
  60. else
  61. 6 def in_ractor?
  62. 11778 false
  63. end
  64. end
  65. end
  66. end