http 6.0.0-java

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 (142) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +267 -0
  3. data/CONTRIBUTING.md +26 -0
  4. data/LICENSE.txt +20 -0
  5. data/README.md +263 -0
  6. data/SECURITY.md +17 -0
  7. data/UPGRADING.md +491 -0
  8. data/http.gemspec +48 -0
  9. data/lib/http/base64.rb +22 -0
  10. data/lib/http/chainable/helpers.rb +62 -0
  11. data/lib/http/chainable/verbs.rb +136 -0
  12. data/lib/http/chainable.rb +377 -0
  13. data/lib/http/client.rb +230 -0
  14. data/lib/http/connection/internals.rb +141 -0
  15. data/lib/http/connection.rb +265 -0
  16. data/lib/http/content_type.rb +89 -0
  17. data/lib/http/errors.rb +67 -0
  18. data/lib/http/feature.rb +86 -0
  19. data/lib/http/features/auto_deflate.rb +230 -0
  20. data/lib/http/features/auto_inflate.rb +64 -0
  21. data/lib/http/features/caching/entry.rb +178 -0
  22. data/lib/http/features/caching/in_memory_store.rb +63 -0
  23. data/lib/http/features/caching.rb +216 -0
  24. data/lib/http/features/digest_auth.rb +234 -0
  25. data/lib/http/features/instrumentation.rb +149 -0
  26. data/lib/http/features/logging.rb +231 -0
  27. data/lib/http/features/normalize_uri.rb +34 -0
  28. data/lib/http/features/raise_error.rb +37 -0
  29. data/lib/http/form_data/composite_io.rb +106 -0
  30. data/lib/http/form_data/file.rb +95 -0
  31. data/lib/http/form_data/multipart/param.rb +62 -0
  32. data/lib/http/form_data/multipart.rb +106 -0
  33. data/lib/http/form_data/part.rb +52 -0
  34. data/lib/http/form_data/readable.rb +58 -0
  35. data/lib/http/form_data/urlencoded.rb +175 -0
  36. data/lib/http/form_data/version.rb +8 -0
  37. data/lib/http/form_data.rb +102 -0
  38. data/lib/http/headers/known.rb +90 -0
  39. data/lib/http/headers/normalizer.rb +50 -0
  40. data/lib/http/headers.rb +343 -0
  41. data/lib/http/mime_type/adapter.rb +43 -0
  42. data/lib/http/mime_type/json.rb +41 -0
  43. data/lib/http/mime_type.rb +96 -0
  44. data/lib/http/options/definitions.rb +189 -0
  45. data/lib/http/options.rb +241 -0
  46. data/lib/http/redirector.rb +157 -0
  47. data/lib/http/request/body.rb +181 -0
  48. data/lib/http/request/builder.rb +184 -0
  49. data/lib/http/request/proxy.rb +83 -0
  50. data/lib/http/request/writer.rb +186 -0
  51. data/lib/http/request.rb +375 -0
  52. data/lib/http/response/body.rb +172 -0
  53. data/lib/http/response/inflater.rb +60 -0
  54. data/lib/http/response/parser.rb +223 -0
  55. data/lib/http/response/status/reasons.rb +79 -0
  56. data/lib/http/response/status.rb +263 -0
  57. data/lib/http/response.rb +350 -0
  58. data/lib/http/retriable/delay_calculator.rb +91 -0
  59. data/lib/http/retriable/errors.rb +35 -0
  60. data/lib/http/retriable/performer.rb +197 -0
  61. data/lib/http/session.rb +280 -0
  62. data/lib/http/timeout/global.rb +229 -0
  63. data/lib/http/timeout/null.rb +225 -0
  64. data/lib/http/timeout/per_operation.rb +197 -0
  65. data/lib/http/uri/normalizer.rb +82 -0
  66. data/lib/http/uri/parsing.rb +182 -0
  67. data/lib/http/uri.rb +376 -0
  68. data/lib/http/version.rb +6 -0
  69. data/lib/http.rb +36 -0
  70. data/sig/deps.rbs +122 -0
  71. data/sig/http.rbs +1619 -0
  72. data/test/http/base64_test.rb +28 -0
  73. data/test/http/client_test.rb +739 -0
  74. data/test/http/connection_test.rb +1533 -0
  75. data/test/http/content_type_test.rb +190 -0
  76. data/test/http/errors_test.rb +28 -0
  77. data/test/http/feature_test.rb +49 -0
  78. data/test/http/features/auto_deflate_test.rb +317 -0
  79. data/test/http/features/auto_inflate_test.rb +213 -0
  80. data/test/http/features/caching_test.rb +942 -0
  81. data/test/http/features/digest_auth_test.rb +996 -0
  82. data/test/http/features/instrumentation_test.rb +246 -0
  83. data/test/http/features/logging_test.rb +654 -0
  84. data/test/http/features/normalize_uri_test.rb +41 -0
  85. data/test/http/features/raise_error_test.rb +77 -0
  86. data/test/http/form_data/composite_io_test.rb +215 -0
  87. data/test/http/form_data/file_test.rb +255 -0
  88. data/test/http/form_data/fixtures/the-http-gem.info +1 -0
  89. data/test/http/form_data/multipart_test.rb +303 -0
  90. data/test/http/form_data/part_test.rb +90 -0
  91. data/test/http/form_data/urlencoded_test.rb +164 -0
  92. data/test/http/form_data_test.rb +232 -0
  93. data/test/http/headers/normalizer_test.rb +93 -0
  94. data/test/http/headers_test.rb +888 -0
  95. data/test/http/mime_type/json_test.rb +39 -0
  96. data/test/http/mime_type_test.rb +150 -0
  97. data/test/http/options/base_uri_test.rb +148 -0
  98. data/test/http/options/body_test.rb +21 -0
  99. data/test/http/options/features_test.rb +38 -0
  100. data/test/http/options/form_test.rb +21 -0
  101. data/test/http/options/headers_test.rb +32 -0
  102. data/test/http/options/json_test.rb +21 -0
  103. data/test/http/options/merge_test.rb +78 -0
  104. data/test/http/options/new_test.rb +37 -0
  105. data/test/http/options/proxy_test.rb +32 -0
  106. data/test/http/options_test.rb +575 -0
  107. data/test/http/redirector_test.rb +639 -0
  108. data/test/http/request/body_test.rb +318 -0
  109. data/test/http/request/builder_test.rb +623 -0
  110. data/test/http/request/writer_test.rb +391 -0
  111. data/test/http/request_test.rb +1733 -0
  112. data/test/http/response/body_test.rb +292 -0
  113. data/test/http/response/parser_test.rb +105 -0
  114. data/test/http/response/status_test.rb +322 -0
  115. data/test/http/response_test.rb +502 -0
  116. data/test/http/retriable/delay_calculator_test.rb +194 -0
  117. data/test/http/retriable/errors_test.rb +71 -0
  118. data/test/http/retriable/performer_test.rb +551 -0
  119. data/test/http/session_test.rb +424 -0
  120. data/test/http/timeout/global_test.rb +239 -0
  121. data/test/http/timeout/null_test.rb +218 -0
  122. data/test/http/timeout/per_operation_test.rb +220 -0
  123. data/test/http/uri/normalizer_test.rb +89 -0
  124. data/test/http/uri_test.rb +1140 -0
  125. data/test/http/version_test.rb +15 -0
  126. data/test/http_test.rb +818 -0
  127. data/test/regression_tests.rb +27 -0
  128. data/test/support/capture_warning.rb +10 -0
  129. data/test/support/dummy_server/encoding_routes.rb +47 -0
  130. data/test/support/dummy_server/routes.rb +201 -0
  131. data/test/support/dummy_server/servlet.rb +81 -0
  132. data/test/support/dummy_server.rb +200 -0
  133. data/test/support/fakeio.rb +21 -0
  134. data/test/support/http_handling_shared/connection_reuse_tests.rb +97 -0
  135. data/test/support/http_handling_shared/timeout_tests.rb +134 -0
  136. data/test/support/http_handling_shared.rb +11 -0
  137. data/test/support/proxy_server.rb +207 -0
  138. data/test/support/servers/runner.rb +67 -0
  139. data/test/support/simplecov.rb +28 -0
  140. data/test/support/ssl_helper.rb +108 -0
  141. data/test/test_helper.rb +38 -0
  142. metadata +218 -0
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HTTP
4
+ module Features
5
+ class Caching < Feature
6
+ # Simple in-memory cache store backed by a Hash
7
+ #
8
+ # Cache keys are derived from the request method and URI.
9
+ #
10
+ # @example
11
+ # store = InMemoryStore.new
12
+ # store.store(request, entry)
13
+ # store.lookup(request) # => entry
14
+ #
15
+ class InMemoryStore
16
+ # Create a new empty in-memory store
17
+ #
18
+ # @example
19
+ # store = InMemoryStore.new
20
+ #
21
+ # @return [InMemoryStore]
22
+ # @api public
23
+ def initialize
24
+ @cache = {}
25
+ end
26
+
27
+ # Look up a cached entry for a request
28
+ #
29
+ # @example
30
+ # store.lookup(request) # => Entry or nil
31
+ #
32
+ # @param request [HTTP::Request]
33
+ # @return [Entry, nil]
34
+ # @api public
35
+ def lookup(request)
36
+ @cache[cache_key(request)]
37
+ end
38
+
39
+ # Store a cache entry for a request
40
+ #
41
+ # @example
42
+ # store.store(request, entry)
43
+ #
44
+ # @param request [HTTP::Request]
45
+ # @param entry [Entry]
46
+ # @return [Entry]
47
+ # @api public
48
+ def store(request, entry)
49
+ @cache[cache_key(request)] = entry
50
+ end
51
+
52
+ private
53
+
54
+ # Compute the cache key for a request
55
+ # @return [String]
56
+ # @api private
57
+ def cache_key(request)
58
+ format("%s %s", request.verb, request.uri)
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -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
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HTTP
4
+ module Features
5
+ # Instrument requests and responses. Expects an
6
+ # ActiveSupport::Notifications-compatible instrumenter. Defaults to use a
7
+ # namespace of 'http' which may be overridden with a `:namespace` param.
8
+ # Emits a single event like `"request.{namespace}"`, eg `"request.http"`.
9
+ # Be sure to specify the instrumenter when enabling the feature:
10
+ #
11
+ # HTTP
12
+ # .use(instrumentation: {instrumenter: ActiveSupport::Notifications.instrumenter})
13
+ # .get("https://example.com/")
14
+ #
15
+ # Emits two events on every request:
16
+ #
17
+ # * `start_request.http` before the request is made, so you can log the reqest being started
18
+ # * `request.http` after the response is recieved, and contains `start`
19
+ # and `finish` so the duration of the request can be calculated.
20
+ #
21
+ class Instrumentation < Feature
22
+ # The instrumenter instance
23
+ #
24
+ # @example
25
+ # feature.instrumenter
26
+ #
27
+ # @return [#instrument] the instrumenter instance
28
+ # @api public
29
+ attr_reader :instrumenter
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
58
+ def initialize(instrumenter: NullInstrumenter.new, namespace: "http")
59
+ super()
60
+ @instrumenter = instrumenter
61
+ @name = "request.#{namespace}"
62
+ @error_name = "error.#{namespace}"
63
+ end
64
+
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)
80
+ # Emit a separate "start" event, so a logger can print the request
81
+ # being run without waiting for a 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
88
+ end
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
99
+ def on_error(request, error)
100
+ instrumenter.instrument(error_name, request: request, error: error) {} # rubocop:disable Lint/EmptyBlock
101
+ end
102
+
103
+ HTTP::Options.register_feature(:instrumentation, self)
104
+
105
+ # No-op instrumenter used as default when none is provided
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
116
+ def instrument(name, payload = {})
117
+ start(name, payload)
118
+ begin
119
+ yield payload if block_given?
120
+ ensure
121
+ finish name, payload
122
+ end
123
+ end
124
+
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
135
+
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
146
+ end
147
+ end
148
+ end
149
+ end