http 5.3.1 → 6.0.0

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 (201) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +241 -41
  3. data/LICENSE.txt +1 -1
  4. data/README.md +110 -13
  5. data/UPGRADING.md +491 -0
  6. data/http.gemspec +32 -29
  7. data/lib/http/base64.rb +11 -1
  8. data/lib/http/chainable/helpers.rb +62 -0
  9. data/lib/http/chainable/verbs.rb +136 -0
  10. data/lib/http/chainable.rb +232 -136
  11. data/lib/http/client.rb +158 -127
  12. data/lib/http/connection/internals.rb +141 -0
  13. data/lib/http/connection.rb +126 -97
  14. data/lib/http/content_type.rb +61 -6
  15. data/lib/http/errors.rb +25 -1
  16. data/lib/http/feature.rb +65 -5
  17. data/lib/http/features/auto_deflate.rb +124 -17
  18. data/lib/http/features/auto_inflate.rb +38 -15
  19. data/lib/http/features/caching/entry.rb +178 -0
  20. data/lib/http/features/caching/in_memory_store.rb +63 -0
  21. data/lib/http/features/caching.rb +216 -0
  22. data/lib/http/features/digest_auth.rb +234 -0
  23. data/lib/http/features/instrumentation.rb +97 -17
  24. data/lib/http/features/logging.rb +183 -5
  25. data/lib/http/features/normalize_uri.rb +17 -0
  26. data/lib/http/features/raise_error.rb +18 -3
  27. data/lib/http/form_data/composite_io.rb +106 -0
  28. data/lib/http/form_data/file.rb +95 -0
  29. data/lib/http/form_data/multipart/param.rb +62 -0
  30. data/lib/http/form_data/multipart.rb +106 -0
  31. data/lib/http/form_data/part.rb +52 -0
  32. data/lib/http/form_data/readable.rb +58 -0
  33. data/lib/http/form_data/urlencoded.rb +175 -0
  34. data/lib/http/form_data/version.rb +8 -0
  35. data/lib/http/form_data.rb +102 -0
  36. data/lib/http/headers/known.rb +3 -0
  37. data/lib/http/headers/normalizer.rb +17 -36
  38. data/lib/http/headers.rb +172 -65
  39. data/lib/http/mime_type/adapter.rb +24 -9
  40. data/lib/http/mime_type/json.rb +19 -4
  41. data/lib/http/mime_type.rb +21 -3
  42. data/lib/http/options/definitions.rb +189 -0
  43. data/lib/http/options.rb +172 -125
  44. data/lib/http/redirector.rb +80 -75
  45. data/lib/http/request/body.rb +87 -6
  46. data/lib/http/request/builder.rb +184 -0
  47. data/lib/http/request/proxy.rb +83 -0
  48. data/lib/http/request/writer.rb +76 -16
  49. data/lib/http/request.rb +214 -98
  50. data/lib/http/response/body.rb +103 -18
  51. data/lib/http/response/inflater.rb +35 -7
  52. data/lib/http/response/parser.rb +98 -4
  53. data/lib/http/response/status/reasons.rb +2 -4
  54. data/lib/http/response/status.rb +141 -31
  55. data/lib/http/response.rb +219 -61
  56. data/lib/http/retriable/delay_calculator.rb +38 -11
  57. data/lib/http/retriable/errors.rb +21 -0
  58. data/lib/http/retriable/performer.rb +82 -38
  59. data/lib/http/session.rb +280 -0
  60. data/lib/http/timeout/global.rb +147 -34
  61. data/lib/http/timeout/null.rb +155 -9
  62. data/lib/http/timeout/per_operation.rb +139 -18
  63. data/lib/http/uri/normalizer.rb +82 -0
  64. data/lib/http/uri/parsing.rb +182 -0
  65. data/lib/http/uri.rb +289 -124
  66. data/lib/http/version.rb +2 -1
  67. data/lib/http.rb +11 -2
  68. data/sig/deps.rbs +122 -0
  69. data/sig/http.rbs +1619 -0
  70. data/test/http/base64_test.rb +28 -0
  71. data/test/http/client_test.rb +739 -0
  72. data/test/http/connection_test.rb +1533 -0
  73. data/test/http/content_type_test.rb +190 -0
  74. data/test/http/errors_test.rb +28 -0
  75. data/test/http/feature_test.rb +49 -0
  76. data/test/http/features/auto_deflate_test.rb +317 -0
  77. data/test/http/features/auto_inflate_test.rb +213 -0
  78. data/test/http/features/caching_test.rb +942 -0
  79. data/test/http/features/digest_auth_test.rb +996 -0
  80. data/test/http/features/instrumentation_test.rb +246 -0
  81. data/test/http/features/logging_test.rb +654 -0
  82. data/test/http/features/normalize_uri_test.rb +41 -0
  83. data/test/http/features/raise_error_test.rb +77 -0
  84. data/test/http/form_data/composite_io_test.rb +215 -0
  85. data/test/http/form_data/file_test.rb +255 -0
  86. data/test/http/form_data/fixtures/the-http-gem.info +1 -0
  87. data/test/http/form_data/multipart_test.rb +303 -0
  88. data/test/http/form_data/part_test.rb +90 -0
  89. data/test/http/form_data/urlencoded_test.rb +164 -0
  90. data/test/http/form_data_test.rb +232 -0
  91. data/test/http/headers/normalizer_test.rb +93 -0
  92. data/test/http/headers_test.rb +888 -0
  93. data/test/http/mime_type/json_test.rb +39 -0
  94. data/test/http/mime_type_test.rb +150 -0
  95. data/test/http/options/base_uri_test.rb +148 -0
  96. data/test/http/options/body_test.rb +21 -0
  97. data/test/http/options/features_test.rb +38 -0
  98. data/test/http/options/form_test.rb +21 -0
  99. data/test/http/options/headers_test.rb +32 -0
  100. data/test/http/options/json_test.rb +21 -0
  101. data/test/http/options/merge_test.rb +78 -0
  102. data/test/http/options/new_test.rb +37 -0
  103. data/test/http/options/proxy_test.rb +32 -0
  104. data/test/http/options_test.rb +575 -0
  105. data/test/http/redirector_test.rb +639 -0
  106. data/test/http/request/body_test.rb +318 -0
  107. data/test/http/request/builder_test.rb +623 -0
  108. data/test/http/request/writer_test.rb +391 -0
  109. data/test/http/request_test.rb +1733 -0
  110. data/test/http/response/body_test.rb +292 -0
  111. data/test/http/response/parser_test.rb +105 -0
  112. data/test/http/response/status_test.rb +322 -0
  113. data/test/http/response_test.rb +502 -0
  114. data/test/http/retriable/delay_calculator_test.rb +194 -0
  115. data/test/http/retriable/errors_test.rb +71 -0
  116. data/test/http/retriable/performer_test.rb +551 -0
  117. data/test/http/session_test.rb +424 -0
  118. data/test/http/timeout/global_test.rb +239 -0
  119. data/test/http/timeout/null_test.rb +218 -0
  120. data/test/http/timeout/per_operation_test.rb +220 -0
  121. data/test/http/uri/normalizer_test.rb +89 -0
  122. data/test/http/uri_test.rb +1140 -0
  123. data/test/http/version_test.rb +15 -0
  124. data/test/http_test.rb +818 -0
  125. data/test/regression_tests.rb +27 -0
  126. data/test/support/dummy_server/encoding_routes.rb +47 -0
  127. data/test/support/dummy_server/routes.rb +201 -0
  128. data/test/support/dummy_server/servlet.rb +81 -0
  129. data/test/support/dummy_server.rb +200 -0
  130. data/{spec → test}/support/fakeio.rb +2 -2
  131. data/test/support/http_handling_shared/connection_reuse_tests.rb +97 -0
  132. data/test/support/http_handling_shared/timeout_tests.rb +134 -0
  133. data/test/support/http_handling_shared.rb +11 -0
  134. data/test/support/proxy_server.rb +207 -0
  135. data/test/support/servers/runner.rb +67 -0
  136. data/{spec → test}/support/simplecov.rb +11 -2
  137. data/test/support/ssl_helper.rb +108 -0
  138. data/test/test_helper.rb +38 -0
  139. metadata +108 -168
  140. data/.github/workflows/ci.yml +0 -67
  141. data/.gitignore +0 -15
  142. data/.rspec +0 -1
  143. data/.rubocop/layout.yml +0 -8
  144. data/.rubocop/metrics.yml +0 -4
  145. data/.rubocop/rspec.yml +0 -9
  146. data/.rubocop/style.yml +0 -32
  147. data/.rubocop.yml +0 -11
  148. data/.rubocop_todo.yml +0 -219
  149. data/.yardopts +0 -2
  150. data/CHANGES_OLD.md +0 -1002
  151. data/Gemfile +0 -51
  152. data/Guardfile +0 -18
  153. data/Rakefile +0 -64
  154. data/lib/http/headers/mixin.rb +0 -34
  155. data/lib/http/retriable/client.rb +0 -37
  156. data/logo.png +0 -0
  157. data/spec/lib/http/client_spec.rb +0 -556
  158. data/spec/lib/http/connection_spec.rb +0 -88
  159. data/spec/lib/http/content_type_spec.rb +0 -47
  160. data/spec/lib/http/features/auto_deflate_spec.rb +0 -77
  161. data/spec/lib/http/features/auto_inflate_spec.rb +0 -86
  162. data/spec/lib/http/features/instrumentation_spec.rb +0 -81
  163. data/spec/lib/http/features/logging_spec.rb +0 -65
  164. data/spec/lib/http/features/raise_error_spec.rb +0 -62
  165. data/spec/lib/http/headers/mixin_spec.rb +0 -36
  166. data/spec/lib/http/headers/normalizer_spec.rb +0 -52
  167. data/spec/lib/http/headers_spec.rb +0 -527
  168. data/spec/lib/http/options/body_spec.rb +0 -15
  169. data/spec/lib/http/options/features_spec.rb +0 -33
  170. data/spec/lib/http/options/form_spec.rb +0 -15
  171. data/spec/lib/http/options/headers_spec.rb +0 -24
  172. data/spec/lib/http/options/json_spec.rb +0 -15
  173. data/spec/lib/http/options/merge_spec.rb +0 -68
  174. data/spec/lib/http/options/new_spec.rb +0 -30
  175. data/spec/lib/http/options/proxy_spec.rb +0 -20
  176. data/spec/lib/http/options_spec.rb +0 -13
  177. data/spec/lib/http/redirector_spec.rb +0 -530
  178. data/spec/lib/http/request/body_spec.rb +0 -211
  179. data/spec/lib/http/request/writer_spec.rb +0 -121
  180. data/spec/lib/http/request_spec.rb +0 -234
  181. data/spec/lib/http/response/body_spec.rb +0 -85
  182. data/spec/lib/http/response/parser_spec.rb +0 -74
  183. data/spec/lib/http/response/status_spec.rb +0 -253
  184. data/spec/lib/http/response_spec.rb +0 -262
  185. data/spec/lib/http/retriable/delay_calculator_spec.rb +0 -69
  186. data/spec/lib/http/retriable/performer_spec.rb +0 -302
  187. data/spec/lib/http/uri/normalizer_spec.rb +0 -95
  188. data/spec/lib/http/uri_spec.rb +0 -71
  189. data/spec/lib/http_spec.rb +0 -535
  190. data/spec/regression_specs.rb +0 -24
  191. data/spec/spec_helper.rb +0 -89
  192. data/spec/support/black_hole.rb +0 -13
  193. data/spec/support/dummy_server/servlet.rb +0 -203
  194. data/spec/support/dummy_server.rb +0 -44
  195. data/spec/support/fuubar.rb +0 -21
  196. data/spec/support/http_handling_shared.rb +0 -190
  197. data/spec/support/proxy_server.rb +0 -39
  198. data/spec/support/servers/config.rb +0 -11
  199. data/spec/support/servers/runner.rb +0 -19
  200. data/spec/support/ssl_helper.rb +0 -104
  201. /data/{spec → test}/support/capture_warning.rb +0 -0
@@ -0,0 +1,216 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ require "http/features/caching/entry"
6
+ require "http/features/caching/in_memory_store"
7
+
8
+ module HTTP
9
+ module Features
10
+ # HTTP caching feature that stores and reuses responses according to
11
+ # RFC 7234. Only GET and HEAD responses are cached. Supports
12
+ # `Cache-Control`, `Expires`, `ETag`, and `Last-Modified` for freshness
13
+ # checks and conditional revalidation.
14
+ #
15
+ # @example Basic usage with in-memory cache
16
+ # HTTP.use(:caching).get("https://example.com/")
17
+ #
18
+ # @example With a shared store across requests
19
+ # store = HTTP::Features::Caching::InMemoryStore.new
20
+ # client = HTTP.use(caching: { store: store })
21
+ # client.get("https://example.com/")
22
+ #
23
+ class Caching < Feature
24
+ CACHEABLE_METHODS = Set.new(%i[get head]).freeze
25
+ private_constant :CACHEABLE_METHODS
26
+
27
+ # The cache store instance
28
+ #
29
+ # @example
30
+ # feature.store
31
+ #
32
+ # @return [#lookup, #store] the cache store
33
+ # @api public
34
+ attr_reader :store
35
+
36
+ # Initializes the Caching feature
37
+ #
38
+ # @example
39
+ # Caching.new(store: InMemoryStore.new)
40
+ #
41
+ # @param store [#lookup, #store] cache store instance
42
+ # @return [Caching]
43
+ # @api public
44
+ def initialize(store: InMemoryStore.new)
45
+ @store = store
46
+ end
47
+
48
+ # Wraps the HTTP exchange with caching logic
49
+ #
50
+ # Checks the cache before making a request. Returns a cached response
51
+ # if fresh; otherwise adds conditional headers and revalidates. Stores
52
+ # cacheable responses for future use.
53
+ #
54
+ # @example
55
+ # feature.around_request(request) { |req| perform_exchange(req) }
56
+ #
57
+ # @param request [HTTP::Request]
58
+ # @yield Executes the HTTP exchange
59
+ # @yieldreturn [HTTP::Response]
60
+ # @return [HTTP::Response]
61
+ # @api public
62
+ def around_request(request)
63
+ return yield(request) unless cacheable_request?(request)
64
+
65
+ entry = store.lookup(request)
66
+
67
+ return yield(request) unless entry
68
+
69
+ return build_cached_response(entry, request) if entry.fresh?
70
+
71
+ response = yield(add_conditional_headers(request, entry))
72
+
73
+ return revalidate_entry(entry, response, request) if response.status.not_modified?
74
+
75
+ response
76
+ end
77
+
78
+ # Stores cacheable responses in the cache
79
+ #
80
+ # @example
81
+ # feature.wrap_response(response)
82
+ #
83
+ # @param response [HTTP::Response]
84
+ # @return [HTTP::Response]
85
+ # @api public
86
+ def wrap_response(response)
87
+ return response unless cacheable_request?(response.request)
88
+ return response unless cacheable_response?(response)
89
+
90
+ store_and_freeze_response(response)
91
+ end
92
+
93
+ private
94
+
95
+ # Revalidate a cached entry with a 304 response
96
+ # @return [HTTP::Response]
97
+ # @api private
98
+ def revalidate_entry(entry, response, request)
99
+ entry.update_headers!(response.headers)
100
+ entry.revalidate!
101
+ build_cached_response(entry, request)
102
+ end
103
+
104
+ # Store response in cache and return a new response with eagerly-read body
105
+ # @return [HTTP::Response]
106
+ # @api private
107
+ def store_and_freeze_response(response)
108
+ body_string = String(response)
109
+ store.store(response.request, build_entry(response, body_string))
110
+
111
+ Response.new(
112
+ status: response.code,
113
+ version: response.version,
114
+ headers: response.headers,
115
+ proxy_headers: response.proxy_headers,
116
+ body: body_string,
117
+ request: response.request
118
+ )
119
+ end
120
+
121
+ # Build a cache entry from a response
122
+ # @return [Entry]
123
+ # @api private
124
+ def build_entry(response, body_string)
125
+ Entry.new(
126
+ status: response.code,
127
+ version: response.version,
128
+ headers: response.headers.dup,
129
+ proxy_headers: response.proxy_headers,
130
+ body: body_string,
131
+ request_uri: response.uri,
132
+ stored_at: now
133
+ )
134
+ end
135
+
136
+ # Check whether this request method is cacheable
137
+ # @return [Boolean]
138
+ # @api private
139
+ def cacheable_request?(request)
140
+ CACHEABLE_METHODS.include?(request.verb)
141
+ end
142
+
143
+ # Check whether this response is cacheable
144
+ # @return [Boolean]
145
+ # @api private
146
+ def cacheable_response?(response)
147
+ return false if response.status < 200
148
+ return false if response.status >= 400
149
+
150
+ directives = parse_cache_control(response.headers)
151
+ return false if directives.include?("no-store")
152
+
153
+ freshness_info?(response, directives)
154
+ end
155
+
156
+ # Whether the response carries enough information to determine freshness
157
+ # @return [Boolean]
158
+ # @api private
159
+ def freshness_info?(response, directives)
160
+ return true if directives.any? { |d| d.start_with?("max-age=") }
161
+ return true if response.headers.include?(Headers::EXPIRES)
162
+ return true if response.headers.include?(Headers::ETAG)
163
+
164
+ response.headers.include?(Headers::LAST_MODIFIED)
165
+ end
166
+
167
+ # Parse Cache-Control header into a list of directives
168
+ # @return [Array<String>]
169
+ # @api private
170
+ def parse_cache_control(headers)
171
+ String(headers[Headers::CACHE_CONTROL]).downcase.split(",").map(&:strip)
172
+ end
173
+
174
+ # Add conditional headers from a cached entry to the request
175
+ # @return [HTTP::Request]
176
+ # @api private
177
+ def add_conditional_headers(request, entry)
178
+ headers = request.headers.dup
179
+ headers[Headers::IF_NONE_MATCH] = entry.headers[Headers::ETAG] # steep:ignore
180
+ headers[Headers::IF_MODIFIED_SINCE] = entry.headers[Headers::LAST_MODIFIED] # steep:ignore
181
+
182
+ Request.new(
183
+ verb: request.verb,
184
+ uri: request.uri,
185
+ headers: headers,
186
+ proxy: request.proxy,
187
+ body: request.body,
188
+ version: request.version
189
+ )
190
+ end
191
+
192
+ # Build a response from a cached entry
193
+ # @return [HTTP::Response]
194
+ # @api private
195
+ def build_cached_response(entry, request)
196
+ Response.new(
197
+ status: entry.status,
198
+ version: entry.version,
199
+ headers: entry.headers,
200
+ proxy_headers: entry.proxy_headers,
201
+ body: entry.body,
202
+ request: request
203
+ )
204
+ end
205
+
206
+ # Current time (extracted for testability)
207
+ # @return [Time]
208
+ # @api private
209
+ def now
210
+ Time.now
211
+ end
212
+
213
+ HTTP::Options.register_feature(:caching, self)
214
+ end
215
+ end
216
+ end
@@ -0,0 +1,234 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "securerandom"
5
+
6
+ module HTTP
7
+ module Features
8
+ # Implements HTTP Digest Authentication (RFC 2617 / RFC 7616)
9
+ #
10
+ # When a server responds with 401 and a Digest challenge, this feature
11
+ # automatically computes the digest response and retries the request
12
+ # with the correct Authorization header.
13
+ class DigestAuth < Feature
14
+ # Supported hash algorithms
15
+ ALGORITHMS = {
16
+ "MD5" => Digest::MD5,
17
+ "SHA-256" => Digest::SHA256,
18
+ "MD5-sess" => Digest::MD5,
19
+ "SHA-256-sess" => Digest::SHA256
20
+ }.freeze
21
+
22
+ # WWW-Authenticate header name
23
+ # @api private
24
+ WWW_AUTHENTICATE = "WWW-Authenticate"
25
+
26
+ # Initialize the DigestAuth feature
27
+ #
28
+ # @example
29
+ # DigestAuth.new(user: "admin", pass: "secret")
30
+ #
31
+ # @param user [String] username for authentication
32
+ # @param pass [String] password for authentication
33
+ # @return [DigestAuth]
34
+ # @api public
35
+ def initialize(user:, pass:)
36
+ @user = user
37
+ @pass = pass
38
+ end
39
+
40
+ # Wraps the HTTP exchange to handle digest authentication challenges
41
+ #
42
+ # On a 401 with a Digest WWW-Authenticate header, flushes the error
43
+ # response, computes digest credentials, and retries the request.
44
+ #
45
+ # @example
46
+ # feature.around_request(request) { |req| perform(req) }
47
+ #
48
+ # @param request [HTTP::Request]
49
+ # @yield [HTTP::Request] the request to perform
50
+ # @yieldreturn [HTTP::Response]
51
+ # @return [HTTP::Response]
52
+ # @api public
53
+ def around_request(request)
54
+ response = yield request
55
+ return response unless digest_challenge?(response)
56
+
57
+ response.flush
58
+ yield authorize(request, response)
59
+ end
60
+
61
+ private
62
+
63
+ # Check if the response contains a digest authentication challenge
64
+ #
65
+ # @param response [HTTP::Response]
66
+ # @return [Boolean]
67
+ # @api private
68
+ def digest_challenge?(response)
69
+ www_auth = response.headers[WWW_AUTHENTICATE] #: String?
70
+ response.status.code == 401 && www_auth&.start_with?("Digest ") == true
71
+ end
72
+
73
+ # Build an authorized copy of the request using the digest challenge
74
+ #
75
+ # @param request [HTTP::Request] the original request
76
+ # @param response [HTTP::Response] the 401 response with challenge
77
+ # @return [HTTP::Request] a new request with Authorization header
78
+ # @api private
79
+ def authorize(request, response)
80
+ www_auth = response.headers[WWW_AUTHENTICATE] #: String
81
+ challenge = parse_challenge(www_auth)
82
+ headers = request.headers.dup
83
+ headers.set Headers::AUTHORIZATION, build_auth(request, challenge)
84
+
85
+ Request.new(
86
+ verb: request.verb,
87
+ uri: request.uri,
88
+ headers: headers,
89
+ proxy: request.proxy,
90
+ body: request.body.source,
91
+ version: request.version,
92
+ uri_normalizer: request.uri_normalizer
93
+ )
94
+ end
95
+
96
+ # Parse the WWW-Authenticate header into a parameter hash
97
+ #
98
+ # @param header [String] the WWW-Authenticate header value
99
+ # @return [Hash{String => String}] parsed challenge parameters
100
+ # @api private
101
+ def parse_challenge(header)
102
+ params = {} #: Hash[String, String]
103
+ header.sub(/\ADigest\s+/i, "").scan(/(\w+)=(?:"([^"]*)"|([\w-]+))/) do |match|
104
+ key = match[0] #: String
105
+ params[key] = format("%s", match[1] || match[2])
106
+ end
107
+ params
108
+ end
109
+
110
+ # Build the Authorization header value
111
+ #
112
+ # @param request [HTTP::Request] the request being authorized
113
+ # @param challenge [Hash{String => String}] parsed challenge params
114
+ # @return [String] the Digest authorization header value
115
+ # @api private
116
+ def build_auth(request, challenge)
117
+ algorithm = challenge.fetch("algorithm", "MD5")
118
+ qop = select_qop(challenge["qop"])
119
+ nonce = challenge.fetch("nonce")
120
+ cnonce = SecureRandom.hex(16)
121
+ nonce_count = "00000001"
122
+ uri = String(request.uri.request_uri)
123
+ ha1 = compute_ha1(algorithm, challenge.fetch("realm"), nonce, cnonce)
124
+ ha2 = compute_ha2(algorithm, String(request.verb).upcase, uri)
125
+
126
+ compute_auth_header(algorithm: algorithm, qop: qop, nonce: nonce, cnonce: cnonce,
127
+ nonce_count: nonce_count, uri: uri, ha1: ha1, ha2: ha2,
128
+ challenge: challenge)
129
+ end
130
+
131
+ # Compute digest and build the Authorization header string
132
+ #
133
+ # @return [String] formatted authorization header
134
+ # @api private
135
+ def compute_auth_header(algorithm:, qop:, nonce:, cnonce:, nonce_count:, uri:, ha1:, ha2:, challenge:)
136
+ response = compute_response(algorithm, ha1, ha2, nonce: nonce,
137
+ nonce_count: nonce_count, cnonce: cnonce, qop: qop)
138
+
139
+ build_header(username: @user, realm: challenge.fetch("realm"), nonce: nonce, uri: uri,
140
+ qop: qop, nonce_count: nonce_count, cnonce: cnonce, response: response,
141
+ opaque: challenge["opaque"], algorithm: algorithm)
142
+ end
143
+
144
+ # Select the best qop value from the challenge
145
+ #
146
+ # @param qop_str [String, nil] comma-separated qop options
147
+ # @return [String, nil] selected qop value
148
+ # @api private
149
+ def select_qop(qop_str)
150
+ return unless qop_str
151
+
152
+ qops = qop_str.split(",").map(&:strip)
153
+ return "auth" if qops.include?("auth")
154
+
155
+ qops.first
156
+ end
157
+
158
+ # Compute HA1 per RFC 2617
159
+ #
160
+ # @return [String] hex digest
161
+ # @api private
162
+ def compute_ha1(algorithm, realm, nonce, cnonce)
163
+ base = hex_digest(algorithm, "#{@user}:#{realm}:#{@pass}")
164
+
165
+ if algorithm.end_with?("-sess")
166
+ hex_digest(algorithm, "#{base}:#{nonce}:#{cnonce}")
167
+ else
168
+ base
169
+ end
170
+ end
171
+
172
+ # Compute HA2 per RFC 2617
173
+ #
174
+ # @return [String] hex digest
175
+ # @api private
176
+ def compute_ha2(algorithm, method, uri)
177
+ hex_digest(algorithm, "#{method}:#{uri}")
178
+ end
179
+
180
+ # Compute the final digest response value
181
+ #
182
+ # @param algorithm [String] algorithm name
183
+ # @param ha1 [String] HA1 hex digest
184
+ # @param ha2 [String] HA2 hex digest
185
+ # @param nonce [String] server nonce
186
+ # @param nonce_count [String] request counter
187
+ # @param cnonce [String] client nonce
188
+ # @param qop [String, nil] quality of protection
189
+ # @return [String] hex digest
190
+ # @api private
191
+ def compute_response(algorithm, ha1, ha2, nonce:, nonce_count:, cnonce:, qop:)
192
+ if qop
193
+ hex_digest(algorithm, "#{ha1}:#{nonce}:#{nonce_count}:#{cnonce}:#{qop}:#{ha2}")
194
+ else
195
+ hex_digest(algorithm, "#{ha1}:#{nonce}:#{ha2}")
196
+ end
197
+ end
198
+
199
+ # Compute a hex digest using the specified algorithm
200
+ #
201
+ # @param algorithm [String] algorithm name
202
+ # @param data [String] data to digest
203
+ # @return [String] hex digest
204
+ # @api private
205
+ def hex_digest(algorithm, data)
206
+ ALGORITHMS.fetch(algorithm.sub(/-sess\z/i, "")).hexdigest(data)
207
+ end
208
+
209
+ # Build the Digest Authorization header string
210
+ #
211
+ # @return [String] formatted header value
212
+ # @api private
213
+ def build_header(username:, realm:, nonce:, uri:, qop:, nonce_count:, cnonce:,
214
+ response:, opaque:, algorithm:)
215
+ parts = [
216
+ %(username="#{username}"),
217
+ %(realm="#{realm}"),
218
+ %(nonce="#{nonce}"),
219
+ %(uri="#{uri}")
220
+ ]
221
+
222
+ parts.push(%(qop=#{qop}), %(nc=#{nonce_count}), %(cnonce="#{cnonce}")) if qop
223
+
224
+ parts << %(response="#{response}")
225
+ parts << %(opaque="#{opaque}") if opaque
226
+ parts << %(algorithm=#{algorithm})
227
+
228
+ "Digest #{parts.join(', ')}"
229
+ end
230
+
231
+ HTTP::Options.register_feature(:digest_auth, self)
232
+ end
233
+ end
234
+ end
@@ -19,34 +19,100 @@ module HTTP
19
19
  # and `finish` so the duration of the request can be calculated.
20
20
  #
21
21
  class Instrumentation < Feature
22
- attr_reader :instrumenter, :name, :error_name
22
+ # The instrumenter instance
23
+ #
24
+ # @example
25
+ # feature.instrumenter
26
+ #
27
+ # @return [#instrument] the instrumenter instance
28
+ # @api public
29
+ attr_reader :instrumenter
23
30
 
31
+ # The event name for requests
32
+ #
33
+ # @example
34
+ # feature.name # => "request.http"
35
+ #
36
+ # @return [String] the event name for requests
37
+ # @api public
38
+ attr_reader :name
39
+
40
+ # The event name for errors
41
+ #
42
+ # @example
43
+ # feature.error_name # => "error.http"
44
+ #
45
+ # @return [String] the event name for errors
46
+ # @api public
47
+ attr_reader :error_name
48
+
49
+ # Initializes the Instrumentation feature
50
+ #
51
+ # @example
52
+ # Instrumentation.new(instrumenter: ActiveSupport::Notifications.instrumenter)
53
+ #
54
+ # @param instrumenter [#instrument] instrumenter instance
55
+ # @param namespace [String] event namespace
56
+ # @return [Instrumentation]
57
+ # @api public
24
58
  def initialize(instrumenter: NullInstrumenter.new, namespace: "http")
59
+ super()
25
60
  @instrumenter = instrumenter
26
61
  @name = "request.#{namespace}"
27
62
  @error_name = "error.#{namespace}"
28
63
  end
29
64
 
30
- def wrap_request(request)
65
+ # Wraps the HTTP exchange with instrumentation
66
+ #
67
+ # Emits a `"start_request.http"` event before the request, then wraps
68
+ # the exchange in a `"request.http"` span that is guaranteed to close
69
+ # on both success and failure (via the instrumenter's ensure block).
70
+ #
71
+ # @example
72
+ # feature.around_request(request) { perform_io }
73
+ #
74
+ # @param request [HTTP::Request]
75
+ # @yield Executes the HTTP exchange
76
+ # @yieldreturn [HTTP::Response]
77
+ # @return [HTTP::Response]
78
+ # @api public
79
+ def around_request(request)
31
80
  # Emit a separate "start" event, so a logger can print the request
32
81
  # being run without waiting for a response
33
- instrumenter.instrument("start_#{name}", :request => request)
34
- instrumenter.start(name, :request => request)
35
- request
36
- end
37
-
38
- def wrap_response(response)
39
- instrumenter.finish(name, :response => response)
40
- response
82
+ instrumenter.instrument("start_#{name}", request: request) {} # rubocop:disable Lint/EmptyBlock
83
+ instrumenter.instrument(name, request: request) do |payload|
84
+ response = yield request
85
+ payload[:response] = response if payload
86
+ response
87
+ end
41
88
  end
42
89
 
90
+ # Instruments a request error
91
+ #
92
+ # @example
93
+ # feature.on_error(request, error)
94
+ #
95
+ # @param request [HTTP::Request]
96
+ # @param error [Exception]
97
+ # @return [Object]
98
+ # @api public
43
99
  def on_error(request, error)
44
- instrumenter.instrument(error_name, :request => request, :error => error)
100
+ instrumenter.instrument(error_name, request: request, error: error) {} # rubocop:disable Lint/EmptyBlock
45
101
  end
46
102
 
47
103
  HTTP::Options.register_feature(:instrumentation, self)
48
104
 
105
+ # No-op instrumenter used as default when none is provided
49
106
  class NullInstrumenter
107
+ # Instruments an event with a name and payload
108
+ #
109
+ # @example
110
+ # instrumenter.instrument("request.http", request: req)
111
+ #
112
+ # @param name [String]
113
+ # @param payload [Hash]
114
+ # @return [Object]
115
+ # @api public
50
116
  def instrument(name, payload = {})
51
117
  start(name, payload)
52
118
  begin
@@ -56,13 +122,27 @@ module HTTP
56
122
  end
57
123
  end
58
124
 
59
- def start(_name, _payload)
60
- true
61
- end
125
+ # Starts an instrumentation event
126
+ #
127
+ # @example
128
+ # instrumenter.start("request.http", request: req)
129
+ #
130
+ # @param _name [String]
131
+ # @param _payload [Hash]
132
+ # @return [nil]
133
+ # @api public
134
+ def start(_name, _payload); end
62
135
 
63
- def finish(_name, _payload)
64
- true
65
- end
136
+ # Finishes an instrumentation event
137
+ #
138
+ # @example
139
+ # instrumenter.finish("request.http", response: resp)
140
+ #
141
+ # @param _name [String]
142
+ # @param _payload [Hash]
143
+ # @return [nil]
144
+ # @api public
145
+ def finish(_name, _payload); end
66
146
  end
67
147
  end
68
148
  end