httpx 1.7.8 → 1.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. checksums.yaml +4 -4
  2. data/doc/release_notes/1_7_1.md +1 -2
  3. data/doc/release_notes/1_8_0.md +100 -0
  4. data/doc/release_notes/1_8_1.md +27 -0
  5. data/lib/httpx/adapters/datadog.rb +12 -1
  6. data/lib/httpx/adapters/faraday.rb +2 -2
  7. data/lib/httpx/adapters/webmock.rb +6 -0
  8. data/lib/httpx/connection/http1.rb +15 -12
  9. data/lib/httpx/connection/http2.rb +69 -9
  10. data/lib/httpx/connection.rb +119 -21
  11. data/lib/httpx/errors.rb +8 -1
  12. data/lib/httpx/headers.rb +2 -2
  13. data/lib/httpx/io/ssl.rb +8 -9
  14. data/lib/httpx/io/tcp.rb +22 -5
  15. data/lib/httpx/io/unix.rb +2 -2
  16. data/lib/httpx/loggable.rb +11 -4
  17. data/lib/httpx/options.rb +16 -4
  18. data/lib/httpx/parser/http1.rb +8 -2
  19. data/lib/httpx/plugins/auth/digest.rb +1 -1
  20. data/lib/httpx/plugins/auth.rb +61 -4
  21. data/lib/httpx/plugins/{response_cache → cache}/file_store.rb +1 -1
  22. data/lib/httpx/plugins/{response_cache → cache}/store.rb +1 -1
  23. data/lib/httpx/plugins/cache.rb +221 -0
  24. data/lib/httpx/plugins/callbacks.rb +12 -0
  25. data/lib/httpx/plugins/digest_auth.rb +4 -0
  26. data/lib/httpx/plugins/fiber_concurrency.rb +75 -4
  27. data/lib/httpx/plugins/ntlm_v2_auth.rb +92 -0
  28. data/lib/httpx/plugins/oauth.rb +66 -14
  29. data/lib/httpx/plugins/persistent.rb +18 -2
  30. data/lib/httpx/plugins/proxy.rb +5 -0
  31. data/lib/httpx/plugins/push_promise.rb +2 -2
  32. data/lib/httpx/plugins/response_cache.rb +26 -105
  33. data/lib/httpx/plugins/retries.rb +7 -5
  34. data/lib/httpx/plugins/server_sent_events.rb +158 -0
  35. data/lib/httpx/plugins/ssrf_filter.rb +17 -2
  36. data/lib/httpx/plugins/stream.rb +9 -5
  37. data/lib/httpx/plugins/stream_bidi.rb +2 -0
  38. data/lib/httpx/plugins/tracing.rb +15 -4
  39. data/lib/httpx/pool.rb +2 -2
  40. data/lib/httpx/request/body.rb +2 -2
  41. data/lib/httpx/request.rb +42 -10
  42. data/lib/httpx/resolver/cache/base.rb +1 -8
  43. data/lib/httpx/resolver/cache/file.rb +56 -0
  44. data/lib/httpx/resolver/https.rb +20 -20
  45. data/lib/httpx/resolver/native.rb +21 -3
  46. data/lib/httpx/resolver/resolver.rb +4 -0
  47. data/lib/httpx/resolver/system.rb +5 -2
  48. data/lib/httpx/response/body.rb +6 -4
  49. data/lib/httpx/response.rb +12 -5
  50. data/lib/httpx/selector.rb +12 -1
  51. data/lib/httpx/session.rb +6 -1
  52. data/lib/httpx/session_extensions.rb +2 -2
  53. data/lib/httpx/timers.rb +3 -0
  54. data/lib/httpx/version.rb +1 -1
  55. data/sig/chainable.rbs +3 -0
  56. data/sig/connection/http1.rbs +1 -1
  57. data/sig/connection/http2.rbs +4 -1
  58. data/sig/connection.rbs +16 -9
  59. data/sig/errors.rbs +9 -3
  60. data/sig/httpx.rbs +2 -0
  61. data/sig/io/tcp.rbs +2 -0
  62. data/sig/loggable.rbs +8 -0
  63. data/sig/options.rbs +25 -12
  64. data/sig/parser/http1.rbs +3 -1
  65. data/sig/plugins/auth/ntlm.rbs +1 -1
  66. data/sig/plugins/auth.rbs +4 -0
  67. data/sig/plugins/{response_cache → cache}/file_store.rbs +2 -2
  68. data/sig/plugins/{response_cache → cache}/store.rbs +2 -2
  69. data/sig/plugins/cache.rbs +69 -0
  70. data/sig/plugins/fiber_concurrency.rbs +4 -0
  71. data/sig/plugins/ntlm_v2_auth.rbs +36 -0
  72. data/sig/plugins/persistent.rbs +3 -0
  73. data/sig/plugins/response_cache.rbs +13 -38
  74. data/sig/plugins/retries.rbs +5 -5
  75. data/sig/plugins/server_sent_events.rbs +45 -0
  76. data/sig/plugins/ssrf_filter.rbs +5 -1
  77. data/sig/plugins/stream.rbs +1 -1
  78. data/sig/plugins/stream_bidi.rbs +0 -2
  79. data/sig/plugins/webdav.rbs +1 -1
  80. data/sig/pool.rbs +2 -2
  81. data/sig/request.rbs +9 -6
  82. data/sig/resolver/cache/file.rbs +13 -0
  83. data/sig/resolver/entry.rbs +1 -1
  84. data/sig/resolver/https.rbs +3 -3
  85. data/sig/resolver/multi.rbs +1 -1
  86. data/sig/resolver/native.rbs +5 -5
  87. data/sig/resolver/resolver.rbs +1 -3
  88. data/sig/resolver/system.rbs +5 -2
  89. data/sig/resolver.rbs +3 -0
  90. data/sig/response.rbs +3 -0
  91. data/sig/selector.rbs +13 -8
  92. data/sig/timers.rbs +7 -5
  93. data/sig/transcoder/body.rbs +1 -1
  94. data/sig/transcoder/gzip.rbs +3 -2
  95. data/sig/transcoder/multipart.rbs +4 -1
  96. data/sig/transcoder/utils/deflater.rbs +2 -0
  97. data/sig/transcoder.rbs +2 -0
  98. data/sig/utils.rbs +1 -1
  99. metadata +19 -7
@@ -12,6 +12,7 @@ module HTTPX
12
12
  module OAuth
13
13
  class << self
14
14
  def load_dependencies(klass)
15
+ require "monitor"
15
16
  require_relative "auth/basic"
16
17
  klass.plugin(:auth)
17
18
  end
@@ -33,8 +34,6 @@ module HTTPX
33
34
  # Implements the bulk of functionality and maintains the state associated with the
34
35
  # management of the the lifecycle of an OAuth session.
35
36
  class OAuthSession
36
- attr_reader :access_token, :refresh_token
37
-
38
37
  def initialize(
39
38
  issuer:,
40
39
  client_id:,
@@ -62,8 +61,8 @@ module HTTPX
62
61
  @refresh_token = refresh_token
63
62
  @token_endpoint_auth_method = String(token_endpoint_auth_method) if token_endpoint_auth_method
64
63
  @grant_type = grant_type || (@refresh_token ? "refresh_token" : "client_credentials")
65
- @access_token = access_token
66
- @refresh_token = refresh_token
64
+ @expires_at = nil
65
+ @token_mon = Monitor.new
67
66
 
68
67
  unless @token_endpoint_auth_method.nil? || SUPPORTED_AUTH_METHODS.include?(@token_endpoint_auth_method)
69
68
  raise Error, "#{@token_endpoint_auth_method} is not a supported auth method"
@@ -84,8 +83,24 @@ module HTTPX
84
83
  @token_endpoint_auth_method || "client_secret_basic"
85
84
  end
86
85
 
86
+ def expires_at
87
+ @token_mon.synchronize { @expires_at }
88
+ end
89
+
90
+ def access_token
91
+ @token_mon.synchronize do
92
+ if (expires_at = @expires_at) && expires_at < Time.now.to_i
93
+ reset!
94
+ end
95
+
96
+ @access_token
97
+ end
98
+ end
99
+
87
100
  def reset!
88
- @access_token = nil
101
+ @token_mon.synchronize do
102
+ @access_token = @expires_at = nil
103
+ end
89
104
  end
90
105
 
91
106
  # when not available, it uses the +http+ object to request new access and refresh tokens.
@@ -117,9 +132,11 @@ module HTTPX
117
132
  when "client_credentials"
118
133
  # do nothing
119
134
  when "refresh_token"
120
- raise Error, "cannot use the `\"refresh_token\"` grant type without a refresh token" unless refresh_token
135
+ ref_token = refresh_token
121
136
 
122
- form_post["refresh_token"] = refresh_token
137
+ raise Error, "cannot use the `\"refresh_token\"` grant type without a refresh token" unless ref_token
138
+
139
+ form_post["refresh_token"] = ref_token
123
140
  end
124
141
 
125
142
  # POST /token
@@ -138,8 +155,13 @@ module HTTPX
138
155
 
139
156
  payload = token_response.json
140
157
 
141
- @refresh_token = payload["refresh_token"] || @refresh_token
142
- @access_token = payload["access_token"]
158
+ @token_mon.synchronize do
159
+ @refresh_token = payload.fetch("refresh_token", @refresh_token)
160
+ if (expires_in = payload["expires_in"])
161
+ @expires_at = Time.now.to_i + Integer(expires_in)
162
+ end
163
+ @access_token = payload["access_token"]
164
+ end
143
165
  end
144
166
 
145
167
  # TODO: remove this after deprecating the `:oauth_session` option
@@ -164,17 +186,23 @@ module HTTPX
164
186
 
165
187
  private
166
188
 
189
+ def refresh_token
190
+ @token_mon.synchronize { @refresh_token }
191
+ end
192
+
167
193
  # uses +http+ to fetch for the oauth server metadata.
168
194
  def load(http)
169
195
  return if @grant_type && @scope
170
196
 
171
197
  metadata = http.skip_auth_header { http.get("#{@issuer}/.well-known/oauth-authorization-server").raise_for_status.json }
172
198
 
173
- @token_endpoint = metadata["token_endpoint"]
174
- @scope = metadata["scopes_supported"]
175
- @grant_type = Array(metadata["grant_types_supported"]).find { |gr| SUPPORTED_GRANT_TYPES.include?(gr) }
176
- @token_endpoint_auth_method = Array(metadata["token_endpoint_auth_methods_supported"]).find do |am|
177
- SUPPORTED_AUTH_METHODS.include?(am)
199
+ @token_mon.synchronize do
200
+ @token_endpoint = metadata["token_endpoint"]
201
+ @scope = metadata["scopes_supported"]
202
+ @grant_type = Array(metadata["grant_types_supported"]).find { |gr| SUPPORTED_GRANT_TYPES.include?(gr) }
203
+ @token_endpoint_auth_method = Array(metadata["token_endpoint_auth_methods_supported"]).find do |am|
204
+ SUPPORTED_AUTH_METHODS.include?(am)
205
+ end
178
206
  end
179
207
  nil
180
208
  end
@@ -239,6 +267,11 @@ module HTTPX
239
267
 
240
268
  @oauth_session.reset!
241
269
  @oauth_session.fetch_access_token(self)
270
+ if (expires_at = @oauth_session.expires_at)
271
+ @auth_header_value_mtx.synchronize do
272
+ @auth_header_expires_at = expires_at
273
+ end
274
+ end
242
275
  end
243
276
 
244
277
  # TODO: deprecate
@@ -249,9 +282,18 @@ module HTTPX
249
282
  other_session = dup # : instance
250
283
  oauth_session = other_session.oauth_session
251
284
  oauth_session.fetch_access_token(other_session)
285
+ if (expires_at = oauth_session.expires_at)
286
+ @auth_header_expires_at = expires_at
287
+ end
252
288
  other_session
253
289
  end
254
290
 
291
+ def reset_auth_header_value!
292
+ super.tap do
293
+ @oauth_session.reset if @oauth_session
294
+ end
295
+ end
296
+
255
297
  private
256
298
 
257
299
  def generate_auth_token
@@ -260,6 +302,16 @@ module HTTPX
260
302
  @oauth_session.fetch_access_token(self)
261
303
  end
262
304
 
305
+ def set_auth_header_expires_at(_)
306
+ return super unless @oauth_session
307
+
308
+ expires_at = @oauth_session.expires_at
309
+
310
+ return super unless expires_at
311
+
312
+ @auth_header_expires_at = expires_at
313
+ end
314
+
263
315
  def dynamic_auth_token?(_)
264
316
  @oauth_session
265
317
  end
@@ -55,13 +55,29 @@ module HTTPX
55
55
 
56
56
  private
57
57
 
58
+ def reconnectable_error?(error)
59
+ Retries::RECONNECTABLE_ERRORS.any? { |klass| error.is_a?(klass) }
60
+ end
61
+
62
+ def when_to_retry(request, response, *)
63
+ return super unless response.is_a?(ErrorResponse)
64
+
65
+ error = response.error
66
+ # allow request to be retried immediately if the request failed right after the keep alive timeout.
67
+ # the chances are, the request failed because the connect has been dropped by the peer server, so it's
68
+ # fine to reopen.
69
+ return if request.ping? && reconnectable_error?(error)
70
+
71
+ super
72
+ end
73
+
58
74
  def retryable_request?(request, response, *)
59
75
  super || begin
60
- return false unless response && response.is_a?(ErrorResponse)
76
+ return false unless response.is_a?(ErrorResponse)
61
77
 
62
78
  error = response.error
63
79
 
64
- Retries::RECONNECTABLE_ERRORS.any? { |klass| error.is_a?(klass) }
80
+ reconnectable_error?(error)
65
81
  end
66
82
  end
67
83
 
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "cgi"
4
+
3
5
  module HTTPX
4
6
  class ProxyError < ConnectionError; end
5
7
 
@@ -65,6 +67,9 @@ module HTTPX
65
67
 
66
68
  return unless @scheme
67
69
 
70
+ @username = CGI.unescape(@username) if @username
71
+ @password = CGI.unescape(@password) if @password
72
+
68
73
  @authenticator = load_authenticator(@scheme, @username, @password, **extra)
69
74
  end
70
75
 
@@ -44,9 +44,9 @@ module HTTPX
44
44
 
45
45
  def __on_promise_request(parser, stream, h)
46
46
  log(level: 1, color: :yellow) do
47
- # :nocov:
47
+ # simplecov:disable
48
48
  h.map { |k, v| "#{stream.id}: -> PROMISE HEADER: #{k}: #{v}" }.join("\n")
49
- # :nocov:
49
+ # simplecov:enable
50
50
  end
51
51
  headers = @options.headers_class.new(h)
52
52
  path = headers[":path"]
@@ -3,7 +3,8 @@
3
3
  module HTTPX
4
4
  module Plugins
5
5
  #
6
- # This plugin adds support for retrying requests when certain errors happen.
6
+ # This plugin caches and reuses responses based on HTTP caching directives defined by
7
+ # the [HTTP Caching RFC](https://www.rfc-editor.org/rfc/rfc9111.html)
7
8
  #
8
9
  # https://gitlab.com/os85/httpx/wikis/Response-Cache
9
10
  #
@@ -15,9 +16,8 @@ module HTTPX
15
16
  private_constant :CACHEABLE_STATUS_CODES
16
17
 
17
18
  class << self
18
- def load_dependencies(*)
19
- require_relative "response_cache/store"
20
- require_relative "response_cache/file_store"
19
+ def load_dependencies(klass)
20
+ klass.plugin(:cache)
21
21
  end
22
22
 
23
23
  # whether the +response+ can be stored in the response cache.
@@ -47,7 +47,6 @@ module HTTPX
47
47
  def extra_options(options)
48
48
  options.merge(
49
49
  supported_vary_headers: SUPPORTED_VARY_HEADERS,
50
- response_cache_store: :store,
51
50
  )
52
51
  end
53
52
  end
@@ -56,58 +55,18 @@ module HTTPX
56
55
  #
57
56
  # :supported_vary_headers :: array of header values that will be considered for a "vary" header based cache validation
58
57
  # (defaults to {SUPPORTED_VARY_HEADERS}).
59
- # :response_cache_store :: object where cached responses are fetch from or stored in; defaults to <tt>:store</tt> (in-memory
60
- # cache), can be set to <tt>:file_store</tt> (file system cache store) as well, or any object which
61
- # abides by the Cache Store Interface
62
- #
63
- # The Cache Store Interface requires implementation of the following methods:
64
- #
65
- # * +#get(request) -> response or nil+
66
- # * +#set(request, response) -> void+
67
- # * +#clear() -> void+)
68
58
  #
69
59
  module OptionsMethods
70
60
  private
71
61
 
72
- def option_response_cache_store(value)
73
- case value
74
- when :store
75
- Store.new
76
- when :file_store
77
- FileStore.new
78
- else
79
- value
80
- end
81
- end
82
-
83
62
  def option_supported_vary_headers(value)
84
63
  Array(value).sort
85
64
  end
86
65
  end
87
66
 
88
67
  module InstanceMethods
89
- # wipes out all cached responses from the cache store.
90
- def clear_response_cache
91
- @options.response_cache_store.clear
92
- end
93
-
94
- def build_request(*)
95
- request = super
96
- return request unless cacheable_request?(request)
97
-
98
- prepare_cache(request)
99
-
100
- request
101
- end
102
-
103
68
  private
104
69
 
105
- def send_request(request, *)
106
- return request if request.response
107
-
108
- super
109
- end
110
-
111
70
  def fetch_response(request, *)
112
71
  response = super
113
72
 
@@ -117,11 +76,7 @@ module HTTPX
117
76
  log { "returning cached response for #{request.uri}" }
118
77
 
119
78
  response.copy_from_cached!
120
- elsif request.cacheable_verb? && ResponseCache.cacheable_response?(response)
121
- unless response.cached?
122
- log { "caching response for #{request.uri}..." }
123
- request.options.response_cache_store.set(request, response)
124
- end
79
+
125
80
  end
126
81
 
127
82
  response
@@ -130,21 +85,13 @@ module HTTPX
130
85
  # will either assign a still-fresh cached response to +request+, or set up its HTTP
131
86
  # cache invalidation headers in case it's not fresh anymore.
132
87
  def prepare_cache(request)
133
- cached_response = request.options.response_cache_store.get(request)
88
+ super
134
89
 
135
- return unless cached_response && match_by_vary?(request, cached_response)
90
+ return if request.response # already cached
136
91
 
137
- cached_response.body.rewind
92
+ cached_response = retrieve_cached_response(request)
138
93
 
139
- if cached_response.fresh?
140
- cached_response = cached_response.dup
141
- cached_response.mark_as_cached!
142
- request.response = cached_response
143
- request.emit_response(cached_response)
144
- return
145
- end
146
-
147
- request.cached_response = cached_response
94
+ return unless cached_response && match_by_vary?(request, cached_response)
148
95
 
149
96
  if !request.headers.key?("if-modified-since") && (last_modified = cached_response.headers["last-modified"])
150
97
  request.headers.add("if-modified-since", last_modified)
@@ -156,20 +103,31 @@ module HTTPX
156
103
  end
157
104
 
158
105
  def cacheable_request?(request)
159
- request.cacheable_verb? &&
106
+ (
107
+ request.cacheable_verb? &&
160
108
  (
161
109
  !request.headers.key?("cache-control") || !request.headers.get("cache-control").include?("no-store")
162
110
  )
111
+ ) || super
163
112
  end
164
113
 
165
- # whether the +response+ complies with the directives set by the +request+ "vary" header
114
+ def cacheable_response?(_, response)
115
+ ResponseCache.cacheable_response?(response) || super
116
+ end
117
+
118
+ # +cached_response+ is still valid if it's still fresh
119
+ def valid_cached_response?(_, cached_response)
120
+ cached_response.fresh?
121
+ end
122
+
123
+ # whether the +cached_response+ complies with the directives set by the +request+ "vary" header
166
124
  # (true when none is available).
167
- def match_by_vary?(request, response)
168
- vary = response.vary
125
+ def match_by_vary?(request, cached_response)
126
+ vary = cached_response.vary
169
127
 
170
128
  return true unless vary
171
129
 
172
- original_request = response.original_request
130
+ original_request = cached_response.original_request
173
131
 
174
132
  if vary == %w[*]
175
133
  request.options.supported_vary_headers.each do |field|
@@ -186,19 +144,6 @@ module HTTPX
186
144
  end
187
145
 
188
146
  module RequestMethods
189
- # points to a previously cached Response corresponding to this request.
190
- attr_accessor :cached_response
191
-
192
- def initialize(*)
193
- super
194
- @cached_response = nil
195
- end
196
-
197
- def merge_headers(*)
198
- super
199
- @response_cache_key = nil
200
- end
201
-
202
147
  # returns whether this request is cacheable as per HTTP caching rules.
203
148
  def cacheable_verb?
204
149
  CACHEABLE_VERBS.include?(@verb)
@@ -220,29 +165,13 @@ module HTTPX
220
165
  end
221
166
 
222
167
  module ResponseMethods
223
- attr_writer :original_request, :revalidated_at
168
+ attr_writer :revalidated_at
224
169
 
225
170
  def initialize(*)
226
171
  super
227
- @cached = false
228
172
  @revalidated_at = nil
229
173
  end
230
174
 
231
- # a copy of the request this response was originally cached from
232
- def original_request
233
- @original_request || @request
234
- end
235
-
236
- # whether this Response was duplicated from a previously {RequestMethods#cached_response}.
237
- def cached?
238
- @cached
239
- end
240
-
241
- # sets this Response as being duplicated from a previously cached response.
242
- def mark_as_cached!
243
- @cached = true
244
- end
245
-
246
175
  # eager-copies the response headers and body from {RequestMethods#cached_response}.
247
176
  def copy_from_cached!
248
177
  cached_response = @request.cached_response
@@ -333,14 +262,6 @@ module HTTPX
333
262
  Time.now
334
263
  end
335
264
  end
336
-
337
- module ResponseBodyMethods
338
- def decode_chunk(chunk)
339
- return chunk if @response.cached?
340
-
341
- super
342
- end
343
- end
344
265
  end
345
266
  register_plugin :response_cache, ResponseCache
346
267
  end
@@ -53,16 +53,18 @@ module HTTPX
53
53
  end
54
54
  end
55
55
 
56
- # returns the time to wait before resending +request+ as per the polynomial backoff retry strategy.
56
+ # returns the time to wait before resending +request+ as per the polynomial backoff retry strategy,
57
+ # where base is 1 and exponent is 2.
57
58
  def retry_after_polynomial_backoff(request, _)
58
59
  offset = request.options.max_retries - request.retries
59
- 2 * (offset - 1)
60
+ 1 * ((offset - 1)**2)
60
61
  end
61
62
 
62
- # returns the time to wait before resending +request+ as per the exponential backoff retry strategy.
63
+ # returns the time to wait before resending +request+ as per the exponential backoff retry strategy,
64
+ # where base is 2
63
65
  def retry_after_exponential_backoff(request, _)
64
66
  offset = request.options.max_retries - request.retries
65
- (offset - 1) * 2
67
+ 2**(offset - 1)
66
68
  end
67
69
  end
68
70
 
@@ -186,7 +188,7 @@ module HTTPX
186
188
 
187
189
  # returns whether the +ex+ exception happend for a retriable request.
188
190
  def retryable_error?(ex, _)
189
- RETRYABLE_ERRORS.any? { |klass| ex.is_a?(klass) }
191
+ RETRYABLE_ERRORS.any? { |klass| ex.is_a?(klass) } && !ex.is_a?(TotalRequestTimeoutError)
190
192
  end
191
193
 
192
194
  def proxy_error?(request, response, _)
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HTTPX
4
+ module Plugins
5
+ #
6
+ # This plugin implements convenience methods for Server Sent Events streams.
7
+ #
8
+ # https://gitlab.com/os85/httpx/wikis/Server-Sent-Events
9
+ #
10
+ module ServerSentEvents
11
+ Message = if RUBY_VERSION >= "3.2.0" # rubocop:disable Naming/ConstantName
12
+ Data.define(:data, :event, :id, :retry_after) do
13
+ def initialize(event: nil, id: nil, retry_after: nil, **kwargs)
14
+ super
15
+ end
16
+ end
17
+ else
18
+ Struct.new(:data, :event, :id, :retry_after, keyword_init: true)
19
+ end
20
+
21
+ class << self
22
+ def subplugins
23
+ {
24
+ retries: ServerSentEventsRetries,
25
+ }
26
+ end
27
+
28
+ def load_dependencies(klass)
29
+ klass.plugin(:stream)
30
+ end
31
+ end
32
+
33
+ # adds support for the following options:
34
+ #
35
+ # :event_stream :: whether the request is a server-sent events text event stream (defaults to <tt>false</tt>).
36
+ module OptionsMethods
37
+ def option_event_stream(val)
38
+ val
39
+ end
40
+ end
41
+
42
+ module InstanceMethods
43
+ def request(*args, **options)
44
+ options[:stream] = true if options[:event_stream]
45
+
46
+ super
47
+ end
48
+
49
+ def build_request(*)
50
+ super.tap do |request|
51
+ if request.options.event_stream
52
+ request.headers["accept"] = "text/event-stream"
53
+ request.headers["cache-control"] = "no-cache"
54
+ end
55
+ end
56
+ end
57
+ end
58
+
59
+ module RequestMethods
60
+ attr_accessor :last_server_sent_message
61
+
62
+ def initialize(*)
63
+ super
64
+
65
+ @last_server_sent_message = nil
66
+ end
67
+ end
68
+
69
+ module StreamResponseMethods
70
+ # yields each event Message as the server emits them.
71
+ def each_message(&block)
72
+ return enum_for(__method__) unless block
73
+
74
+ payload = {}
75
+
76
+ each_line do |line|
77
+ if line.empty?
78
+ if payload[:comment]
79
+ payload.clear
80
+ next
81
+ end
82
+
83
+ next if payload.empty?
84
+
85
+ message = Message.new(**payload)
86
+
87
+ payload.clear
88
+
89
+ @request.last_server_sent_message = message
90
+
91
+ yield message
92
+ else
93
+ type, value = line.split(": ", 2)
94
+
95
+ case type
96
+ when "data"
97
+ type = type.to_sym
98
+ if payload.key?(type)
99
+ payload[type] << "\n" << value
100
+ else
101
+ payload[type] = value
102
+ end
103
+ when "id", "event", "retry"
104
+ type = type.to_sym
105
+ raise_format_error(line) if payload.key?(type) || value.empty?
106
+
107
+ type = :retry_after if type == :retry # avoid using keyword
108
+
109
+ payload[type] = value
110
+ else
111
+ # skip if it's a comment
112
+ if line.start_with?(":")
113
+ payload[:comment] = true
114
+ next
115
+ end
116
+
117
+ raise_format_error(line)
118
+ end
119
+ end
120
+ end
121
+ end
122
+
123
+ private
124
+
125
+ def raise_format_error(line)
126
+ raise Error, "'#{line}': invalid or unsupported event stream format"
127
+ end
128
+ end
129
+
130
+ module ServerSentEventsRetries
131
+ module InstanceMethods
132
+ private
133
+
134
+ def prepare_to_retry(request, *)
135
+ super
136
+
137
+ last_message = request.last_server_sent_message
138
+
139
+ return unless last_message && last_message.id
140
+
141
+ request.headers["last-event-id"] = last_message.id
142
+ ensure
143
+ request.last_server_sent_message = nil
144
+ end
145
+
146
+ def when_to_retry(request, *)
147
+ retry_after = request.last_server_sent_message&.retry_after
148
+
149
+ retry_after / 1_000.0 if retry_after # original in milliseconds
150
+
151
+ request.last_server_sent_message&.retry_after && super
152
+ end
153
+ end
154
+ end
155
+ end
156
+ register_plugin(:server_sent_events, ServerSentEvents)
157
+ end
158
+ end
@@ -90,12 +90,22 @@ module HTTPX
90
90
  # adds support for the following options:
91
91
  #
92
92
  # :allowed_schemes :: list of URI schemes allowed (defaults to <tt>["https", "http"]</tt>)
93
+ # :extra_unsafe_ranges :: A list of IP ranges (or addresses) that will be filtered, in addition to the defaults
94
+ # :safe_private_ranges :: A list of IP ranges (or addresses) that will not be filtered, even if they'd be filtered by default
93
95
  module OptionsMethods
94
96
  private
95
97
 
96
98
  def option_allowed_schemes(value)
97
99
  Array(value)
98
100
  end
101
+
102
+ def option_extra_unsafe_ranges(value)
103
+ Array(value).map { |v| v.is_a?(IPAddr) ? v : IPAddr.new(v) }
104
+ end
105
+
106
+ def option_safe_private_ranges(value)
107
+ Array(value).map { |v| v.is_a?(IPAddr) ? v : IPAddr.new(v) }
108
+ end
99
109
  end
100
110
 
101
111
  module InstanceMethods
@@ -132,9 +142,14 @@ module HTTPX
132
142
  end
133
143
 
134
144
  def addresses=(addrs)
135
- addrs.reject!(&SsrfFilter.method(:unsafe_ip_address?))
145
+ addrs.reject! do |ipaddr|
146
+ ipaddr = ipaddr.address
147
+ next false if @options.safe_private_ranges&.any? { |r| r.include?(ipaddr) }
148
+
149
+ SsrfFilter.unsafe_ip_address?(ipaddr) || @options.extra_unsafe_ranges&.any? { |r| r.include?(ipaddr) }
150
+ end
136
151
 
137
- raise ServerSideRequestForgeryError, "#{@origin.host} has no public IP addresses" if addrs.empty?
152
+ raise ServerSideRequestForgeryError, "#{@origin.host} has no allowed IP addresses" if addrs.empty?
138
153
 
139
154
  super
140
155
  end