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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +241 -41
- data/LICENSE.txt +1 -1
- data/README.md +110 -13
- data/UPGRADING.md +491 -0
- data/http.gemspec +32 -29
- data/lib/http/base64.rb +11 -1
- data/lib/http/chainable/helpers.rb +62 -0
- data/lib/http/chainable/verbs.rb +136 -0
- data/lib/http/chainable.rb +232 -136
- data/lib/http/client.rb +158 -127
- data/lib/http/connection/internals.rb +141 -0
- data/lib/http/connection.rb +126 -97
- data/lib/http/content_type.rb +61 -6
- data/lib/http/errors.rb +25 -1
- data/lib/http/feature.rb +65 -5
- data/lib/http/features/auto_deflate.rb +124 -17
- data/lib/http/features/auto_inflate.rb +38 -15
- data/lib/http/features/caching/entry.rb +178 -0
- data/lib/http/features/caching/in_memory_store.rb +63 -0
- data/lib/http/features/caching.rb +216 -0
- data/lib/http/features/digest_auth.rb +234 -0
- data/lib/http/features/instrumentation.rb +97 -17
- data/lib/http/features/logging.rb +183 -5
- data/lib/http/features/normalize_uri.rb +17 -0
- data/lib/http/features/raise_error.rb +18 -3
- data/lib/http/form_data/composite_io.rb +106 -0
- data/lib/http/form_data/file.rb +95 -0
- data/lib/http/form_data/multipart/param.rb +62 -0
- data/lib/http/form_data/multipart.rb +106 -0
- data/lib/http/form_data/part.rb +52 -0
- data/lib/http/form_data/readable.rb +58 -0
- data/lib/http/form_data/urlencoded.rb +175 -0
- data/lib/http/form_data/version.rb +8 -0
- data/lib/http/form_data.rb +102 -0
- data/lib/http/headers/known.rb +3 -0
- data/lib/http/headers/normalizer.rb +17 -36
- data/lib/http/headers.rb +172 -65
- data/lib/http/mime_type/adapter.rb +24 -9
- data/lib/http/mime_type/json.rb +19 -4
- data/lib/http/mime_type.rb +21 -3
- data/lib/http/options/definitions.rb +189 -0
- data/lib/http/options.rb +172 -125
- data/lib/http/redirector.rb +80 -75
- data/lib/http/request/body.rb +87 -6
- data/lib/http/request/builder.rb +184 -0
- data/lib/http/request/proxy.rb +83 -0
- data/lib/http/request/writer.rb +76 -16
- data/lib/http/request.rb +214 -98
- data/lib/http/response/body.rb +103 -18
- data/lib/http/response/inflater.rb +35 -7
- data/lib/http/response/parser.rb +98 -4
- data/lib/http/response/status/reasons.rb +2 -4
- data/lib/http/response/status.rb +141 -31
- data/lib/http/response.rb +219 -61
- data/lib/http/retriable/delay_calculator.rb +38 -11
- data/lib/http/retriable/errors.rb +21 -0
- data/lib/http/retriable/performer.rb +82 -38
- data/lib/http/session.rb +280 -0
- data/lib/http/timeout/global.rb +147 -34
- data/lib/http/timeout/null.rb +155 -9
- data/lib/http/timeout/per_operation.rb +139 -18
- data/lib/http/uri/normalizer.rb +82 -0
- data/lib/http/uri/parsing.rb +182 -0
- data/lib/http/uri.rb +289 -124
- data/lib/http/version.rb +2 -1
- data/lib/http.rb +11 -2
- data/sig/deps.rbs +122 -0
- data/sig/http.rbs +1619 -0
- data/test/http/base64_test.rb +28 -0
- data/test/http/client_test.rb +739 -0
- data/test/http/connection_test.rb +1533 -0
- data/test/http/content_type_test.rb +190 -0
- data/test/http/errors_test.rb +28 -0
- data/test/http/feature_test.rb +49 -0
- data/test/http/features/auto_deflate_test.rb +317 -0
- data/test/http/features/auto_inflate_test.rb +213 -0
- data/test/http/features/caching_test.rb +942 -0
- data/test/http/features/digest_auth_test.rb +996 -0
- data/test/http/features/instrumentation_test.rb +246 -0
- data/test/http/features/logging_test.rb +654 -0
- data/test/http/features/normalize_uri_test.rb +41 -0
- data/test/http/features/raise_error_test.rb +77 -0
- data/test/http/form_data/composite_io_test.rb +215 -0
- data/test/http/form_data/file_test.rb +255 -0
- data/test/http/form_data/fixtures/the-http-gem.info +1 -0
- data/test/http/form_data/multipart_test.rb +303 -0
- data/test/http/form_data/part_test.rb +90 -0
- data/test/http/form_data/urlencoded_test.rb +164 -0
- data/test/http/form_data_test.rb +232 -0
- data/test/http/headers/normalizer_test.rb +93 -0
- data/test/http/headers_test.rb +888 -0
- data/test/http/mime_type/json_test.rb +39 -0
- data/test/http/mime_type_test.rb +150 -0
- data/test/http/options/base_uri_test.rb +148 -0
- data/test/http/options/body_test.rb +21 -0
- data/test/http/options/features_test.rb +38 -0
- data/test/http/options/form_test.rb +21 -0
- data/test/http/options/headers_test.rb +32 -0
- data/test/http/options/json_test.rb +21 -0
- data/test/http/options/merge_test.rb +78 -0
- data/test/http/options/new_test.rb +37 -0
- data/test/http/options/proxy_test.rb +32 -0
- data/test/http/options_test.rb +575 -0
- data/test/http/redirector_test.rb +639 -0
- data/test/http/request/body_test.rb +318 -0
- data/test/http/request/builder_test.rb +623 -0
- data/test/http/request/writer_test.rb +391 -0
- data/test/http/request_test.rb +1733 -0
- data/test/http/response/body_test.rb +292 -0
- data/test/http/response/parser_test.rb +105 -0
- data/test/http/response/status_test.rb +322 -0
- data/test/http/response_test.rb +502 -0
- data/test/http/retriable/delay_calculator_test.rb +194 -0
- data/test/http/retriable/errors_test.rb +71 -0
- data/test/http/retriable/performer_test.rb +551 -0
- data/test/http/session_test.rb +424 -0
- data/test/http/timeout/global_test.rb +239 -0
- data/test/http/timeout/null_test.rb +218 -0
- data/test/http/timeout/per_operation_test.rb +220 -0
- data/test/http/uri/normalizer_test.rb +89 -0
- data/test/http/uri_test.rb +1140 -0
- data/test/http/version_test.rb +15 -0
- data/test/http_test.rb +818 -0
- data/test/regression_tests.rb +27 -0
- data/test/support/dummy_server/encoding_routes.rb +47 -0
- data/test/support/dummy_server/routes.rb +201 -0
- data/test/support/dummy_server/servlet.rb +81 -0
- data/test/support/dummy_server.rb +200 -0
- data/{spec → test}/support/fakeio.rb +2 -2
- data/test/support/http_handling_shared/connection_reuse_tests.rb +97 -0
- data/test/support/http_handling_shared/timeout_tests.rb +134 -0
- data/test/support/http_handling_shared.rb +11 -0
- data/test/support/proxy_server.rb +207 -0
- data/test/support/servers/runner.rb +67 -0
- data/{spec → test}/support/simplecov.rb +11 -2
- data/test/support/ssl_helper.rb +108 -0
- data/test/test_helper.rb +38 -0
- metadata +108 -168
- data/.github/workflows/ci.yml +0 -67
- data/.gitignore +0 -15
- data/.rspec +0 -1
- data/.rubocop/layout.yml +0 -8
- data/.rubocop/metrics.yml +0 -4
- data/.rubocop/rspec.yml +0 -9
- data/.rubocop/style.yml +0 -32
- data/.rubocop.yml +0 -11
- data/.rubocop_todo.yml +0 -219
- data/.yardopts +0 -2
- data/CHANGES_OLD.md +0 -1002
- data/Gemfile +0 -51
- data/Guardfile +0 -18
- data/Rakefile +0 -64
- data/lib/http/headers/mixin.rb +0 -34
- data/lib/http/retriable/client.rb +0 -37
- data/logo.png +0 -0
- data/spec/lib/http/client_spec.rb +0 -556
- data/spec/lib/http/connection_spec.rb +0 -88
- data/spec/lib/http/content_type_spec.rb +0 -47
- data/spec/lib/http/features/auto_deflate_spec.rb +0 -77
- data/spec/lib/http/features/auto_inflate_spec.rb +0 -86
- data/spec/lib/http/features/instrumentation_spec.rb +0 -81
- data/spec/lib/http/features/logging_spec.rb +0 -65
- data/spec/lib/http/features/raise_error_spec.rb +0 -62
- data/spec/lib/http/headers/mixin_spec.rb +0 -36
- data/spec/lib/http/headers/normalizer_spec.rb +0 -52
- data/spec/lib/http/headers_spec.rb +0 -527
- data/spec/lib/http/options/body_spec.rb +0 -15
- data/spec/lib/http/options/features_spec.rb +0 -33
- data/spec/lib/http/options/form_spec.rb +0 -15
- data/spec/lib/http/options/headers_spec.rb +0 -24
- data/spec/lib/http/options/json_spec.rb +0 -15
- data/spec/lib/http/options/merge_spec.rb +0 -68
- data/spec/lib/http/options/new_spec.rb +0 -30
- data/spec/lib/http/options/proxy_spec.rb +0 -20
- data/spec/lib/http/options_spec.rb +0 -13
- data/spec/lib/http/redirector_spec.rb +0 -530
- data/spec/lib/http/request/body_spec.rb +0 -211
- data/spec/lib/http/request/writer_spec.rb +0 -121
- data/spec/lib/http/request_spec.rb +0 -234
- data/spec/lib/http/response/body_spec.rb +0 -85
- data/spec/lib/http/response/parser_spec.rb +0 -74
- data/spec/lib/http/response/status_spec.rb +0 -253
- data/spec/lib/http/response_spec.rb +0 -262
- data/spec/lib/http/retriable/delay_calculator_spec.rb +0 -69
- data/spec/lib/http/retriable/performer_spec.rb +0 -302
- data/spec/lib/http/uri/normalizer_spec.rb +0 -95
- data/spec/lib/http/uri_spec.rb +0 -71
- data/spec/lib/http_spec.rb +0 -535
- data/spec/regression_specs.rb +0 -24
- data/spec/spec_helper.rb +0 -89
- data/spec/support/black_hole.rb +0 -13
- data/spec/support/dummy_server/servlet.rb +0 -203
- data/spec/support/dummy_server.rb +0 -44
- data/spec/support/fuubar.rb +0 -21
- data/spec/support/http_handling_shared.rb +0 -190
- data/spec/support/proxy_server.rb +0 -39
- data/spec/support/servers/config.rb +0 -11
- data/spec/support/servers/runner.rb +0 -19
- data/spec/support/ssl_helper.rb +0 -104
- /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
|
-
|
|
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
|
-
|
|
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
|
|
34
|
-
instrumenter.
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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, :
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|