loading
Generated 2025-05-15T00:01:51+00:00

All Files ( 95.81% covered at 87426.43 hits/line )

105 files in total.
7591 relevant lines, 7273 lines covered and 318 lines missed. ( 95.81% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line
lib/httpx.rb 100.00 % 66 39 39 0 638.21
lib/httpx/adapters/datadog.rb 87.04 % 345 162 141 21 36.93
lib/httpx/adapters/faraday.rb 98.10 % 298 158 155 3 108.15
lib/httpx/adapters/sentry.rb 100.00 % 121 62 62 0 99.03
lib/httpx/adapters/webmock.rb 100.00 % 175 93 93 0 96.04
lib/httpx/altsvc.rb 96.39 % 163 83 80 3 192.40
lib/httpx/buffer.rb 100.00 % 61 27 27 0 363996.44
lib/httpx/callbacks.rb 100.00 % 35 19 19 0 140875.79
lib/httpx/chainable.rb 95.35 % 106 43 41 2 917.00
lib/httpx/connection.rb 91.91 % 953 482 443 39 166424.38
lib/httpx/connection/http1.rb 89.14 % 400 221 197 24 3159.42
lib/httpx/connection/http2.rb 95.04 % 452 262 249 13 261133.02
lib/httpx/domain_name.rb 95.45 % 145 44 42 2 189.27
lib/httpx/errors.rb 97.62 % 109 42 41 1 70.33
lib/httpx/extensions.rb 67.86 % 59 28 19 9 416.71
lib/httpx/headers.rb 100.00 % 176 71 71 0 14887.62
lib/httpx/io.rb 100.00 % 11 5 5 0 25.00
lib/httpx/io/ssl.rb 88.89 % 166 81 72 9 2058.15
lib/httpx/io/tcp.rb 90.27 % 208 113 102 11 6549.94
lib/httpx/io/udp.rb 85.71 % 62 35 30 5 317.29
lib/httpx/io/unix.rb 94.29 % 70 35 33 2 18.14
lib/httpx/loggable.rb 100.00 % 53 20 20 0 521946.55
lib/httpx/options.rb 98.72 % 375 156 154 2 15530.29
lib/httpx/parser/http1.rb 100.00 % 186 109 109 0 6269.05
lib/httpx/plugins/auth.rb 100.00 % 25 9 9 0 18.00
lib/httpx/plugins/auth/basic.rb 100.00 % 20 10 10 0 66.00
lib/httpx/plugins/auth/digest.rb 100.00 % 102 55 55 0 105.16
lib/httpx/plugins/auth/ntlm.rb 100.00 % 35 19 19 0 2.95
lib/httpx/plugins/auth/socks5.rb 100.00 % 22 11 11 0 18.18
lib/httpx/plugins/aws_sdk_authentication.rb 100.00 % 109 43 43 0 9.91
lib/httpx/plugins/aws_sigv4.rb 100.00 % 237 105 105 0 87.14
lib/httpx/plugins/basic_auth.rb 100.00 % 29 12 12 0 26.50
lib/httpx/plugins/brotli.rb 100.00 % 50 25 25 0 10.80
lib/httpx/plugins/callbacks.rb 100.00 % 115 53 53 0 105.57
lib/httpx/plugins/circuit_breaker.rb 100.00 % 145 64 64 0 58.28
lib/httpx/plugins/circuit_breaker/circuit.rb 100.00 % 100 47 47 0 44.51
lib/httpx/plugins/circuit_breaker/circuit_store.rb 100.00 % 53 23 23 0 74.09
lib/httpx/plugins/content_digest.rb 100.00 % 202 98 98 0 61.51
lib/httpx/plugins/cookies.rb 100.00 % 107 51 51 0 93.76
lib/httpx/plugins/cookies/cookie.rb 100.00 % 174 76 76 0 242.47
lib/httpx/plugins/cookies/jar.rb 100.00 % 95 46 46 0 195.87
lib/httpx/plugins/cookies/set_cookie_parser.rb 100.00 % 143 72 72 0 117.08
lib/httpx/plugins/digest_auth.rb 100.00 % 65 29 29 0 74.90
lib/httpx/plugins/expect.rb 100.00 % 118 56 56 0 67.32
lib/httpx/plugins/follow_redirects.rb 100.00 % 231 108 108 0 166715.38
lib/httpx/plugins/grpc.rb 100.00 % 280 133 133 0 117.79
lib/httpx/plugins/grpc/call.rb 90.91 % 63 33 30 3 40.18
lib/httpx/plugins/grpc/grpc_encoding.rb 97.87 % 90 47 46 1 76.85
lib/httpx/plugins/grpc/message.rb 95.83 % 55 24 23 1 39.50
lib/httpx/plugins/h2c.rb 94.92 % 117 59 56 3 10.88
lib/httpx/plugins/ntlm_auth.rb 100.00 % 60 30 30 0 3.97
lib/httpx/plugins/oauth.rb 100.00 % 175 87 87 0 53.33
lib/httpx/plugins/persistent.rb 100.00 % 73 28 28 0 182.04
lib/httpx/plugins/proxy.rb 98.00 % 308 150 147 3 256.37
lib/httpx/plugins/proxy/http.rb 100.00 % 184 104 104 0 147.82
lib/httpx/plugins/proxy/socks4.rb 97.44 % 135 78 76 2 140.87
lib/httpx/plugins/proxy/socks5.rb 99.11 % 194 112 111 1 224.61
lib/httpx/plugins/proxy/ssh.rb 92.31 % 92 52 48 4 8.19
lib/httpx/plugins/push_promise.rb 100.00 % 81 41 41 0 7.90
lib/httpx/plugins/query.rb 100.00 % 35 14 14 0 8.14
lib/httpx/plugins/rate_limiter.rb 100.00 % 55 17 17 0 32.82
lib/httpx/plugins/response_cache.rb 100.00 % 331 140 140 0 143.76
lib/httpx/plugins/response_cache/file_store.rb 100.00 % 140 72 72 0 128.17
lib/httpx/plugins/response_cache/store.rb 100.00 % 33 16 16 0 116.63
lib/httpx/plugins/retries.rb 96.84 % 228 95 92 3 191885.74
lib/httpx/plugins/ssrf_filter.rb 100.00 % 145 59 59 0 112.81
lib/httpx/plugins/stream.rb 97.73 % 183 88 86 2 98.75
lib/httpx/plugins/stream_bidi.rb 99.28 % 315 138 137 1 70.59
lib/httpx/plugins/upgrade.rb 100.00 % 78 34 34 0 38.18
lib/httpx/plugins/upgrade/h2.rb 91.67 % 54 24 22 2 6.25
lib/httpx/plugins/webdav.rb 100.00 % 86 38 38 0 17.84
lib/httpx/plugins/xml.rb 100.00 % 76 34 34 0 63.71
lib/httpx/pmatch_extensions.rb 100.00 % 33 17 17 0 24.12
lib/httpx/pool.rb 97.70 % 185 87 85 2 3915.79
lib/httpx/punycode.rb 100.00 % 22 9 9 0 16.78
lib/httpx/request.rb 100.00 % 317 132 132 0 5516.06
lib/httpx/request/body.rb 100.00 % 153 66 66 0 2526.42
lib/httpx/resolver.rb 100.00 % 161 79 79 0 1397.19
lib/httpx/resolver/https.rb 86.01 % 254 143 123 20 34.02
lib/httpx/resolver/multi.rb 88.24 % 93 51 45 6 2816.27
lib/httpx/resolver/native.rb 88.96 % 538 308 274 34 1050.14
lib/httpx/resolver/resolver.rb 83.95 % 169 81 68 13 1420.44
lib/httpx/resolver/system.rb 78.99 % 253 138 109 29 16.86
lib/httpx/response.rb 100.00 % 304 114 114 0 1541.18
lib/httpx/response/body.rb 100.00 % 242 106 106 0 2400.57
lib/httpx/response/buffer.rb 91.67 % 120 60 55 5 1324.60
lib/httpx/selector.rb 93.40 % 221 106 99 7 1830586.74
lib/httpx/session.rb 94.42 % 553 269 254 15 110945.22
lib/httpx/session_extensions.rb 100.00 % 29 14 14 0 6.21
lib/httpx/timers.rb 93.94 % 133 66 62 4 3087017.62
lib/httpx/transcoder.rb 100.00 % 91 52 52 0 214.46
lib/httpx/transcoder/body.rb 100.00 % 43 26 26 0 750.38
lib/httpx/transcoder/chunker.rb 100.00 % 115 66 66 0 169.38
lib/httpx/transcoder/deflate.rb 100.00 % 37 20 20 0 25.40
lib/httpx/transcoder/form.rb 100.00 % 80 42 42 0 335.86
lib/httpx/transcoder/gzip.rb 100.00 % 71 40 40 0 82.70
lib/httpx/transcoder/json.rb 100.00 % 71 33 33 0 35.73
lib/httpx/transcoder/multipart.rb 100.00 % 17 10 10 0 699.10
lib/httpx/transcoder/multipart/decoder.rb 93.83 % 141 81 76 5 23.80
lib/httpx/transcoder/multipart/encoder.rb 100.00 % 120 70 70 0 1470.74
lib/httpx/transcoder/multipart/mime_type_detector.rb 91.89 % 78 37 34 3 138.43
lib/httpx/transcoder/multipart/part.rb 100.00 % 35 18 18 0 385.50
lib/httpx/transcoder/utils/body_reader.rb 92.00 % 46 25 23 2 96.40
lib/httpx/transcoder/utils/deflater.rb 97.30 % 75 37 36 1 88.54
lib/httpx/utils.rb 100.00 % 75 39 39 0 497149.15

lib/httpx.rb

100.0% lines covered

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

lib/httpx/adapters/datadog.rb

87.04% lines covered

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

lib/httpx/adapters/faraday.rb

98.1% lines covered

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

lib/httpx/adapters/sentry.rb

100.0% lines covered

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

lib/httpx/adapters/webmock.rb

100.0% lines covered

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

lib/httpx/altsvc.rb

96.39% lines covered

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

lib/httpx/buffer.rb

100.0% lines covered

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

lib/httpx/chainable.rb

95.35% lines covered

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

lib/httpx/connection.rb

91.91% lines covered

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

lib/httpx/connection/http1.rb

89.14% lines covered

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

lib/httpx/connection/http2.rb

95.04% lines covered

262 relevant lines. 249 lines covered and 13 lines missed.
    
  1. # frozen_string_literal: true
  2. 25 require "securerandom"
  3. 25 require "http/2"
  4. 25 module HTTPX
  5. 25 class Connection::HTTP2
  6. 25 include Callbacks
  7. 25 include Loggable
  8. 25 MAX_CONCURRENT_REQUESTS = ::HTTP2::DEFAULT_MAX_CONCURRENT_STREAMS
  9. 25 class Error < Error
  10. 25 def initialize(id, error)
  11. 44 super("stream #{id} closed with error: #{error}")
  12. end
  13. end
  14. 25 class PingError < Error
  15. 25 def initialize
  16. super(0, :ping_error)
  17. end
  18. end
  19. 25 class GoawayError < Error
  20. 25 def initialize
  21. 14 super(0, :no_error)
  22. end
  23. end
  24. 25 attr_reader :streams, :pending
  25. 25 def initialize(buffer, options)
  26. 2516 @options = options
  27. 2516 @settings = @options.http2_settings
  28. 2516 @pending = []
  29. 2516 @streams = {}
  30. 2516 @drains = {}
  31. 2516 @pings = []
  32. 2516 @buffer = buffer
  33. 2516 @handshake_completed = false
  34. 2516 @wait_for_handshake = @settings.key?(:wait_for_handshake) ? @settings.delete(:wait_for_handshake) : true
  35. 2516 @max_concurrent_requests = @options.max_concurrent_requests || MAX_CONCURRENT_REQUESTS
  36. 2516 @max_requests = @options.max_requests
  37. 2516 init_connection
  38. end
  39. 25 def timeout
  40. 4967 return @options.timeout[:operation_timeout] if @handshake_completed
  41. 2481 @options.timeout[:settings_timeout]
  42. end
  43. 25 def interests
  44. # waiting for WINDOW_UPDATE frames
  45. 9714486 return :r if @buffer.full?
  46. 9714486 if @connection.state == :closed
  47. 2225 return unless @handshake_completed
  48. 2183 return :w
  49. end
  50. 9712261 unless @connection.state == :connected && @handshake_completed
  51. 10599 return @buffer.empty? ? :r : :rw
  52. end
  53. 9701662 return :w if !@pending.empty? && can_buffer_more_requests?
  54. 9701662 return :w unless @drains.empty?
  55. 9700920 if @buffer.empty?
  56. 9700920 return if @streams.empty? && @pings.empty?
  57. 34760 return :r
  58. end
  59. :rw
  60. end
  61. 25 def close
  62. 2113 unless @connection.state == :closed
  63. 2107 @connection.goaway
  64. 2107 emit(:timeout, @options.timeout[:close_handshake_timeout])
  65. end
  66. 2113 emit(:close, true)
  67. end
  68. 25 def empty?
  69. 2107 @connection.state == :closed || @streams.empty?
  70. end
  71. 25 def exhausted?
  72. 2520 !@max_requests.positive?
  73. end
  74. 25 def <<(data)
  75. 25336 @connection << data
  76. end
  77. 25 def send(request, head = false)
  78. 5498 unless can_buffer_more_requests?
  79. 2660 head ? @pending.unshift(request) : @pending << request
  80. 2660 return false
  81. end
  82. 2838 unless (stream = @streams[request])
  83. 2838 stream = @connection.new_stream
  84. 2838 handle_stream(stream, request)
  85. 2838 @streams[request] = stream
  86. 2838 @max_requests -= 1
  87. end
  88. 2838 handle(request, stream)
  89. 2826 true
  90. rescue ::HTTP2::Error::StreamLimitExceeded
  91. @pending.unshift(request)
  92. false
  93. end
  94. 25 def consume
  95. 19804 @streams.each do |request, stream|
  96. 7943 next unless request.can_buffer?
  97. 850 handle(request, stream)
  98. end
  99. end
  100. 25 def handle_error(ex, request = nil)
  101. 209 if ex.is_a?(OperationTimeoutError) && !@handshake_completed && @connection.state != :closed
  102. 6 @connection.goaway(:settings_timeout, "closing due to settings timeout")
  103. 6 emit(:close_handshake)
  104. 6 settings_ex = SettingsTimeoutError.new(ex.timeout, ex.message)
  105. 6 settings_ex.set_backtrace(ex.backtrace)
  106. 6 ex = settings_ex
  107. end
  108. 209 @streams.each_key do |req|
  109. 172 next if request && request == req
  110. 14 emit(:error, req, ex)
  111. end
  112. 445 while (req = @pending.shift)
  113. 27 next if request && request == req
  114. 27 emit(:error, req, ex)
  115. end
  116. end
  117. 25 def ping
  118. 16 ping = SecureRandom.gen_random(8)
  119. 16 @connection.ping(ping.dup)
  120. ensure
  121. 16 @pings << ping
  122. end
  123. 25 private
  124. 25 def can_buffer_more_requests?
  125. 5891 (@handshake_completed || !@wait_for_handshake) &&
  126. @streams.size < @max_concurrent_requests &&
  127. @streams.size < @max_requests
  128. end
  129. 25 def send_pending
  130. 7468 while (request = @pending.shift)
  131. 2563 break unless send(request, true)
  132. end
  133. end
  134. 25 def handle(request, stream)
  135. 3796 catch(:buffer_full) do
  136. 3796 request.transition(:headers)
  137. 3790 join_headers(stream, request) if request.state == :headers
  138. 3790 request.transition(:body)
  139. 3790 join_body(stream, request) if request.state == :body
  140. 2964 request.transition(:trailers)
  141. 2964 join_trailers(stream, request) if request.state == :trailers && !request.body.empty?
  142. 2964 request.transition(:done)
  143. end
  144. end
  145. 25 def init_connection
  146. 2516 @connection = ::HTTP2::Client.new(@settings)
  147. 2516 @connection.on(:frame, &method(:on_frame))
  148. 2516 @connection.on(:frame_sent, &method(:on_frame_sent))
  149. 2516 @connection.on(:frame_received, &method(:on_frame_received))
  150. 2516 @connection.on(:origin, &method(:on_origin))
  151. 2516 @connection.on(:promise, &method(:on_promise))
  152. 2516 @connection.on(:altsvc) { |frame| on_altsvc(frame[:origin], frame) }
  153. 2516 @connection.on(:settings_ack, &method(:on_settings))
  154. 2516 @connection.on(:ack, &method(:on_pong))
  155. 2516 @connection.on(:goaway, &method(:on_close))
  156. #
  157. # Some servers initiate HTTP/2 negotiation right away, some don't.
  158. # As such, we have to check the socket buffer. If there is something
  159. # to read, the server initiated the negotiation. If not, we have to
  160. # initiate it.
  161. #
  162. 2516 @connection.send_connection_preface
  163. end
  164. 25 alias_method :reset, :init_connection
  165. 25 public :reset
  166. 25 def handle_stream(stream, request)
  167. 2850 request.on(:refuse, &method(:on_stream_refuse).curry(3)[stream, request])
  168. 2850 stream.on(:close, &method(:on_stream_close).curry(3)[stream, request])
  169. 2850 stream.on(:half_close) do
  170. 2820 log(level: 2) { "#{stream.id}: waiting for response..." }
  171. end
  172. 2850 stream.on(:altsvc, &method(:on_altsvc).curry(2)[request.origin])
  173. 2850 stream.on(:headers, &method(:on_stream_headers).curry(3)[stream, request])
  174. 2850 stream.on(:data, &method(:on_stream_data).curry(3)[stream, request])
  175. end
  176. 25 def set_protocol_headers(request)
  177. {
  178. 2832 ":scheme" => request.scheme,
  179. ":method" => request.verb,
  180. ":path" => request.path,
  181. ":authority" => request.authority,
  182. }
  183. end
  184. 25 def join_headers(stream, request)
  185. 2832 extra_headers = set_protocol_headers(request)
  186. 2832 if request.headers.key?("host")
  187. 6 log { "forbidden \"host\" header found (#{log_redact(request.headers["host"])}), will use it as authority..." }
  188. 6 extra_headers[":authority"] = request.headers["host"]
  189. end
  190. 2832 log(level: 1, color: :yellow) do
  191. 108 request.headers.merge(extra_headers).each.map { |k, v| "#{stream.id}: -> HEADER: #{k}: #{log_redact(v)}" }.join("\n")
  192. end
  193. 2832 stream.headers(request.headers.each(extra_headers), end_stream: request.body.empty?)
  194. end
  195. 25 def join_trailers(stream, request)
  196. 1201 unless request.trailers?
  197. 1195 stream.data("", end_stream: true) if request.callbacks_for?(:trailers)
  198. 1195 return
  199. end
  200. 6 log(level: 1, color: :yellow) do
  201. 12 request.trailers.each.map { |k, v| "#{stream.id}: -> HEADER: #{k}: #{log_redact(v)}" }.join("\n")
  202. end
  203. 6 stream.headers(request.trailers.each, end_stream: true)
  204. end
  205. 25 def join_body(stream, request)
  206. 3646 return if request.body.empty?
  207. 2027 chunk = @drains.delete(request) || request.drain_body
  208. 2027 while chunk
  209. 2183 next_chunk = request.drain_body
  210. 2183 send_chunk(request, stream, chunk, next_chunk)
  211. 2111 if next_chunk && (@buffer.full? || request.body.unbounded_body?)
  212. 754 @drains[request] = next_chunk
  213. 754 throw(:buffer_full)
  214. end
  215. 1357 chunk = next_chunk
  216. end
  217. 1201 return unless (error = request.drain_error)
  218. 24 on_stream_refuse(stream, request, error)
  219. end
  220. 25 def send_chunk(request, stream, chunk, next_chunk)
  221. 2201 log(level: 1, color: :green) { "#{stream.id}: -> DATA: #{chunk.bytesize} bytes..." }
  222. 2201 log(level: 2, color: :green) { "#{stream.id}: -> #{log_redact(chunk.inspect)}" }
  223. 2183 stream.data(chunk, end_stream: end_stream?(request, next_chunk))
  224. end
  225. 25 def end_stream?(request, next_chunk)
  226. 2111 !(next_chunk || request.trailers? || request.callbacks_for?(:trailers))
  227. end
  228. ######
  229. # HTTP/2 Callbacks
  230. ######
  231. 25 def on_stream_headers(stream, request, h)
  232. 2820 response = request.response
  233. 2820 if response.is_a?(Response) && response.version == "2.0"
  234. 114 on_stream_trailers(stream, response, h)
  235. 114 return
  236. end
  237. 2706 log(color: :yellow) do
  238. 108 h.map { |k, v| "#{stream.id}: <- HEADER: #{k}: #{log_redact(v)}" }.join("\n")
  239. end
  240. 2706 _, status = h.shift
  241. 2706 headers = request.options.headers_class.new(h)
  242. 2706 response = request.options.response_class.new(request, status, "2.0", headers)
  243. 2706 request.response = response
  244. 2700 @streams[request] = stream
  245. 2700 handle(request, stream) if request.expects?
  246. end
  247. 25 def on_stream_trailers(stream, response, h)
  248. 114 log(color: :yellow) do
  249. h.map { |k, v| "#{stream.id}: <- HEADER: #{k}: #{log_redact(v)}" }.join("\n")
  250. end
  251. 114 response.merge_headers(h)
  252. end
  253. 25 def on_stream_data(stream, request, data)
  254. 4922 log(level: 1, color: :green) { "#{stream.id}: <- DATA: #{data.bytesize} bytes..." }
  255. 4922 log(level: 2, color: :green) { "#{stream.id}: <- #{log_redact(data.inspect)}" }
  256. 4904 request.response << data
  257. end
  258. 25 def on_stream_refuse(stream, request, error)
  259. 24 on_stream_close(stream, request, error)
  260. 24 stream.close
  261. end
  262. 25 def on_stream_close(stream, request, error)
  263. 2664 return if error == :stream_closed && !@streams.key?(request)
  264. 2652 log(level: 2) { "#{stream.id}: closing stream" }
  265. 2640 @drains.delete(request)
  266. 2640 @streams.delete(request)
  267. 2640 if error
  268. 24 case error
  269. when :http_1_1_required
  270. emit(:error, request, error)
  271. else
  272. 24 ex = Error.new(stream.id, error)
  273. 24 ex.set_backtrace(caller)
  274. 24 response = ErrorResponse.new(request, ex)
  275. 24 request.response = response
  276. 24 emit(:response, request, response)
  277. end
  278. else
  279. 2616 response = request.response
  280. 2616 if response && response.is_a?(Response) && response.status == 421
  281. 6 emit(:error, request, :http_1_1_required)
  282. else
  283. 2610 emit(:response, request, response)
  284. end
  285. end
  286. 2634 send(@pending.shift) unless @pending.empty?
  287. 2634 return unless @streams.empty? && exhausted?
  288. 6 if @pending.empty?
  289. close
  290. else
  291. 6 emit(:exhausted)
  292. end
  293. end
  294. 25 def on_frame(bytes)
  295. 15595 @buffer << bytes
  296. end
  297. 25 def on_settings(*)
  298. 2486 @handshake_completed = true
  299. 2486 emit(:current_timeout)
  300. 2486 @max_concurrent_requests = [@max_concurrent_requests, @connection.remote_settings[:settings_max_concurrent_streams]].min
  301. 2486 send_pending
  302. end
  303. 25 def on_close(_last_frame, error, _payload)
  304. 26 is_connection_closed = @connection.state == :closed
  305. 26 if error
  306. 26 @buffer.clear if is_connection_closed
  307. 26 case error
  308. when :http_1_1_required
  309. 18 while (request = @pending.shift)
  310. 6 emit(:error, request, error)
  311. end
  312. when :no_error
  313. 14 ex = GoawayError.new
  314. 14 @pending.unshift(*@streams.keys)
  315. 14 @drains.clear
  316. 14 @streams.clear
  317. else
  318. 6 ex = Error.new(0, error)
  319. end
  320. 26 if ex
  321. 20 ex.set_backtrace(caller)
  322. 20 handle_error(ex)
  323. end
  324. end
  325. 26 return unless is_connection_closed && @streams.empty?
  326. 26 emit(:close, is_connection_closed)
  327. end
  328. 25 def on_frame_sent(frame)
  329. 13145 log(level: 2) { "#{frame[:stream]}: frame was sent!" }
  330. 13073 log(level: 2, color: :blue) do
  331. payload =
  332. 72 case frame[:type]
  333. when :data
  334. 18 frame.merge(payload: frame[:payload].bytesize)
  335. when :headers, :ping
  336. 18 frame.merge(payload: log_redact(frame[:payload]))
  337. else
  338. 36 frame
  339. end
  340. 72 "#{frame[:stream]}: #{payload}"
  341. end
  342. end
  343. 25 def on_frame_received(frame)
  344. 13862 log(level: 2) { "#{frame[:stream]}: frame was received!" }
  345. 13808 log(level: 2, color: :magenta) do
  346. payload =
  347. 54 case frame[:type]
  348. when :data
  349. 18 frame.merge(payload: frame[:payload].bytesize)
  350. when :headers, :ping
  351. 12 frame.merge(payload: log_redact(frame[:payload]))
  352. else
  353. 24 frame
  354. end
  355. 54 "#{frame[:stream]}: #{payload}"
  356. end
  357. end
  358. 25 def on_altsvc(origin, frame)
  359. log(level: 2) { "#{frame[:stream]}: altsvc frame was received" }
  360. log(level: 2) { "#{frame[:stream]}: #{log_redact(frame.inspect)}" }
  361. alt_origin = URI.parse("#{frame[:proto]}://#{frame[:host]}:#{frame[:port]}")
  362. params = { "ma" => frame[:max_age] }
  363. emit(:altsvc, origin, alt_origin, origin, params)
  364. end
  365. 25 def on_promise(stream)
  366. 18 emit(:promise, @streams.key(stream.parent), stream)
  367. end
  368. 25 def on_origin(origin)
  369. emit(:origin, origin)
  370. end
  371. 25 def on_pong(ping)
  372. 6 raise PingError unless @pings.delete(ping.to_s)
  373. 6 emit(:pong)
  374. end
  375. end
  376. end

lib/httpx/domain_name.rb

95.45% lines covered

44 relevant lines. 42 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. 25 require "ipaddr"
  28. 25 module HTTPX
  29. # Represents a domain name ready for extracting its registered domain
  30. # and TLD.
  31. 25 class DomainName
  32. 25 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. 25 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. 25 attr_reader :domain
  47. 25 class << self
  48. 25 def new(domain)
  49. 642 return domain if domain.is_a?(self)
  50. 594 super(domain)
  51. end
  52. # Normalizes a _domain_ using the Punycode algorithm as necessary.
  53. # The result will be a downcased, ASCII-only string.
  54. 25 def normalize(domain)
  55. 570 unless domain.ascii_only?
  56. domain = domain.chomp(".").unicode_normalize(:nfc)
  57. domain = Punycode.encode_hostname(domain)
  58. end
  59. 570 domain.downcase
  60. end
  61. end
  62. # Parses _hostname_ into a DomainName object. An IP address is also
  63. # accepted. An IPv6 address may be enclosed in square brackets.
  64. 25 def initialize(hostname)
  65. 594 hostname = String(hostname)
  66. 594 raise ArgumentError, "domain name must not start with a dot: #{hostname}" if hostname.start_with?(".")
  67. begin
  68. 594 @ipaddr = IPAddr.new(hostname)
  69. 24 @hostname = @ipaddr.to_s
  70. 24 return
  71. rescue IPAddr::Error
  72. 570 nil
  73. end
  74. 570 @hostname = DomainName.normalize(hostname)
  75. 570 tld = if (last_dot = @hostname.rindex("."))
  76. 138 @hostname[(last_dot + 1)..-1]
  77. else
  78. 432 @hostname
  79. end
  80. # unknown/local TLD
  81. 570 @domain = if last_dot
  82. # fallback - accept cookies down to second level
  83. # cf. http://www.dkim-reputation.org/regdom-libs/
  84. 138 if (penultimate_dot = @hostname.rindex(".", last_dot - 1))
  85. 36 @hostname[(penultimate_dot + 1)..-1]
  86. else
  87. 102 @hostname
  88. end
  89. else
  90. # no domain part - must be a local hostname
  91. 432 tld
  92. end
  93. end
  94. # Checks if the server represented by this domain is qualified to
  95. # send and receive cookies with a domain attribute value of
  96. # _domain_. A true value given as the second argument represents
  97. # cookies without a domain attribute value, in which case only
  98. # hostname equality is checked.
  99. 25 def cookie_domain?(domain, host_only = false)
  100. # RFC 6265 #5.3
  101. # When the user agent "receives a cookie":
  102. 24 return self == @domain if host_only
  103. 24 domain = DomainName.new(domain)
  104. # RFC 6265 #5.1.3
  105. # Do not perform subdomain matching against IP addresses.
  106. 24 @hostname == domain.hostname if @ipaddr
  107. # RFC 6265 #4.1.1
  108. # Domain-value must be a subdomain.
  109. 24 @domain && self <= domain && domain <= @domain
  110. end
  111. 25 def <=>(other)
  112. 36 other = DomainName.new(other)
  113. 36 othername = other.hostname
  114. 36 if othername == @hostname
  115. 12 0
  116. 24 elsif @hostname.end_with?(othername) && @hostname[-othername.size - 1, 1] == "."
  117. # The other is higher
  118. 12 -1
  119. else
  120. # The other is lower
  121. 12 1
  122. end
  123. end
  124. end
  125. end

lib/httpx/errors.rb

97.62% lines covered

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

lib/httpx/extensions.rb

67.86% lines covered

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

lib/httpx/headers.rb

100.0% lines covered

71 relevant lines. 71 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 25 module HTTPX
  3. 25 class Headers
  4. 25 class << self
  5. 25 def new(headers = nil)
  6. 20314 return headers if headers.is_a?(self)
  7. 9233 super
  8. end
  9. end
  10. 25 def initialize(headers = nil)
  11. 9233 if headers.nil? || headers.empty?
  12. 1458 @headers = headers.to_h
  13. 1458 return
  14. end
  15. 7775 @headers = {}
  16. 7775 headers.each do |field, value|
  17. 47982 field = downcased(field)
  18. 47982 value = array_value(value)
  19. 47982 current = @headers[field]
  20. 47982 if current.nil?
  21. 47941 @headers[field] = value
  22. else
  23. 41 current.concat(value)
  24. end
  25. end
  26. end
  27. # cloned initialization
  28. 25 def initialize_clone(orig, **kwargs)
  29. 6 super
  30. 6 @headers = orig.instance_variable_get(:@headers).clone(**kwargs)
  31. end
  32. # dupped initialization
  33. 25 def initialize_dup(orig)
  34. 12219 super
  35. 12219 @headers = orig.instance_variable_get(:@headers).dup
  36. end
  37. # freezes the headers hash
  38. 25 def freeze
  39. 15880 @headers.freeze
  40. 15880 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. 25 def merge(other)
  47. 3883 headers = dup
  48. 3883 other.each do |field, value|
  49. 3966 headers[downcased(field)] = value
  50. end
  51. 3883 headers
  52. end
  53. # returns the comma-separated values of the header field
  54. # identified by +field+, or nil otherwise.
  55. #
  56. 25 def [](field)
  57. 74663 a = @headers[downcased(field)] || return
  58. 22403 a.join(", ")
  59. end
  60. # sets +value+ (if not nil) as single value for the +field+ header.
  61. #
  62. 25 def []=(field, value)
  63. 33151 return unless value
  64. 33151 @headers[downcased(field)] = array_value(value)
  65. end
  66. # deletes all values associated with +field+ header.
  67. #
  68. 25 def delete(field)
  69. 210 canonical = downcased(field)
  70. 210 @headers.delete(canonical) if @headers.key?(canonical)
  71. end
  72. # adds additional +value+ to the existing, for header +field+.
  73. #
  74. 25 def add(field, value)
  75. 444 (@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. 25 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. 25 def each(extra_headers = nil)
  89. 53903 return enum_for(__method__, extra_headers) { @headers.size } unless block_given?
  90. 28690 @headers.each do |field, value|
  91. 39181 yield(field, value.join(", ")) unless value.empty?
  92. end
  93. 5080 extra_headers.each do |field, value|
  94. 19193 yield(field, value) unless value.empty?
  95. 28690 end if extra_headers
  96. end
  97. 25 def ==(other)
  98. 16750 other == to_hash
  99. end
  100. 25 def empty?
  101. 234 @headers.empty?
  102. end
  103. # the headers store in Hash format
  104. 25 def to_hash
  105. 18295 Hash[to_a]
  106. end
  107. 25 alias_method :to_h, :to_hash
  108. # the headers store in array of pairs format
  109. 25 def to_a
  110. 18312 Array(each)
  111. end
  112. # headers as string
  113. 25 def to_s
  114. 1626 @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. 25 def key?(downcased_key)
  127. 50795 @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. 25 def get(field)
  134. 227 @headers[field] || EMPTY
  135. end
  136. 25 private
  137. 25 def array_value(value)
  138. 81133 Array(value)
  139. end
  140. 25 def downcased(field)
  141. 160416 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. 25 require "socket"
  3. 25 require "httpx/io/udp"
  4. 25 require "httpx/io/tcp"
  5. 25 require "httpx/io/unix"
  6. begin
  7. 25 require "httpx/io/ssl"
  8. rescue LoadError
  9. end

lib/httpx/io/ssl.rb

88.89% lines covered

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

lib/httpx/io/tcp.rb

90.27% lines covered

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

lib/httpx/io/udp.rb

85.71% lines covered

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

lib/httpx/io/unix.rb

94.29% lines covered

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

lib/httpx/loggable.rb

100.0% lines covered

20 relevant lines. 20 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 25 module HTTPX
  3. 25 module Loggable
  4. 25 COLORS = {
  5. black: 30,
  6. red: 31,
  7. green: 32,
  8. yellow: 33,
  9. blue: 34,
  10. magenta: 35,
  11. cyan: 36,
  12. white: 37,
  13. }.freeze
  14. 25 USE_DEBUG_LOG = ENV.key?("HTTPX_DEBUG")
  15. 25 def log(
  16. level: @options.debug_level,
  17. color: nil,
  18. debug_level: @options.debug_level,
  19. debug: @options.debug,
  20. &msg
  21. )
  22. 10068993 return unless debug_level >= level
  23. 176917 debug_stream = debug || ($stderr if USE_DEBUG_LOG)
  24. 176917 return unless debug_stream
  25. 1929 klass = self.class
  26. 4242 until (class_name = klass.name)
  27. 384 klass = klass.superclass
  28. end
  29. 1929 message = +"(pid:#{Process.pid} tid:#{Thread.current.object_id}, self:#{class_name}##{object_id}) "
  30. 1929 message << msg.call << "\n"
  31. 1929 message = "\e[#{COLORS[color]}m#{message}\e[0m" if color && debug_stream.respond_to?(:isatty) && debug_stream.isatty
  32. 1929 debug_stream << message
  33. end
  34. 25 def log_exception(ex, level: @options.debug_level, color: nil, debug_level: @options.debug_level, debug: @options.debug)
  35. 974 log(level: level, color: color, debug_level: debug_level, debug: debug) { ex.full_message }
  36. end
  37. 25 def log_redact(text, should_redact = @options.debug_redact)
  38. 600 return text.to_s unless should_redact
  39. 84 "[REDACTED]"
  40. end
  41. end
  42. end

lib/httpx/options.rb

98.72% lines covered

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

lib/httpx/parser/http1.rb

100.0% lines covered

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

lib/httpx/plugins/auth.rb

100.0% lines covered

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

lib/httpx/plugins/auth/basic.rb

100.0% lines covered

10 relevant lines. 10 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 7 require "httpx/base64"
  3. 7 module HTTPX
  4. 7 module Plugins
  5. 7 module Authentication
  6. 7 class Basic
  7. 7 def initialize(user, password, **)
  8. 208 @user = user
  9. 208 @password = password
  10. end
  11. 7 def authenticate(*)
  12. 195 "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

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

lib/httpx/plugins/auth/ntlm.rb

100.0% lines covered

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

lib/httpx/plugins/auth/socks5.rb

100.0% lines covered

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

lib/httpx/plugins/aws_sdk_authentication.rb

100.0% lines covered

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

lib/httpx/plugins/aws_sigv4.rb

100.0% lines covered

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

lib/httpx/plugins/basic_auth.rb

100.0% lines covered

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

lib/httpx/plugins/brotli.rb

100.0% lines covered

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

lib/httpx/plugins/callbacks.rb

100.0% lines covered

53 relevant lines. 53 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 25 module HTTPX
  3. 25 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. 25 module Callbacks
  10. 25 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. 25 class CallbackError < Exception; end # rubocop:disable Lint/InheritException
  20. 25 module InstanceMethods
  21. 25 include HTTPX::Callbacks
  22. 25 CALLBACKS.each do |meth|
  23. 225 class_eval(<<-MOD, __FILE__, __LINE__ + 1)
  24. def on_#{meth}(&blk) # def on_connection_opened(&blk)
  25. on(:#{meth}, &blk) # on(:connection_opened, &blk)
  26. self # self
  27. end # end
  28. MOD
  29. end
  30. 25 private
  31. 25 def branch(options, &blk)
  32. 12 super(options).tap do |sess|
  33. 12 CALLBACKS.each do |cb|
  34. 108 next unless callbacks_for?(cb)
  35. 12 sess.callbacks(cb).concat(callbacks(cb))
  36. end
  37. 12 sess.wrap(&blk) if blk
  38. end
  39. end
  40. 25 def do_init_connection(connection, selector)
  41. 169 super
  42. 169 connection.on(:open) do
  43. 147 next unless connection.current_session == self
  44. 147 emit_or_callback_error(:connection_opened, connection.origin, connection.io.socket)
  45. end
  46. 169 connection.on(:close) do
  47. 159 next unless connection.current_session == self
  48. 159 emit_or_callback_error(:connection_closed, connection.origin) if connection.used?
  49. end
  50. 169 connection
  51. end
  52. 25 def set_request_callbacks(request)
  53. 171 super
  54. 171 request.on(:headers) do
  55. 135 emit_or_callback_error(:request_started, request)
  56. end
  57. 171 request.on(:body_chunk) do |chunk|
  58. 12 emit_or_callback_error(:request_body_chunk, request, chunk)
  59. end
  60. 171 request.on(:done) do
  61. 123 emit_or_callback_error(:request_completed, request)
  62. end
  63. 171 request.on(:response_started) do |res|
  64. 135 if res.is_a?(Response)
  65. 111 emit_or_callback_error(:response_started, request, res)
  66. 99 res.on(:chunk_received) do |chunk|
  67. 120 emit_or_callback_error(:response_body_chunk, request, res, chunk)
  68. end
  69. else
  70. 24 emit_or_callback_error(:request_error, request, res.error)
  71. end
  72. end
  73. 171 request.on(:response) do |res|
  74. 99 emit_or_callback_error(:response_completed, request, res)
  75. end
  76. end
  77. 25 def emit_or_callback_error(*args)
  78. 918 emit(*args)
  79. rescue StandardError => e
  80. 96 ex = CallbackError.new(e.message)
  81. 96 ex.set_backtrace(e.backtrace)
  82. 96 raise ex
  83. end
  84. 25 def receive_requests(*)
  85. 171 super
  86. rescue CallbackError => e
  87. 90 raise e.cause
  88. end
  89. 25 def close(*)
  90. 169 super
  91. rescue CallbackError => e
  92. 6 raise e.cause
  93. end
  94. end
  95. end
  96. 25 register_plugin :callbacks, Callbacks
  97. end
  98. end

lib/httpx/plugins/circuit_breaker.rb

100.0% lines covered

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

lib/httpx/plugins/circuit_breaker/circuit.rb

100.0% lines covered

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

lib/httpx/plugins/circuit_breaker/circuit_store.rb

100.0% lines covered

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

lib/httpx/plugins/content_digest.rb

100.0% lines covered

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

lib/httpx/plugins/cookies.rb

100.0% lines covered

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

lib/httpx/plugins/cookies/cookie.rb

100.0% lines covered

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

lib/httpx/plugins/cookies/jar.rb

100.0% lines covered

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

lib/httpx/plugins/cookies/set_cookie_parser.rb

100.0% lines covered

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

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

lib/httpx/plugins/expect.rb

100.0% lines covered

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

lib/httpx/plugins/follow_redirects.rb

100.0% lines covered

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

lib/httpx/plugins/grpc.rb

100.0% lines covered

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

lib/httpx/plugins/grpc/call.rb

90.91% lines covered

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

lib/httpx/plugins/grpc/grpc_encoding.rb

97.87% lines covered

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

lib/httpx/plugins/grpc/message.rb

95.83% lines covered

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

lib/httpx/plugins/h2c.rb

94.92% lines covered

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

lib/httpx/plugins/ntlm_auth.rb

100.0% lines covered

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

lib/httpx/plugins/oauth.rb

100.0% lines covered

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

lib/httpx/plugins/persistent.rb

100.0% lines covered

28 relevant lines. 28 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 15 module HTTPX
  3. 15 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. 15 module Persistent
  20. 15 def self.load_dependencies(klass)
  21. 424 max_retries = if klass.default_options.respond_to?(:max_retries)
  22. 6 [klass.default_options.max_retries, 1].max
  23. else
  24. 418 1
  25. end
  26. 424 klass.plugin(:retries, max_retries: max_retries)
  27. end
  28. 15 def self.extra_options(options)
  29. 424 options.merge(persistent: true)
  30. end
  31. 15 module InstanceMethods
  32. 15 private
  33. 15 def repeatable_request?(request, _)
  34. 439 super || begin
  35. 180 response = request.response
  36. 180 return false unless response && response.is_a?(ErrorResponse)
  37. 24 error = response.error
  38. 264 Retries::RECONNECTABLE_ERRORS.any? { |klass| error.is_a?(klass) }
  39. end
  40. end
  41. 15 def retryable_error?(ex)
  42. 64 super &&
  43. # under the persistent plugin rules, requests are only retried for connection related errors,
  44. # which do not include request timeout related errors. This only gets overriden if the end user
  45. # manually changed +:max_retries+ to something else, which means it is aware of the
  46. # consequences.
  47. 52 (!ex.is_a?(RequestTimeoutError) || @options.max_retries != 1)
  48. end
  49. 15 def get_current_selector
  50. 417 super(&nil) || begin
  51. 404 return unless block_given?
  52. 404 default = yield
  53. 404 set_current_selector(default)
  54. 404 default
  55. end
  56. end
  57. end
  58. end
  59. 15 register_plugin :persistent, Persistent
  60. end
  61. end

lib/httpx/plugins/proxy.rb

98.0% lines covered

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

lib/httpx/plugins/proxy/http.rb

100.0% lines covered

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

lib/httpx/plugins/proxy/socks4.rb

97.44% lines covered

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

lib/httpx/plugins/proxy/socks5.rb

99.11% lines covered

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

lib/httpx/plugins/proxy/ssh.rb

92.31% lines covered

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

lib/httpx/plugins/push_promise.rb

100.0% lines covered

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

lib/httpx/plugins/query.rb

100.0% lines covered

14 relevant lines. 14 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 6 module HTTPX
  3. 6 module Plugins
  4. #
  5. # This plugin adds support for using the experimental QUERY HTTP method
  6. #
  7. # https://gitlab.com/os85/httpx/wikis/Query
  8. 6 module Query
  9. 6 def self.subplugins
  10. {
  11. 18 retries: QueryRetries,
  12. }
  13. end
  14. 6 module InstanceMethods
  15. 6 def query(*uri, **options)
  16. 12 request("QUERY", uri, **options)
  17. end
  18. end
  19. 6 module QueryRetries
  20. 6 module InstanceMethods
  21. 6 private
  22. 6 def repeatable_request?(request, options)
  23. 18 super || request.verb == "QUERY"
  24. end
  25. end
  26. end
  27. end
  28. 6 register_plugin :query, Query
  29. end
  30. end

lib/httpx/plugins/rate_limiter.rb

100.0% lines covered

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

lib/httpx/plugins/response_cache.rb

100.0% lines covered

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

lib/httpx/plugins/response_cache/file_store.rb

100.0% lines covered

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

lib/httpx/plugins/response_cache/store.rb

100.0% lines covered

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

lib/httpx/plugins/retries.rb

96.84% lines covered

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

lib/httpx/plugins/ssrf_filter.rb

100.0% lines covered

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

lib/httpx/plugins/stream.rb

97.73% lines covered

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

lib/httpx/plugins/stream_bidi.rb

99.28% lines covered

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

lib/httpx/plugins/upgrade.rb

100.0% lines covered

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

lib/httpx/plugins/upgrade/h2.rb

91.67% lines covered

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

lib/httpx/plugins/webdav.rb

100.0% lines covered

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

lib/httpx/plugins/xml.rb

100.0% lines covered

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

lib/httpx/pmatch_extensions.rb

100.0% lines covered

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

lib/httpx/pool.rb

97.7% lines covered

87 relevant lines. 85 lines covered and 2 lines missed.
    
  1. # frozen_string_literal: true
  2. 25 require "httpx/selector"
  3. 25 require "httpx/connection"
  4. 25 require "httpx/resolver"
  5. 25 module HTTPX
  6. 25 class Pool
  7. 25 using ArrayExtensions::FilterMap
  8. 25 using URIExtensions
  9. 25 POOL_TIMEOUT = 5
  10. # Sets up the connection pool with the given +options+, which can be the following:
  11. #
  12. # :max_connections:: the maximum number of connections held in the pool.
  13. # :max_connections_per_origin :: the maximum number of connections held in the pool pointing to a given origin.
  14. # :pool_timeout :: the number of seconds to wait for a connection to a given origin (before raising HTTPX::PoolTimeoutError)
  15. #
  16. 25 def initialize(options)
  17. 8873 @max_connections = options.fetch(:max_connections, Float::INFINITY)
  18. 8873 @max_connections_per_origin = options.fetch(:max_connections_per_origin, Float::INFINITY)
  19. 8873 @pool_timeout = options.fetch(:pool_timeout, POOL_TIMEOUT)
  20. 14233 @resolvers = Hash.new { |hs, resolver_type| hs[resolver_type] = [] }
  21. 8873 @resolver_mtx = Thread::Mutex.new
  22. 8873 @connections = []
  23. 8873 @connection_mtx = Thread::Mutex.new
  24. 8873 @connections_counter = 0
  25. 8873 @max_connections_cond = ConditionVariable.new
  26. 8873 @origin_counters = Hash.new(0)
  27. 13609 @origin_conds = Hash.new { |hs, orig| hs[orig] = ConditionVariable.new }
  28. end
  29. # connections returned by this function are not expected to return to the connection pool.
  30. 25 def pop_connection
  31. 8964 @connection_mtx.synchronize do
  32. 8964 drop_connection
  33. end
  34. end
  35. # opens a connection to the IP reachable through +uri+.
  36. # Many hostnames are reachable through the same IP, so we try to
  37. # maximize pipelining by opening as few connections as possible.
  38. #
  39. 25 def checkout_connection(uri, options)
  40. 6287 return checkout_new_connection(uri, options) if options.io
  41. 6233 @connection_mtx.synchronize do
  42. 6233 acquire_connection(uri, options) || begin
  43. 5751 if @connections_counter == @max_connections
  44. # this takes precedence over per-origin
  45. 12 @max_connections_cond.wait(@connection_mtx, @pool_timeout)
  46. 12 acquire_connection(uri, options) || begin
  47. 8 if @connections_counter == @max_connections
  48. # if no matching usable connection was found, the pool will make room and drop a closed connection. if none is found,
  49. # this means that all of them are persistent or being used, so raise a timeout error.
  50. 6 conn = @connections.find { |c| c.state == :closed }
  51. raise PoolTimeoutError.new(@pool_timeout,
  52. 6 "Timed out after #{@pool_timeout} seconds while waiting for a connection") unless conn
  53. drop_connection(conn)
  54. end
  55. end
  56. end
  57. 5745 if @origin_counters[uri.origin] == @max_connections_per_origin
  58. 12 @origin_conds[uri.origin].wait(@connection_mtx, @pool_timeout)
  59. 12 return acquire_connection(uri, options) ||
  60. raise(PoolTimeoutError.new(@pool_timeout,
  61. "Timed out after #{@pool_timeout} seconds while waiting for a connection to #{uri.origin}"))
  62. end
  63. 5733 @connections_counter += 1
  64. 5733 @origin_counters[uri.origin] += 1
  65. 5733 checkout_new_connection(uri, options)
  66. end
  67. end
  68. end
  69. 25 def checkin_connection(connection)
  70. 6155 return if connection.options.io
  71. 6101 @connection_mtx.synchronize do
  72. 6101 @connections << connection
  73. 6101 @max_connections_cond.signal
  74. 6101 @origin_conds[connection.origin.to_s].signal
  75. end
  76. end
  77. 25 def checkout_mergeable_connection(connection)
  78. 5733 return if connection.options.io
  79. 5733 @connection_mtx.synchronize do
  80. 5733 idx = @connections.find_index do |ch|
  81. 180 ch != connection && ch.mergeable?(connection)
  82. end
  83. 5733 @connections.delete_at(idx) if idx
  84. end
  85. end
  86. 25 def reset_resolvers
  87. 11150 @resolver_mtx.synchronize { @resolvers.clear }
  88. end
  89. 25 def checkout_resolver(options)
  90. 5555 resolver_type = options.resolver_class
  91. 5555 resolver_type = Resolver.resolver_for(resolver_type)
  92. 5555 @resolver_mtx.synchronize do
  93. 5555 resolvers = @resolvers[resolver_type]
  94. 5555 idx = resolvers.find_index do |res|
  95. 26 res.options == options
  96. end
  97. 5555 resolvers.delete_at(idx) if idx
  98. end || checkout_new_resolver(resolver_type, options)
  99. end
  100. 25 def checkin_resolver(resolver)
  101. 308 @resolver_mtx.synchronize do
  102. 308 resolvers = @resolvers[resolver.class]
  103. 308 resolver = resolver.multi
  104. 308 resolvers << resolver unless resolvers.include?(resolver)
  105. end
  106. end
  107. skipped # :nocov:
  108. skipped def inspect
  109. skipped "#<#{self.class}:#{object_id} " \
  110. skipped "@max_connections_per_origin=#{@max_connections_per_origin} " \
  111. skipped "@pool_timeout=#{@pool_timeout} " \
  112. skipped "@connections=#{@connections.size}>"
  113. skipped end
  114. skipped # :nocov:
  115. 25 private
  116. 25 def acquire_connection(uri, options)
  117. 6257 idx = @connections.find_index do |connection|
  118. 654 connection.match?(uri, options)
  119. end
  120. 6257 return unless idx
  121. 492 @connections.delete_at(idx)
  122. end
  123. 25 def checkout_new_connection(uri, options)
  124. 5787 options.connection_class.new(uri, options)
  125. end
  126. 25 def checkout_new_resolver(resolver_type, options)
  127. 5533 if resolver_type.multi?
  128. 5508 Resolver::Multi.new(resolver_type, options)
  129. else
  130. 25 resolver_type.new(options)
  131. end
  132. end
  133. # drops and returns the +connection+ from the connection pool; if +connection+ is <tt>nil</tt> (default),
  134. # the first available connection from the pool will be dropped.
  135. 25 def drop_connection(connection = nil)
  136. 8964 if connection
  137. @connections.delete(connection)
  138. else
  139. 8964 connection = @connections.shift
  140. 8964 return unless connection
  141. end
  142. 3389 @connections_counter -= 1
  143. 3389 @origin_conds.delete(connection.origin) if (@origin_counters[connection.origin.to_s] -= 1).zero?
  144. 3389 connection
  145. end
  146. end
  147. 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. 25 module HTTPX
  3. 25 module Punycode
  4. 25 module_function
  5. begin
  6. 25 require "idnx"
  7. 24 def encode_hostname(hostname)
  8. 24 Idnx.to_punycode(hostname)
  9. end
  10. rescue LoadError
  11. 1 def encode_hostname(hostname)
  12. 1 warn "#{hostname} cannot be converted to punycode. Install the " \
  13. "\"idnx\" gem: https://github.com/HoneyryderChuck/idnx"
  14. 1 hostname
  15. end
  16. end
  17. end
  18. end

lib/httpx/request.rb

100.0% lines covered

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

lib/httpx/request/body.rb

100.0% lines covered

66 relevant lines. 66 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 25 module HTTPX
  3. # Implementation of the HTTP Request body as a delegator which iterates (responds to +each+) payload chunks.
  4. 25 class Request::Body < SimpleDelegator
  5. 25 class << self
  6. 25 def new(_, options, body: nil, **params)
  7. 8105 if body.is_a?(self)
  8. # request derives its options from body
  9. 12 body.options = options.merge(params)
  10. 12 return body
  11. end
  12. 8093 super
  13. end
  14. end
  15. 25 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. 25 def initialize(h, options, **params)
  25. 8093 @headers = h
  26. 8093 @body = self.class.initialize_body(params)
  27. 8093 @options = options.merge(params)
  28. 8093 if @body
  29. 2464 if @options.compress_request_body && @headers.key?("content-encoding")
  30. 78 @headers.get("content-encoding").each do |encoding|
  31. 78 @body = self.class.initialize_deflater_body(@body, encoding)
  32. end
  33. end
  34. 2464 @headers["content-type"] ||= @body.content_type
  35. 2464 @headers["content-length"] = @body.bytesize unless unbounded_body?
  36. end
  37. 8087 super(@body)
  38. end
  39. # consumes and yields the request payload in chunks.
  40. 25 def each(&block)
  41. 5084 return enum_for(__method__) unless block
  42. 2545 return if @body.nil?
  43. 2491 body = stream(@body)
  44. 2491 if body.respond_to?(:read)
  45. 4735 while (chunk = body.read(16_384))
  46. 2571 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. 1406 elsif body.respond_to?(:each)
  51. 396 body.each(&block)
  52. else
  53. 1010 block[body.to_s]
  54. end
  55. end
  56. 25 def close
  57. 362 @body.close if @body.respond_to?(:close)
  58. end
  59. # if the +@body+ is rewindable, it rewinnds it.
  60. 25 def rewind
  61. 618 return if empty?
  62. 132 @body.rewind if @body.respond_to?(:rewind)
  63. end
  64. # return +true+ if the +body+ has been fully drained (or does nnot exist).
  65. 25 def empty?
  66. 15176 return true if @body.nil?
  67. 6733 return false if chunked?
  68. 6661 @body.bytesize.zero?
  69. end
  70. # returns the +@body+ payload size in bytes.
  71. 25 def bytesize
  72. 2815 return 0 if @body.nil?
  73. 96 @body.bytesize
  74. end
  75. # sets the body to yield using chunked trannsfer encoding format.
  76. 25 def stream(body)
  77. 2491 return body unless chunked?
  78. 72 Transcoder::Chunker.encode(body.enum_for(:each))
  79. end
  80. # returns whether the body yields infinitely.
  81. 25 def unbounded_body?
  82. 2866 return @unbounded_body if defined?(@unbounded_body)
  83. 2518 @unbounded_body = !@body.nil? && (chunked? || @body.bytesize == Float::INFINITY)
  84. end
  85. # returns whether the chunked transfer encoding header is set.
  86. 25 def chunked?
  87. 15751 @headers["transfer-encoding"] == "chunked"
  88. end
  89. # sets the chunked transfer encoding header.
  90. 25 def chunk!
  91. 24 @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. 25 class << self
  100. 25 def initialize_body(params)
  101. 7973 if (body = params.delete(:body))
  102. # @type var body: bodyIO
  103. 1142 Transcoder::Body.encode(body)
  104. 6831 elsif (form = params.delete(:form))
  105. # @type var form: Transcoder::urlencoded_input
  106. 1139 Transcoder::Form.encode(form)
  107. 5692 elsif (json = params.delete(:json))
  108. # @type var body: _ToJson
  109. 63 Transcoder::JSON.encode(json)
  110. end
  111. end
  112. # returns the +body+ wrapped with the correct deflater accordinng to the given +encodisng+.
  113. 25 def initialize_deflater_body(body, encoding)
  114. 78 case encoding
  115. when "gzip"
  116. 42 Transcoder::GZIP.encode(body)
  117. when "deflate"
  118. 18 Transcoder::Deflate.encode(body)
  119. when "identity"
  120. 12 body
  121. else
  122. 6 body
  123. end
  124. end
  125. end
  126. end
  127. end

lib/httpx/resolver.rb

100.0% lines covered

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

lib/httpx/resolver/https.rb

86.01% lines covered

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

lib/httpx/resolver/multi.rb

88.24% lines covered

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

lib/httpx/resolver/native.rb

88.96% lines covered

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

lib/httpx/resolver/resolver.rb

83.95% lines covered

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

lib/httpx/resolver/system.rb

78.99% lines covered

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

lib/httpx/response.rb

100.0% lines covered

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

lib/httpx/response/body.rb

100.0% lines covered

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

lib/httpx/response/buffer.rb

91.67% lines covered

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

lib/httpx/selector.rb

93.4% lines covered

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

lib/httpx/session.rb

94.42% lines covered

269 relevant lines. 254 lines covered and 15 lines missed.
    
  1. # frozen_string_literal: true
  2. 25 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. 25 class Session
  8. 25 include Loggable
  9. 25 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. 25 def initialize(options = EMPTY_HASH, &blk)
  15. 8849 @options = self.class.default_options.merge(options)
  16. 8849 @persistent = @options.persistent
  17. 8849 @pool = @options.pool_class.new(@options.pool_options)
  18. 8849 @wrapped = false
  19. 8849 @closing = false
  20. 8849 INSTANCES[self] = self if @persistent && @options.close_on_fork && INSTANCES
  21. 8849 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. 25 def wrap
  29. 458 prev_wrapped = @wrapped
  30. 458 @wrapped = true
  31. 458 was_initialized = false
  32. 458 current_selector = get_current_selector do
  33. 458 selector = Selector.new
  34. 458 set_current_selector(selector)
  35. 458 was_initialized = true
  36. 458 selector
  37. end
  38. begin
  39. 458 yield self
  40. ensure
  41. 458 unless prev_wrapped
  42. 458 if @persistent
  43. 1 deactivate(current_selector)
  44. else
  45. 457 close(current_selector)
  46. end
  47. end
  48. 458 @wrapped = prev_wrapped
  49. 458 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. 25 def close(selector = Selector.new)
  58. # throw resolvers away from the pool
  59. 5575 @pool.reset_resolvers
  60. # preparing to throw away connections
  61. 14539 while (connection = @pool.pop_connection)
  62. 3389 next if connection.state == :closed
  63. 159 select_connection(connection, selector)
  64. end
  65. begin
  66. 5575 @closing = true
  67. 5575 selector.terminate
  68. ensure
  69. 5575 @closing = false
  70. end
  71. end
  72. # performs one, or multple requests; it accepts:
  73. #
  74. # 1. one or multiple HTTPX::Request objects;
  75. # 2. an HTTP verb, then a sequence of URIs or URI/options tuples;
  76. # 3. one or multiple HTTP verb / uri / (optional) options tuples;
  77. #
  78. # when present, the set of +options+ kwargs is applied to all of the
  79. # sent requests.
  80. #
  81. # respectively returns a single HTTPX::Response response, or all of them in an Array, in the same order.
  82. #
  83. # resp1 = session.request(req1)
  84. # resp1, resp2 = session.request(req1, req2)
  85. # resp1 = session.request("GET", "https://server.org/a")
  86. # resp1, resp2 = session.request("GET", ["https://server.org/a", "https://server.org/b"])
  87. # resp1, resp2 = session.request(["GET", "https://server.org/a"], ["GET", "https://server.org/b"])
  88. # resp1 = session.request("POST", "https://server.org/a", form: { "foo" => "bar" })
  89. # resp1, resp2 = session.request(["POST", "https://server.org/a", form: { "foo" => "bar" }], ["GET", "https://server.org/b"])
  90. # resp1, resp2 = session.request("GET", ["https://server.org/a", "https://server.org/b"], headers: { "x-api-token" => "TOKEN" })
  91. #
  92. 25 def request(*args, **params)
  93. 5845 raise ArgumentError, "must perform at least one request" if args.empty?
  94. 5845 requests = args.first.is_a?(Request) ? args : build_requests(*args, params)
  95. 5808 responses = send_requests(*requests)
  96. 5694 return responses.first if responses.size == 1
  97. 156 responses
  98. end
  99. # returns a HTTP::Request instance built from the HTTP +verb+, the request +uri+, and
  100. # the optional set of request-specific +options+. This request **must** be sent through
  101. # the same session it was built from.
  102. #
  103. # req = session.build_request("GET", "https://server.com")
  104. # resp = session.request(req)
  105. 25 def build_request(verb, uri, params = EMPTY_HASH, options = @options)
  106. 7274 rklass = options.request_class
  107. 7274 request = rklass.new(verb, uri, options, params)
  108. 7237 request.persistent = @persistent
  109. 7237 set_request_callbacks(request)
  110. 7237 request
  111. end
  112. 25 def select_connection(connection, selector)
  113. 6893 pin_connection(connection, selector)
  114. 6893 selector.register(connection)
  115. end
  116. 25 def pin_connection(connection, selector)
  117. 6909 connection.current_session = self
  118. 6909 connection.current_selector = selector
  119. end
  120. 25 alias_method :select_resolver, :select_connection
  121. 25 def deselect_connection(connection, selector, cloned = false)
  122. 6191 selector.deregister(connection)
  123. # when connections coalesce
  124. 6191 return if connection.state == :idle
  125. 6167 return if cloned
  126. 6161 return if @closing && connection.state == :closed
  127. 6155 @pool.checkin_connection(connection)
  128. end
  129. 25 def deselect_resolver(resolver, selector)
  130. 308 selector.deregister(resolver)
  131. 308 return if @closing && resolver.closed?
  132. 308 @pool.checkin_resolver(resolver)
  133. end
  134. 25 def try_clone_connection(connection, selector, family)
  135. 406 connection.family ||= family
  136. 406 return connection if connection.family == family
  137. new_connection = connection.class.new(connection.origin, connection.options)
  138. new_connection.family = family
  139. connection.sibling = new_connection
  140. do_init_connection(new_connection, selector)
  141. new_connection
  142. end
  143. # returns the HTTPX::Connection through which the +request+ should be sent through.
  144. 25 def find_connection(request_uri, selector, options)
  145. 7341 if (connection = selector.find_connection(request_uri, options))
  146. 1054 connection.idling if connection.state == :closed
  147. 1054 connection.log(level: 2) { "found connection##{connection.object_id}(#{connection.state}) in selector##{selector.object_id}" }
  148. 1054 return connection
  149. end
  150. 6287 connection = @pool.checkout_connection(request_uri, options)
  151. 6311 connection.log(level: 2) { "found connection##{connection.object_id}(#{connection.state}) in pool##{@pool.object_id}" }
  152. 6263 case connection.state
  153. when :idle
  154. 5721 do_init_connection(connection, selector)
  155. when :open
  156. 54 if options.io
  157. 54 select_connection(connection, selector)
  158. else
  159. pin_connection(connection, selector)
  160. end
  161. when :closing, :closed
  162. 472 connection.idling
  163. 472 select_connection(connection, selector)
  164. else
  165. 16 pin_connection(connection, selector)
  166. end
  167. 6208 connection
  168. end
  169. 25 private
  170. 25 def deactivate(selector)
  171. 418 selector.each_connection do |connection|
  172. 310 connection.deactivate
  173. 310 deselect_connection(connection, selector) if connection.state == :inactive
  174. end
  175. end
  176. # callback executed when an HTTP/2 promise frame has been received.
  177. 25 def on_promise(_, stream)
  178. 6 log(level: 2) { "#{stream.id}: refusing stream!" }
  179. 6 stream.refuse
  180. end
  181. # returns the corresponding HTTP::Response to the given +request+ if it has been received.
  182. 25 def fetch_response(request, _selector, _options)
  183. 9700106 response = request.response
  184. 9700106 response if response && response.finished?
  185. end
  186. # sends the +request+ to the corresponding HTTPX::Connection
  187. 25 def send_request(request, selector, options = request.options)
  188. error = begin
  189. 7268 catch(:resolve_error) do
  190. 7268 connection = find_connection(request.uri, selector, options)
  191. 7171 connection.send(request)
  192. end
  193. rescue StandardError => e
  194. 30 e
  195. end
  196. 7262 return unless error && error.is_a?(Exception)
  197. 97 raise error unless error.is_a?(Error)
  198. 97 response = ErrorResponse.new(request, error)
  199. 97 request.response = response
  200. 97 request.emit(:response, response)
  201. end
  202. # returns a set of HTTPX::Request objects built from the given +args+ and +options+.
  203. 25 def build_requests(*args, params)
  204. 5351 requests = if args.size == 1
  205. 60 reqs = args.first
  206. 60 reqs.map do |verb, uri, ps = EMPTY_HASH|
  207. 120 request_params = params
  208. 120 request_params = request_params.merge(ps) unless ps.empty?
  209. 120 build_request(verb, uri, request_params)
  210. end
  211. else
  212. 5291 verb, uris = args
  213. 5291 if uris.respond_to?(:each)
  214. 5111 uris.enum_for(:each).map do |uri, ps = EMPTY_HASH|
  215. 5834 request_params = params
  216. 5834 request_params = request_params.merge(ps) unless ps.empty?
  217. 5834 build_request(verb, uri, request_params)
  218. end
  219. else
  220. 180 [build_request(verb, uris, params)]
  221. end
  222. end
  223. 5314 raise ArgumentError, "wrong number of URIs (given 0, expect 1..+1)" if requests.empty?
  224. 5314 requests
  225. end
  226. 25 def set_request_callbacks(request)
  227. 7142 request.on(:promise, &method(:on_promise))
  228. end
  229. 25 def do_init_connection(connection, selector)
  230. 5721 resolve_connection(connection, selector) unless connection.family
  231. end
  232. # sends an array of HTTPX::Request +requests+, returns the respective array of HTTPX::Response objects.
  233. 25 def send_requests(*requests)
  234. 11192 selector = get_current_selector { Selector.new }
  235. begin
  236. 5882 _send_requests(requests, selector)
  237. 5876 receive_requests(requests, selector)
  238. ensure
  239. 5870 unless @wrapped
  240. 5310 if @persistent
  241. 417 deactivate(selector)
  242. else
  243. 4893 close(selector)
  244. end
  245. end
  246. end
  247. end
  248. # sends an array of HTTPX::Request objects
  249. 25 def _send_requests(requests, selector)
  250. 5882 requests.each do |request|
  251. 6654 send_request(request, selector)
  252. end
  253. end
  254. # returns the array of HTTPX::Response objects corresponding to the array of HTTPX::Request +requests+.
  255. 25 def receive_requests(requests, selector)
  256. # @type var responses: Array[response]
  257. 5876 responses = []
  258. # guarantee ordered responses
  259. 5876 loop do
  260. 6654 request = requests.first
  261. 6654 return responses unless request
  262. 9699203 catch(:coalesced) { selector.next_tick } until (response = fetch_response(request, selector, request.options))
  263. 6546 request.emit(:complete, response)
  264. 6546 responses << response
  265. 6546 requests.shift
  266. 6546 break if requests.empty?
  267. 778 next unless selector.empty?
  268. # in some cases, the pool of connections might have been drained because there was some
  269. # handshake error, and the error responses have already been emitted, but there was no
  270. # opportunity to traverse the requests, hence we're returning only a fraction of the errors
  271. # we were supposed to. This effectively fetches the existing responses and return them.
  272. while (request = requests.shift)
  273. response = fetch_response(request, selector, request.options)
  274. request.emit(:complete, response) if response
  275. responses << response
  276. end
  277. break
  278. end
  279. 5768 responses
  280. end
  281. 25 def resolve_connection(connection, selector)
  282. 5745 if connection.addresses || connection.open?
  283. #
  284. # there are two cases in which we want to activate initialization of
  285. # connection immediately:
  286. #
  287. # 1. when the connection already has addresses, i.e. it doesn't need to
  288. # resolve a name (not the same as name being an IP, yet)
  289. # 2. when the connection is initialized with an external already open IO.
  290. #
  291. 188 on_resolver_connection(connection, selector)
  292. 188 return
  293. end
  294. 5557 resolver = find_resolver_for(connection, selector)
  295. 5557 resolver.early_resolve(connection) || resolver.lazy_resolve(connection)
  296. end
  297. 25 def on_resolver_connection(connection, selector)
  298. 5760 from_pool = false
  299. 5760 found_connection = selector.find_mergeable_connection(connection) || begin
  300. 5733 from_pool = true
  301. 5733 @pool.checkout_mergeable_connection(connection)
  302. end
  303. 5760 return select_connection(connection, selector) unless found_connection
  304. 27 connection.log(level: 2) do
  305. "try coalescing from #{from_pool ? "pool##{@pool.object_id}" : "selector##{selector.object_id}"} " \
  306. "(conn##{found_connection.object_id}[#{found_connection.origin}])"
  307. end
  308. 27 coalesce_connections(found_connection, connection, selector, from_pool)
  309. end
  310. 25 def on_resolver_close(resolver, selector)
  311. 308 return if resolver.closed?
  312. 308 deselect_resolver(resolver, selector)
  313. 308 resolver.close unless resolver.closed?
  314. end
  315. 25 def find_resolver_for(connection, selector)
  316. 5557 resolver = selector.find_resolver(connection.options)
  317. 5557 unless resolver
  318. 5555 resolver = @pool.checkout_resolver(connection.options)
  319. 5555 resolver.current_session = self
  320. 5555 resolver.current_selector = selector
  321. end
  322. 5557 resolver
  323. end
  324. # coalesces +conn2+ into +conn1+. if +conn1+ was loaded from the connection pool
  325. # (it is known via +from_pool+), then it adds its to the +selector+.
  326. 25 def coalesce_connections(conn1, conn2, selector, from_pool)
  327. 27 unless conn1.coalescable?(conn2)
  328. 14 conn2.log(level: 2) { "not coalescing with conn##{conn1.object_id}[#{conn1.origin}])" }
  329. 14 select_connection(conn2, selector)
  330. 14 @pool.checkin_connection(conn1) if from_pool
  331. 14 return false
  332. end
  333. 13 conn2.log(level: 2) { "coalescing with conn##{conn1.object_id}[#{conn1.origin}])" }
  334. 13 conn2.coalesce!(conn1)
  335. 13 select_connection(conn1, selector) if from_pool
  336. 13 conn2.disconnect
  337. 13 true
  338. end
  339. 25 def get_current_selector
  340. 6340 selector_store[self] || (yield if block_given?)
  341. end
  342. 25 def set_current_selector(selector)
  343. 1320 if selector
  344. 862 selector_store[self] = selector
  345. else
  346. 458 selector_store.delete(self)
  347. end
  348. end
  349. 25 def selector_store
  350. 7660 th_current = Thread.current
  351. 7660 th_current.thread_variable_get(:httpx_persistent_selector_store) || begin
  352. 127 {}.compare_by_identity.tap do |store|
  353. 127 th_current.thread_variable_set(:httpx_persistent_selector_store, store)
  354. end
  355. end
  356. end
  357. 25 @default_options = Options.new
  358. 25 @default_options.freeze
  359. 25 @plugins = []
  360. 25 class << self
  361. 25 attr_reader :default_options
  362. 25 def inherited(klass)
  363. 4539 super
  364. 4539 klass.instance_variable_set(:@default_options, @default_options)
  365. 4539 klass.instance_variable_set(:@plugins, @plugins.dup)
  366. 4539 klass.instance_variable_set(:@callbacks, @callbacks.dup)
  367. end
  368. # returns a new HTTPX::Session instance, with the plugin pointed by +pl+ loaded.
  369. #
  370. # session_with_retries = session.plugin(:retries)
  371. # session_with_custom = session.plugin(CustomPlugin)
  372. #
  373. 25 def plugin(pl, options = nil, &block)
  374. 6106 label = pl
  375. # raise Error, "Cannot add a plugin to a frozen config" if frozen?
  376. 6106 pl = Plugins.load_plugin(pl) if pl.is_a?(Symbol)
  377. 6106 if !@plugins.include?(pl)
  378. 5882 @plugins << pl
  379. 5882 pl.load_dependencies(self, &block) if pl.respond_to?(:load_dependencies)
  380. 5882 @default_options = @default_options.dup
  381. 5882 include(pl::InstanceMethods) if defined?(pl::InstanceMethods)
  382. 5882 extend(pl::ClassMethods) if defined?(pl::ClassMethods)
  383. 5882 opts = @default_options
  384. 5882 opts.extend_with_plugin_classes(pl)
  385. 5882 if defined?(pl::OptionsMethods)
  386. 2344 (pl::OptionsMethods.instance_methods - Object.instance_methods).each do |meth|
  387. 7136 opts.options_class.method_added(meth)
  388. end
  389. 2344 @default_options = opts.options_class.new(opts)
  390. end
  391. 5882 @default_options = pl.extra_options(@default_options) if pl.respond_to?(:extra_options)
  392. 5882 @default_options = @default_options.merge(options) if options
  393. 5882 if pl.respond_to?(:subplugins)
  394. 24 pl.subplugins.transform_keys(&Plugins.method(:load_plugin)).each do |main_pl, sub_pl|
  395. # in case the main plugin has already been loaded, then apply subplugin functionality
  396. # immediately
  397. 24 next unless @plugins.include?(main_pl)
  398. 6 plugin(sub_pl, options, &block)
  399. end
  400. end
  401. 5882 pl.configure(self, &block) if pl.respond_to?(:configure)
  402. 5882 if label.is_a?(Symbol)
  403. # in case an already-loaded plugin complements functionality of
  404. # the plugin currently being loaded, loaded it now
  405. 4440 @plugins.each do |registered_pl|
  406. 10893 next if registered_pl == pl
  407. 6453 next unless registered_pl.respond_to?(:subplugins)
  408. 12 sub_pl = registered_pl.subplugins[label]
  409. 12 next unless sub_pl
  410. 12 plugin(sub_pl, options, &block)
  411. end
  412. end
  413. 5882 @default_options.freeze
  414. 5882 set_temporary_name("#{superclass}/#{pl}") if respond_to?(:set_temporary_name) # ruby 3.4 only
  415. 224 elsif options
  416. # this can happen when two plugins are loaded, an one of them calls the other under the hood,
  417. # albeit changing some default.
  418. 12 @default_options = pl.extra_options(@default_options) if pl.respond_to?(:extra_options)
  419. 12 @default_options = @default_options.merge(options) if options
  420. 12 @default_options.freeze
  421. end
  422. 6106 self
  423. end
  424. end
  425. # setup of the support for close_on_fork sessions.
  426. # adapted from https://github.com/mperham/connection_pool/blob/main/lib/connection_pool.rb#L48
  427. 25 if Process.respond_to?(:fork)
  428. 25 INSTANCES = ObjectSpace::WeakMap.new
  429. 25 private_constant :INSTANCES
  430. 25 def self.after_fork
  431. 1 INSTANCES.each_value(&:close)
  432. 1 nil
  433. end
  434. 25 if ::Process.respond_to?(:_fork)
  435. 21 module ForkTracker
  436. 21 def _fork
  437. 1 pid = super
  438. 1 Session.after_fork if pid.zero?
  439. 1 pid
  440. end
  441. end
  442. 21 Process.singleton_class.prepend(ForkTracker)
  443. end
  444. else
  445. INSTANCES = nil
  446. private_constant :INSTANCES
  447. def self.after_fork
  448. # noop
  449. end
  450. end
  451. end
  452. # session may be overridden by certain adapters.
  453. 25 S = Session
  454. end

lib/httpx/session_extensions.rb

100.0% lines covered

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

lib/httpx/timers.rb

93.94% lines covered

66 relevant lines. 62 lines covered and 4 lines missed.
    
  1. # frozen_string_literal: true
  2. 25 module HTTPX
  3. 25 class Timers
  4. 25 def initialize
  5. 5993 @intervals = []
  6. end
  7. 25 def after(interval_in_secs, cb = nil, &blk)
  8. 35407 callback = cb || blk
  9. 35407 raise Error, "timer must have a callback" unless callback
  10. # I'm assuming here that most requests will have the same
  11. # request timeout, as in most cases they share common set of
  12. # options. A user setting different request timeouts for 100s of
  13. # requests will already have a hard time dealing with that.
  14. 64530 unless (interval = @intervals.bsearch { |t| t.interval == interval_in_secs })
  15. 7446 interval = Interval.new(interval_in_secs)
  16. 7446 @intervals << interval
  17. 7446 @intervals.sort!
  18. end
  19. 35407 interval << callback
  20. 35407 @next_interval_at = nil
  21. 35407 Timer.new(interval, callback)
  22. end
  23. 25 def wait_interval
  24. 9695806 drop_elapsed!
  25. 9695806 return if @intervals.empty?
  26. 9672367 @next_interval_at = Utils.now
  27. 9672367 @intervals.first.interval
  28. end
  29. 25 def fire(error = nil)
  30. 9695686 raise error if error && error.timeout != @intervals.first
  31. 9695686 return if @intervals.empty? || !@next_interval_at
  32. 9671806 elapsed_time = Utils.elapsed_time(@next_interval_at)
  33. 9671806 drop_elapsed!(elapsed_time)
  34. 19337345 @intervals = @intervals.drop_while { |interval| interval.elapse(elapsed_time) <= 0 }
  35. 9671806 @next_interval_at = nil if @intervals.empty?
  36. end
  37. 25 private
  38. 25 def drop_elapsed!(elapsed_time = 0)
  39. # check first, if not elapsed, then return
  40. 19367612 first_interval = @intervals.first
  41. 19367612 return unless first_interval && first_interval.elapsed?(elapsed_time)
  42. # TODO: would be nice to have a drop_while!
  43. 14036 @intervals = @intervals.drop_while { |interval| interval.elapse(elapsed_time) <= 0 }
  44. end
  45. 25 class Timer
  46. 25 def initialize(interval, callback)
  47. 35407 @interval = interval
  48. 35407 @callback = callback
  49. end
  50. 25 def cancel
  51. 52183 @interval.delete(@callback)
  52. end
  53. end
  54. 25 class Interval
  55. 25 include Comparable
  56. 25 attr_reader :interval
  57. 25 def initialize(interval)
  58. 7446 @interval = interval
  59. 7446 @callbacks = []
  60. end
  61. 25 def <=>(other)
  62. 601 @interval <=> other.interval
  63. end
  64. 25 def ==(other)
  65. return @interval == other if other.is_a?(Numeric)
  66. @interval == other.to_f # rubocop:disable Lint/FloatComparison
  67. end
  68. 25 def to_f
  69. Float(@interval)
  70. end
  71. 25 def <<(callback)
  72. 35407 @callbacks << callback
  73. end
  74. 25 def delete(callback)
  75. 52183 @callbacks.delete(callback)
  76. end
  77. 25 def no_callbacks?
  78. @callbacks.empty?
  79. end
  80. 25 def elapsed?(elapsed = 0)
  81. 19344577 (@interval - elapsed) <= 0 || @callbacks.empty?
  82. end
  83. 25 def elapse(elapsed)
  84. # same as elapsing
  85. 9672794 return 0 if @callbacks.empty?
  86. 9666151 @interval -= elapsed
  87. 9666151 if @interval <= 0
  88. 511 cb = @callbacks.dup
  89. 511 cb.each(&:call)
  90. end
  91. 9666151 @interval
  92. end
  93. end
  94. 25 private_constant :Interval
  95. end
  96. end

lib/httpx/transcoder.rb

100.0% lines covered

52 relevant lines. 52 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 25 module HTTPX
  3. 25 module Transcoder
  4. 25 module_function
  5. 25 def normalize_keys(key, value, cond = nil, &block)
  6. 2585 if cond && cond.call(value)
  7. 809 block.call(key.to_s, value)
  8. 1776 elsif value.respond_to?(:to_ary)
  9. 342 if value.empty?
  10. 96 block.call("#{key}[]")
  11. else
  12. 246 value.to_ary.each do |element|
  13. 396 normalize_keys("#{key}[]", element, cond, &block)
  14. end
  15. end
  16. 1434 elsif value.respond_to?(:to_hash)
  17. 384 value.to_hash.each do |child_key, child_value|
  18. 384 normalize_keys("#{key}[#{child_key}]", child_value, cond, &block)
  19. end
  20. else
  21. 1050 block.call(key.to_s, value)
  22. end
  23. end
  24. # based on https://github.com/rack/rack/blob/d15dd728440710cfc35ed155d66a98dc2c07ae42/lib/rack/query_parser.rb#L82
  25. 25 def normalize_query(params, name, v, depth)
  26. 138 raise Error, "params depth surpasses what's supported" if depth <= 0
  27. 138 name =~ /\A[\[\]]*([^\[\]]+)\]*/
  28. 138 k = Regexp.last_match(1) || ""
  29. 138 after = Regexp.last_match ? Regexp.last_match.post_match : ""
  30. 138 if k.empty?
  31. 12 return Array(v) if !v.empty? && name == "[]"
  32. 6 return
  33. end
  34. 126 case after
  35. when ""
  36. 42 params[k] = v
  37. when "["
  38. 6 params[name] = v
  39. when "[]"
  40. 12 params[k] ||= []
  41. 12 raise Error, "expected Array (got #{params[k].class}) for param '#{k}'" unless params[k].is_a?(Array)
  42. 12 params[k] << v
  43. when /^\[\]\[([^\[\]]+)\]$/, /^\[\](.+)$/
  44. 24 child_key = Regexp.last_match(1)
  45. 24 params[k] ||= []
  46. 24 raise Error, "expected Array (got #{params[k].class}) for param '#{k}'" unless params[k].is_a?(Array)
  47. 24 if params[k].last.is_a?(Hash) && !params_hash_has_key?(params[k].last, child_key)
  48. 6 normalize_query(params[k].last, child_key, v, depth - 1)
  49. else
  50. 18 params[k] << normalize_query({}, child_key, v, depth - 1)
  51. end
  52. else
  53. 42 params[k] ||= {}
  54. 42 raise Error, "expected Hash (got #{params[k].class}) for param '#{k}'" unless params[k].is_a?(Hash)
  55. 42 params[k] = normalize_query(params[k], after, v, depth - 1)
  56. end
  57. 126 params
  58. end
  59. 25 def params_hash_has_key?(hash, key)
  60. 12 return false if key.include?("[]")
  61. 12 key.split(/[\[\]]+/).inject(hash) do |h, part|
  62. 12 next h if part == ""
  63. 12 return false unless h.is_a?(Hash) && h.key?(part)
  64. 6 h[part]
  65. end
  66. 6 true
  67. end
  68. end
  69. end
  70. 25 require "httpx/transcoder/body"
  71. 25 require "httpx/transcoder/form"
  72. 25 require "httpx/transcoder/json"
  73. 25 require "httpx/transcoder/chunker"
  74. 25 require "httpx/transcoder/deflate"
  75. 25 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. 25 require "delegate"
  3. 25 module HTTPX::Transcoder
  4. 25 module Body
  5. 25 class Error < HTTPX::Error; end
  6. 25 module_function
  7. 25 class Encoder < SimpleDelegator
  8. 25 def initialize(body)
  9. 1142 body = body.open(File::RDONLY, encoding: Encoding::BINARY) if Object.const_defined?(:Pathname) && body.is_a?(Pathname)
  10. 1142 @body = body
  11. 1142 super(body)
  12. end
  13. 25 def bytesize
  14. 4414 if @body.respond_to?(:bytesize)
  15. 1972 @body.bytesize
  16. 2442 elsif @body.respond_to?(:to_ary)
  17. 816 @body.sum(&:bytesize)
  18. 1626 elsif @body.respond_to?(:size)
  19. 1134 @body.size || Float::INFINITY
  20. 492 elsif @body.respond_to?(:length)
  21. 270 @body.length || Float::INFINITY
  22. 222 elsif @body.respond_to?(:each)
  23. 216 Float::INFINITY
  24. else
  25. 6 raise Error, "cannot determine size of body: #{@body.inspect}"
  26. end
  27. end
  28. 25 def content_type
  29. 1082 "application/octet-stream"
  30. end
  31. end
  32. 25 def encode(body)
  33. 1142 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. 25 require "forwardable"
  3. 25 module HTTPX::Transcoder
  4. 25 module Chunker
  5. 25 class Error < HTTPX::Error; end
  6. 25 CRLF = "\r\n".b
  7. 25 class Encoder
  8. 25 extend Forwardable
  9. 25 def initialize(body)
  10. 72 @raw = body
  11. end
  12. 25 def each
  13. 72 return enum_for(__method__) unless block_given?
  14. 72 @raw.each do |chunk|
  15. 336 yield "#{chunk.bytesize.to_s(16)}#{CRLF}#{chunk}#{CRLF}"
  16. end
  17. 72 yield "0#{CRLF}"
  18. end
  19. 25 def respond_to_missing?(meth, *args)
  20. 84 @raw.respond_to?(meth, *args) || super
  21. end
  22. end
  23. 25 class Decoder
  24. 25 extend Forwardable
  25. 25 def_delegator :@buffer, :empty?
  26. 25 def_delegator :@buffer, :<<
  27. 25 def_delegator :@buffer, :clear
  28. 25 def initialize(buffer, trailers = false)
  29. 86 @buffer = buffer
  30. 86 @chunk_buffer = "".b
  31. 86 @finished = false
  32. 86 @state = :length
  33. 86 @trailers = trailers
  34. end
  35. 25 def to_s
  36. 80 @buffer
  37. end
  38. 25 def each
  39. 153 loop do
  40. 896 case @state
  41. when :length
  42. 256 index = @buffer.index(CRLF)
  43. 256 return unless index && index.positive?
  44. # Read hex-length
  45. 256 hexlen = @buffer.byteslice(0, index)
  46. 256 @buffer = @buffer.byteslice(index..-1) || "".b
  47. 256 hexlen[/\h/] || raise(Error, "wrong chunk size line: #{hexlen}")
  48. 256 @chunk_length = hexlen.hex
  49. # check if is last chunk
  50. 256 @finished = @chunk_length.zero?
  51. 256 nextstate(:crlf)
  52. when :crlf
  53. 426 crlf_size = @finished && !@trailers ? 4 : 2
  54. # consume CRLF
  55. 426 return if @buffer.bytesize < crlf_size
  56. 426 raise Error, "wrong chunked encoding format" unless @buffer.start_with?(CRLF * (crlf_size / 2))
  57. 426 @buffer = @buffer.byteslice(crlf_size..-1)
  58. 426 if @chunk_length.nil?
  59. 170 nextstate(:length)
  60. else
  61. 256 return if @finished
  62. 176 nextstate(:data)
  63. end
  64. when :data
  65. 214 chunk = @buffer.byteslice(0, @chunk_length)
  66. 214 @buffer = @buffer.byteslice(@chunk_length..-1) || "".b
  67. 214 @chunk_buffer << chunk
  68. 214 @chunk_length -= chunk.bytesize
  69. 214 if @chunk_length.zero?
  70. 176 yield @chunk_buffer unless @chunk_buffer.empty?
  71. 170 @chunk_buffer.clear
  72. 170 @chunk_length = nil
  73. 170 nextstate(:crlf)
  74. end
  75. end
  76. 810 break if @buffer.empty?
  77. end
  78. end
  79. 25 def finished?
  80. 147 @finished
  81. end
  82. 25 private
  83. 25 def nextstate(state)
  84. 772 @state = state
  85. end
  86. end
  87. 25 module_function
  88. 25 def encode(chunks)
  89. 72 Encoder.new(chunks)
  90. end
  91. end
  92. end

lib/httpx/transcoder/deflate.rb

100.0% lines covered

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

lib/httpx/transcoder/form.rb

100.0% lines covered

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

lib/httpx/transcoder/gzip.rb

100.0% lines covered

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

lib/httpx/transcoder/json.rb

100.0% lines covered

33 relevant lines. 33 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 25 require "forwardable"
  3. 25 module HTTPX::Transcoder
  4. 25 module JSON
  5. 25 module_function
  6. 25 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. 25 class Encoder
  20. 25 extend Forwardable
  21. 25 def_delegator :@raw, :to_s
  22. 25 def_delegator :@raw, :bytesize
  23. 25 def_delegator :@raw, :==
  24. 25 def initialize(json)
  25. 63 @raw = JSON.json_dump(json)
  26. 63 @charset = @raw.encoding.name.downcase
  27. end
  28. 25 def content_type
  29. 63 "application/json; charset=#{@charset}"
  30. end
  31. end
  32. 25 def encode(json)
  33. 63 Encoder.new(json)
  34. end
  35. 25 def decode(response)
  36. 99 content_type = response.content_type.mime_type
  37. 99 raise HTTPX::Error, "invalid json mime type (#{content_type})" unless JSON_REGEX.match?(content_type)
  38. 87 method(:json_load)
  39. end
  40. # rubocop:disable Style/SingleLineMethods
  41. 25 if defined?(MultiJson)
  42. 4 def json_load(*args); MultiJson.load(*args); end
  43. 2 def json_dump(*args); MultiJson.dump(*args); end
  44. 24 elsif defined?(Oj)
  45. 5 def json_load(response, *args); Oj.load(response.to_s, *args); end
  46. 3 def json_dump(obj, options = {}); Oj.dump(obj, { mode: :compat }.merge(options)); end
  47. 22 elsif defined?(Yajl)
  48. 4 def json_load(response, *args); Yajl::Parser.new(*args).parse(response.to_s); end
  49. 2 def json_dump(*args); Yajl::Encoder.encode(*args); end
  50. else
  51. 21 require "json"
  52. 99 def json_load(*args); ::JSON.parse(*args); end
  53. 81 def json_dump(*args); ::JSON.dump(*args); end
  54. end
  55. # rubocop:enable Style/SingleLineMethods
  56. end
  57. end

lib/httpx/transcoder/multipart.rb

100.0% lines covered

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

lib/httpx/transcoder/multipart/decoder.rb

93.83% lines covered

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

lib/httpx/transcoder/multipart/encoder.rb

100.0% lines covered

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

lib/httpx/transcoder/multipart/mime_type_detector.rb

91.89% lines covered

37 relevant lines. 34 lines covered and 3 lines missed.
    
  1. # frozen_string_literal: true
  2. 25 module HTTPX
  3. 25 module Transcoder::Multipart
  4. 25 module MimeTypeDetector
  5. 25 module_function
  6. 25 DEFAULT_MIMETYPE = "application/octet-stream"
  7. # inspired by https://github.com/shrinerb/shrine/blob/master/lib/shrine/plugins/determine_mime_type.rb
  8. 25 if defined?(FileMagic)
  9. 1 MAGIC_NUMBER = 256 * 1024
  10. 1 def call(file, _)
  11. 1 return nil if file.eof? # FileMagic returns "application/x-empty" for empty files
  12. 1 mime = FileMagic.open(FileMagic::MAGIC_MIME_TYPE) do |filemagic|
  13. 1 filemagic.buffer(file.read(MAGIC_NUMBER))
  14. end
  15. 1 file.rewind
  16. 1 mime
  17. end
  18. 24 elsif defined?(Marcel)
  19. 1 def call(file, filename)
  20. 1 return nil if file.eof? # marcel returns "application/octet-stream" for empty files
  21. 1 Marcel::MimeType.for(file, name: filename)
  22. end
  23. 23 elsif defined?(MimeMagic)
  24. 1 def call(file, _)
  25. 1 mime = MimeMagic.by_magic(file)
  26. 1 mime.type if mime
  27. end
  28. 22 elsif system("which file", out: File::NULL)
  29. 22 require "open3"
  30. 22 def call(file, _)
  31. 517 return if file.eof? # file command returns "application/x-empty" for empty files
  32. 481 Open3.popen3(*%w[file --mime-type --brief -]) do |stdin, stdout, stderr, thread|
  33. begin
  34. 481 ::IO.copy_stream(file, stdin.binmode)
  35. rescue Errno::EPIPE
  36. end
  37. 481 file.rewind
  38. 481 stdin.close
  39. 481 status = thread.value
  40. # call to file command failed
  41. 481 if status.nil? || !status.success?
  42. $stderr.print(stderr.read)
  43. else
  44. 481 output = stdout.read.strip
  45. 481 if output.include?("cannot open")
  46. $stderr.print(output)
  47. else
  48. 481 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. 25 module HTTPX
  3. 25 module Transcoder::Multipart
  4. 25 module Part
  5. 25 module_function
  6. 25 def call(value)
  7. # take out specialized objects of the way
  8. 905 if value.respond_to?(:filename) && value.respond_to?(:content_type) && value.respond_to?(:read)
  9. 96 return value, value.content_type, value.filename
  10. end
  11. 809 content_type = filename = nil
  12. 809 if value.is_a?(Hash)
  13. 242 content_type = value[:content_type]
  14. 242 filename = value[:filename]
  15. 242 value = value[:body]
  16. end
  17. 809 value = value.open(File::RDONLY, encoding: Encoding::BINARY) if Object.const_defined?(:Pathname) && value.is_a?(Pathname)
  18. 809 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. 521 filename ||= File.basename(value.path)
  21. 521 content_type ||= MimeTypeDetector.call(value, filename) || "application/octet-stream"
  22. 521 [value, content_type, filename]
  23. else
  24. 288 [StringIO.new(value.to_s), content_type || "text/plain", filename]
  25. end
  26. end
  27. end
  28. end
  29. end

lib/httpx/transcoder/utils/body_reader.rb

92.0% lines covered

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

lib/httpx/transcoder/utils/deflater.rb

97.3% lines covered

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

lib/httpx/utils.rb

100.0% lines covered

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