x-core 1.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 +7 -0
- data/.yardopts +8 -0
- data/CHANGELOG.md +353 -0
- data/LICENSE.txt +21 -0
- data/README.md +54 -0
- data/lib/x/core/app_only_authenticator.rb +198 -0
- data/lib/x/core/authenticator.rb +74 -0
- data/lib/x/core/authenticator_request.rb +56 -0
- data/lib/x/core/bearer_token_authenticator.rb +46 -0
- data/lib/x/core/built_response.rb +100 -0
- data/lib/x/core/client.rb +728 -0
- data/lib/x/core/client_app_only.rb +115 -0
- data/lib/x/core/client_credentials.rb +193 -0
- data/lib/x/core/client_internals.rb +315 -0
- data/lib/x/core/client_memo.rb +58 -0
- data/lib/x/core/client_settings.rb +166 -0
- data/lib/x/core/client_token_refresh.rb +281 -0
- data/lib/x/core/connection.rb +328 -0
- data/lib/x/core/connection_pool.rb +123 -0
- data/lib/x/core/connection_proxy.rb +151 -0
- data/lib/x/core/connection_request.rb +90 -0
- data/lib/x/core/credential_holder.rb +78 -0
- data/lib/x/core/credential_validator.rb +286 -0
- data/lib/x/core/errors/authorization_denied.rb +55 -0
- data/lib/x/core/errors/authorization_error.rb +41 -0
- data/lib/x/core/errors/bad_gateway.rb +9 -0
- data/lib/x/core/errors/bad_request.rb +10 -0
- data/lib/x/core/errors/callback_error.rb +88 -0
- data/lib/x/core/errors/client_error.rb +17 -0
- data/lib/x/core/errors/conflict.rb +9 -0
- data/lib/x/core/errors/error.rb +66 -0
- data/lib/x/core/errors/forbidden.rb +10 -0
- data/lib/x/core/errors/gateway_timeout.rb +9 -0
- data/lib/x/core/errors/gone.rb +9 -0
- data/lib/x/core/errors/http_error.rb +333 -0
- data/lib/x/core/errors/internal_server_error.rb +9 -0
- data/lib/x/core/errors/invalid_response.rb +96 -0
- data/lib/x/core/errors/method_not_allowed.rb +10 -0
- data/lib/x/core/errors/network_error.rb +59 -0
- data/lib/x/core/errors/not_acceptable.rb +10 -0
- data/lib/x/core/errors/not_found.rb +15 -0
- data/lib/x/core/errors/payload_too_large.rb +10 -0
- data/lib/x/core/errors/payment_required.rb +10 -0
- data/lib/x/core/errors/request_timeout.rb +9 -0
- data/lib/x/core/errors/server_error.rb +15 -0
- data/lib/x/core/errors/service_unavailable.rb +9 -0
- data/lib/x/core/errors/token_report_failed.rb +59 -0
- data/lib/x/core/errors/too_many_redirects.rb +53 -0
- data/lib/x/core/errors/too_many_requests.rb +80 -0
- data/lib/x/core/errors/unauthorized.rb +15 -0
- data/lib/x/core/errors/unavailable_for_legal_reasons.rb +10 -0
- data/lib/x/core/errors/unprocessable_entity.rb +10 -0
- data/lib/x/core/errors/unsupported_format.rb +22 -0
- data/lib/x/core/errors/unsupported_media_type.rb +10 -0
- data/lib/x/core/errors/unsupported_operation.rb +18 -0
- data/lib/x/core/oauth1_authenticator.rb +142 -0
- data/lib/x/core/oauth2_authenticator.rb +296 -0
- data/lib/x/core/oauth2_authorization.rb +405 -0
- data/lib/x/core/oauth2_refresh.rb +272 -0
- data/lib/x/core/oauth2_tokens.rb +277 -0
- data/lib/x/core/origin.rb +95 -0
- data/lib/x/core/problem.rb +302 -0
- data/lib/x/core/proxy_setting.rb +22 -0
- data/lib/x/core/rate_limit.rb +137 -0
- data/lib/x/core/rate_limit_handler.rb +166 -0
- data/lib/x/core/redirect_handler.rb +208 -0
- data/lib/x/core/refresh_reporter.rb +92 -0
- data/lib/x/core/request_builder.rb +155 -0
- data/lib/x/core/request_context.rb +85 -0
- data/lib/x/core/request_encoding.rb +135 -0
- data/lib/x/core/response.rb +196 -0
- data/lib/x/core/response_headers.rb +31 -0
- data/lib/x/core/response_parser.rb +155 -0
- data/lib/x/core/retry_handler.rb +149 -0
- data/lib/x/core/setting_validator.rb +399 -0
- data/lib/x/core/stream_body.rb +77 -0
- data/lib/x/core/stream_response.rb +267 -0
- data/lib/x/core/token_endpoint.rb +238 -0
- data/lib/x/core/version.rb +25 -0
- data/lib/x/core.rb +6 -0
- data/sig/manifest.yaml +8 -0
- data/sig/x-core.rbs +396 -0
- metadata +162 -0
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
require_relative "authenticator"
|
|
6
|
+
require_relative "connection"
|
|
7
|
+
require_relative "errors/too_many_redirects"
|
|
8
|
+
require_relative "origin"
|
|
9
|
+
require_relative "request_context"
|
|
10
|
+
require_relative "request_builder"
|
|
11
|
+
require_relative "setting_validator"
|
|
12
|
+
|
|
13
|
+
module X
|
|
14
|
+
module Core
|
|
15
|
+
# Handles HTTP redirects for API requests
|
|
16
|
+
#
|
|
17
|
+
# Internal to x-core: Client follows redirects with it, and max_redirects is set on the client.
|
|
18
|
+
#
|
|
19
|
+
# @api private
|
|
20
|
+
class RedirectHandler
|
|
21
|
+
# Default maximum number of redirects to follow
|
|
22
|
+
DEFAULT_MAX_REDIRECTS = 10
|
|
23
|
+
# The redirects that keep the method and the body of the request, whatever the method
|
|
24
|
+
METHOD_PRESERVING_CODES = [307, 308].freeze
|
|
25
|
+
# The redirects that keep the method and the body of a request other than a POST, which is followed with a GET
|
|
26
|
+
MOVED_CODES = [301, 302].freeze
|
|
27
|
+
# The redirects that are followed: those that move the request, and 303 See Other, which is followed with a GET
|
|
28
|
+
FOLLOWED_CODES = [*MOVED_CODES, 303, *METHOD_PRESERVING_CODES].freeze
|
|
29
|
+
private_constant :METHOD_PRESERVING_CODES, :MOVED_CODES, :FOLLOWED_CODES
|
|
30
|
+
|
|
31
|
+
# The maximum number of redirects to follow
|
|
32
|
+
# @api private
|
|
33
|
+
# @return [Integer] the maximum number of redirects to follow
|
|
34
|
+
# @example Get or set the maximum redirects
|
|
35
|
+
# handler.max_redirects = 5
|
|
36
|
+
attr_reader :max_redirects
|
|
37
|
+
|
|
38
|
+
# The connection for making requests
|
|
39
|
+
# @api private
|
|
40
|
+
# @return [Connection] the connection for making requests
|
|
41
|
+
# @example Get the connection
|
|
42
|
+
# handler.connection
|
|
43
|
+
attr_reader :connection
|
|
44
|
+
|
|
45
|
+
# The request builder for creating requests
|
|
46
|
+
# @api private
|
|
47
|
+
# @return [RequestBuilder] the request builder for creating requests
|
|
48
|
+
# @example Get the request builder
|
|
49
|
+
# handler.request_builder
|
|
50
|
+
attr_reader :request_builder
|
|
51
|
+
|
|
52
|
+
# Initialize a new RedirectHandler
|
|
53
|
+
#
|
|
54
|
+
# @api private
|
|
55
|
+
# @param connection [Connection] the connection for making requests
|
|
56
|
+
# @param request_builder [RequestBuilder] the request builder for creating requests
|
|
57
|
+
# @param max_redirects [Integer] the maximum number of redirects to follow
|
|
58
|
+
# @return [RedirectHandler] a new instance
|
|
59
|
+
# @raise [ArgumentError] if the maximum number of redirects is not an Integer of at least 0
|
|
60
|
+
# @example Create a redirect handler
|
|
61
|
+
# handler = X::Core::RedirectHandler.new(connection: conn, request_builder: builder)
|
|
62
|
+
def initialize(connection: Connection.new, request_builder: RequestBuilder.new,
|
|
63
|
+
max_redirects: DEFAULT_MAX_REDIRECTS)
|
|
64
|
+
@connection = connection
|
|
65
|
+
@request_builder = request_builder
|
|
66
|
+
@max_redirects = SettingValidator.count!(:max_redirects, max_redirects)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Handle redirects for an HTTP response
|
|
70
|
+
#
|
|
71
|
+
# A redirect to another scheme, host, or port drops the credentials, the authenticator's and any Authorization,
|
|
72
|
+
# Cookie, or Proxy-Authorization header among the headers, as Origin decides; see {Origin}. A 307 or 308 keeps
|
|
73
|
+
# the method and the body of the request, so a request whose body holds something private replays it to the
|
|
74
|
+
# host it is redirected to, whatever its origin. A 301 or 302 keeps them too, as RFC 9110 Section 15.4 has it,
|
|
75
|
+
# but for a POST, which it lets a client follow with a GET, so a PUT or a DELETE is not answered by a GET that
|
|
76
|
+
# reads as its success. A 303 See Other is followed with a GET, which sends no body, so it sends no Content-Type
|
|
77
|
+
# either, such as the form type of a request given form:.
|
|
78
|
+
#
|
|
79
|
+
# A redirect that cannot be followed, a 300 Multiple Choices, 304 Not Modified, or 305 Use Proxy whatever its
|
|
80
|
+
# Location, or one whose location is missing, is not a valid URL, or is not an HTTP or HTTPS URL, is returned as
|
|
81
|
+
# it is, so that the client raises an HTTPError for it, however many redirects were followed before it. A
|
|
82
|
+
# redirect that can be followed once max_redirects have been raises TooManyRedirects, which names the request
|
|
83
|
+
# that redirect answered, so a max_redirects of 0 follows none, and raises for every one that could be.
|
|
84
|
+
#
|
|
85
|
+
# @api private
|
|
86
|
+
# @param response [Net::HTTPResponse] the HTTP response to handle
|
|
87
|
+
# @param request [Net::HTTPRequest] the request the response answers, built from a URI
|
|
88
|
+
# @param headers [Hash] additional headers to send with redirected requests
|
|
89
|
+
# @param authenticator [Authenticator] the authenticator for requests
|
|
90
|
+
# @param redirect_count [Integer] the current redirect count
|
|
91
|
+
# @return [Net::HTTPResponse] the final HTTP response after following redirects
|
|
92
|
+
# @raise [TooManyRedirects] if the maximum number of redirects is exceeded
|
|
93
|
+
# @example Handle a response
|
|
94
|
+
# response = handler.handle(response: resp, request: req)
|
|
95
|
+
def handle(response:, request:, headers: {}, authenticator: Authenticator.new, redirect_count: 0)
|
|
96
|
+
follow(response:, request:, headers:, authenticator:, redirect_count:).first
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Follow redirects, and return the final response with the request it answers
|
|
100
|
+
#
|
|
101
|
+
# Redirects are followed as handle follows them. The final response answers the last request that was sent, which a redirect may have sent to another URI
|
|
102
|
+
# with another method, so the response is reported, and its error named, for that request rather than the
|
|
103
|
+
# first.
|
|
104
|
+
#
|
|
105
|
+
# @api private
|
|
106
|
+
# @param response [Net::HTTPResponse] the HTTP response to handle
|
|
107
|
+
# @param request [Net::HTTPRequest] the request the response answers, built from a URI
|
|
108
|
+
# @param headers [Hash] additional headers to send with redirected requests
|
|
109
|
+
# @param authenticator [Authenticator] the authenticator for requests
|
|
110
|
+
# @param redirect_count [Integer] the current redirect count
|
|
111
|
+
# @return [Array(Net::HTTPResponse, Net::HTTPRequest)] the final HTTP response and the request it answers
|
|
112
|
+
# @raise [TooManyRedirects] if the maximum number of redirects is exceeded
|
|
113
|
+
# @example Follow a response
|
|
114
|
+
# response, request = handler.follow(response: resp, request: req)
|
|
115
|
+
def follow(response:, request:, headers: {}, authenticator: Authenticator.new, redirect_count: 0)
|
|
116
|
+
return [response, request] unless followed?(response)
|
|
117
|
+
|
|
118
|
+
uri = request.uri #: URI::Generic
|
|
119
|
+
new_uri = build_new_uri(response, uri)
|
|
120
|
+
return [response, request] if new_uri.nil?
|
|
121
|
+
check_redirect_count(request, redirect_count)
|
|
122
|
+
|
|
123
|
+
preserve = preserves_method?(request, Integer(response.code))
|
|
124
|
+
authenticator, headers = Origin.credentials_for(from: uri, to: new_uri, authenticator:, headers: headers_for(preserve, headers))
|
|
125
|
+
new_request = build_request(request, new_uri, preserve, headers, authenticator)
|
|
126
|
+
follow(response: connection.perform(request: new_request), request: new_request, headers:, authenticator:,
|
|
127
|
+
redirect_count: redirect_count + 1)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
private
|
|
131
|
+
|
|
132
|
+
# Whether a response is a redirect that is followed, given a location that can be
|
|
133
|
+
# @api private
|
|
134
|
+
# @param response [Net::HTTPResponse] the response
|
|
135
|
+
# @return [Boolean] whether the status of the response is one that is followed
|
|
136
|
+
def followed?(response) = FOLLOWED_CODES.include?(Integer(response.code))
|
|
137
|
+
|
|
138
|
+
# Raise for a redirect that would be one more than max_redirects allows
|
|
139
|
+
# @api private
|
|
140
|
+
# @param request [Net::HTTPRequest] the request the redirect answered, which the error names
|
|
141
|
+
# @param redirect_count [Integer] the redirects followed so far
|
|
142
|
+
# @return [void]
|
|
143
|
+
# @raise [TooManyRedirects] if max_redirects have been followed
|
|
144
|
+
def check_redirect_count(request, redirect_count)
|
|
145
|
+
raise TooManyRedirects.new("Too many redirects", **RequestContext.of(request)) if redirect_count >= max_redirects
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Build a new URI from the redirect response
|
|
149
|
+
#
|
|
150
|
+
# A relative location is relative to the request that was redirected, as RFC 9110 Section 10.2.2 requires,
|
|
151
|
+
# which need not share the base URL: a request can name a URL of its own. An HTTP or HTTPS URL that names no
|
|
152
|
+
# host, such as https:///users or http:, is not one a request can be sent to, as RFC 9110 Section 4.2.1 says.
|
|
153
|
+
#
|
|
154
|
+
# @api private
|
|
155
|
+
# @param response [Net::HTTPResponse] the redirect response
|
|
156
|
+
# @param uri [URI::Generic] the URI of the request that was redirected
|
|
157
|
+
# @return [URI::HTTP, nil] the new URI, or nil if the location is missing, invalid, or not an HTTP or HTTPS URL
|
|
158
|
+
# of a host
|
|
159
|
+
def build_new_uri(response, uri)
|
|
160
|
+
location = response["location"] or return
|
|
161
|
+
new_uri = URI.join(uri, location)
|
|
162
|
+
new_uri if new_uri.is_a?(URI::HTTP) && !new_uri.host.to_s.empty?
|
|
163
|
+
rescue URI::InvalidURIError
|
|
164
|
+
nil
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Whether a redirect keeps the method and the body of the request it answers
|
|
168
|
+
# @api private
|
|
169
|
+
# @param request [Net::HTTPRequest] the request that was redirected
|
|
170
|
+
# @param response_code [Integer] the status code of the redirect
|
|
171
|
+
# @return [Boolean] true for a 307 or 308, and for a 301 or 302 of any method but POST
|
|
172
|
+
def preserves_method?(request, response_code)
|
|
173
|
+
METHOD_PRESERVING_CODES.include?(response_code) ||
|
|
174
|
+
(MOVED_CODES.include?(response_code) && !request.method.eql?("POST"))
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# The headers of a redirected request, without a Content-Type a GET does not send
|
|
178
|
+
# @api private
|
|
179
|
+
# @param preserve [Boolean] whether the redirected request keeps the method and the body
|
|
180
|
+
# @param headers [Hash] the headers of the request that was redirected
|
|
181
|
+
# @return [Hash] the headers to send with the redirected request
|
|
182
|
+
def headers_for(preserve, headers)
|
|
183
|
+
return headers if preserve
|
|
184
|
+
|
|
185
|
+
headers.reject { |name, _| name.casecmp?("Content-Type") }
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Build a new request for the redirect
|
|
189
|
+
# @api private
|
|
190
|
+
# @param request [Net::HTTPRequest] the original request
|
|
191
|
+
# @param uri [URI] the new URI
|
|
192
|
+
# @param preserve [Boolean] whether the new request keeps the method and the body, or is a GET
|
|
193
|
+
# @param headers [Hash] additional headers for the request
|
|
194
|
+
# @param authenticator [Authenticator] the authenticator
|
|
195
|
+
# @return [Net::HTTPRequest] the new request
|
|
196
|
+
def build_request(request, uri, preserve, headers, authenticator)
|
|
197
|
+
http_method = :get
|
|
198
|
+
if preserve
|
|
199
|
+
http_method = request.method.downcase.to_sym
|
|
200
|
+
body = request.body
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
request_builder.build(http_method:, uri:, body:, headers:, authenticator:)
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
private_constant :RedirectHandler
|
|
207
|
+
end
|
|
208
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "monitor"
|
|
4
|
+
require_relative "errors/token_report_failed"
|
|
5
|
+
|
|
6
|
+
module X
|
|
7
|
+
module Core
|
|
8
|
+
# Reports the refreshes of an OAuth 2.0 authenticator to the callables that store their tokens
|
|
9
|
+
#
|
|
10
|
+
# The refreshes are reported one at a time, in the order they were made, and a refresh another has replaced by
|
|
11
|
+
# the time it is reported is not reported at all: a callable that stores the tokens of a refresh as another
|
|
12
|
+
# stores the tokens that replaced them would otherwise overwrite them with a refresh token X no longer accepts.
|
|
13
|
+
# A callable that raises keeps none of the others from being passed the tokens, and once every one has been,
|
|
14
|
+
# TokenReportFailed is raised, which holds the tokens, since the refresh token they replaced is spent, with the
|
|
15
|
+
# first error as its cause. A Timeout::Error is a failure of a callable too, even one Timeout.timeout raises with
|
|
16
|
+
# its class around the request, since the error that holds the tokens is the only place left to find them; one it
|
|
17
|
+
# raises without a class is not a StandardError until it leaves the block, so it is raised as it is.
|
|
18
|
+
#
|
|
19
|
+
# The lock it reports under is reentrant, so a callable can make a request of its own, such as looking up the
|
|
20
|
+
# user whose tokens it stores, which may refresh and report again on the same thread.
|
|
21
|
+
#
|
|
22
|
+
# @api private
|
|
23
|
+
class RefreshReporter
|
|
24
|
+
# The message of the error raised when a callable raised for the tokens of a refresh
|
|
25
|
+
REPORT_FAILED = "The tokens were refreshed, but save_tokens raised for them"
|
|
26
|
+
# The fiber-local key of a callable that a gem extending a client sets, which the callables are run inside
|
|
27
|
+
#
|
|
28
|
+
# x-streams sets it while a stream runs, and x-uploads in each thread of a chunked upload, so that a stop of
|
|
29
|
+
# the stream, or of the upload, waits for save_tokens to store the tokens of a refresh, rather than cutting it
|
|
30
|
+
# short and leaving the store with a refresh token X no longer accepts. The callable is passed a block, which it
|
|
31
|
+
# runs and whose result it returns.
|
|
32
|
+
GUARD = :x_core_refresh_report_guard
|
|
33
|
+
|
|
34
|
+
# Initialize a reporter with no refresh to report
|
|
35
|
+
# @api private
|
|
36
|
+
# @return [RefreshReporter] a new reporter
|
|
37
|
+
def initialize
|
|
38
|
+
@monitor = Monitor.new
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Record the tokens of a refresh as the latest, while the refresh holds its lock
|
|
42
|
+
# @api private
|
|
43
|
+
# @param tokens [OAuth2Tokens] the tokens the refresh issued
|
|
44
|
+
# @return [OAuth2Tokens] the tokens
|
|
45
|
+
def issued(tokens) = (@latest = tokens)
|
|
46
|
+
|
|
47
|
+
# Pass each refresh to the callables another reads
|
|
48
|
+
# @api private
|
|
49
|
+
# @param hooks [#call] a callable that returns the callables to pass each refresh, read at each refresh
|
|
50
|
+
# @return [#call] the callable
|
|
51
|
+
def to(hooks) = (@hooks = hooks)
|
|
52
|
+
|
|
53
|
+
# Pass the tokens of a refresh to the callables, unless a later one replaced them
|
|
54
|
+
# @api private
|
|
55
|
+
# @param tokens [OAuth2Tokens] the tokens the refresh issued
|
|
56
|
+
# @param client [Client, nil] the client whose request refreshed, which the error holds, or nil for none
|
|
57
|
+
# @return [void]
|
|
58
|
+
# @raise [TokenReportFailed] if a callable raises, with the tokens and client, once every one has been passed them
|
|
59
|
+
def report(tokens, client)
|
|
60
|
+
@monitor.synchronize { guarded { pass(tokens, client) } if tokens.equal?(@latest) }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
# Run a block inside the guard of the fiber, if a gem extending the client set one
|
|
66
|
+
# @api private
|
|
67
|
+
# @yield passes the tokens to the callables
|
|
68
|
+
# @return [void]
|
|
69
|
+
def guarded(&) = (guard = Thread.current[GUARD]) ? guard.call(&) : yield
|
|
70
|
+
|
|
71
|
+
# Pass tokens to each callable, raising for the first error once all have run
|
|
72
|
+
# @api private
|
|
73
|
+
# @param tokens [OAuth2Tokens] the tokens the refresh issued
|
|
74
|
+
# @param client [Client, nil] the client whose request refreshed, or nil for none
|
|
75
|
+
# @return [void]
|
|
76
|
+
# @raise [TokenReportFailed] if a callable raises, with the tokens and client, and the first error as its cause
|
|
77
|
+
def pass(tokens, client)
|
|
78
|
+
hooks = @hooks
|
|
79
|
+
return unless hooks
|
|
80
|
+
|
|
81
|
+
errors = hooks.call.filter_map do |hook|
|
|
82
|
+
hook.call(tokens)
|
|
83
|
+
nil
|
|
84
|
+
rescue => e
|
|
85
|
+
e
|
|
86
|
+
end
|
|
87
|
+
raise TokenReportFailed.new(REPORT_FAILED, client:, tokens:), cause: errors.first unless errors.empty?
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
private_constant :RefreshReporter
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
require_relative "authenticator"
|
|
6
|
+
require_relative "authenticator_request"
|
|
7
|
+
require_relative "version"
|
|
8
|
+
|
|
9
|
+
module X
|
|
10
|
+
module Core
|
|
11
|
+
# Builds HTTP requests for the X API
|
|
12
|
+
#
|
|
13
|
+
# Internal to x-core: Client builds its requests with it.
|
|
14
|
+
#
|
|
15
|
+
# @api private
|
|
16
|
+
class RequestBuilder
|
|
17
|
+
# Default headers for API requests
|
|
18
|
+
DEFAULT_HEADERS = {
|
|
19
|
+
"User-Agent" => "x-ruby/#{Core::VERSION} #{RUBY_ENGINE}/#{RUBY_VERSION} (#{RUBY_PLATFORM})"
|
|
20
|
+
}.freeze
|
|
21
|
+
# The headers of a request that carries a body, which one without a body does not send, since it has no body
|
|
22
|
+
# for a content type to describe
|
|
23
|
+
BODY_HEADERS = {"Content-Type" => "application/json; charset=utf-8"}.freeze
|
|
24
|
+
# Mapping of HTTP method symbols to Net::HTTP classes
|
|
25
|
+
HTTP_METHODS = {
|
|
26
|
+
get: Net::HTTP::Get,
|
|
27
|
+
post: Net::HTTP::Post,
|
|
28
|
+
put: Net::HTTP::Put,
|
|
29
|
+
delete: Net::HTTP::Delete
|
|
30
|
+
}.freeze
|
|
31
|
+
# The HTTP methods a request may be sent again with, since sending one again asks the API for what sending it
|
|
32
|
+
# once did, where a second POST would post a second time
|
|
33
|
+
IDEMPOTENT_METHODS = %i[get put delete].freeze
|
|
34
|
+
|
|
35
|
+
# Check whether sending a request again has the same effect as sending it once
|
|
36
|
+
#
|
|
37
|
+
# @api private
|
|
38
|
+
# @param http_method [Symbol] the HTTP method (:get, :post, :put, :delete)
|
|
39
|
+
# @return [Boolean] true for a GET, PUT, or DELETE
|
|
40
|
+
# @example Check whether a post may be sent again
|
|
41
|
+
# X::Core::RequestBuilder.idempotent?(:post) # => false
|
|
42
|
+
def self.idempotent?(http_method) = IDEMPOTENT_METHODS.include?(http_method)
|
|
43
|
+
|
|
44
|
+
# Merge headers over others, as HTTP names them, without regard to case
|
|
45
|
+
#
|
|
46
|
+
# A header of overrides is sent in place of one of headers whose name differs from it in case alone, as
|
|
47
|
+
# "user-agent" is sent in place of "User-Agent", where Hash#merge would keep both and send whichever came last.
|
|
48
|
+
#
|
|
49
|
+
# @api private
|
|
50
|
+
# @param headers [Hash{String => String}] the headers overridden
|
|
51
|
+
# @param overrides [Hash{String => String}] the headers sent in place of those of the same name
|
|
52
|
+
# @return [Hash{String => String}] the headers of both, those of overrides in place of the others
|
|
53
|
+
# @example Replace the User-Agent of a client with one named in lowercase
|
|
54
|
+
# X::Core::RequestBuilder.merge_headers({"User-Agent" => "a"}, {"user-agent" => "b"}) # => {"user-agent" => "b"}
|
|
55
|
+
def self.merge_headers(headers, overrides)
|
|
56
|
+
headers.reject { |name, _| overrides.any? { |override, _| override.casecmp?(name) } }.merge(overrides)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Build an HTTP request
|
|
60
|
+
#
|
|
61
|
+
# @api private
|
|
62
|
+
# @param http_method [Symbol] the HTTP method (:get, :post, :put, :delete)
|
|
63
|
+
# @param uri [URI] the request URI
|
|
64
|
+
# @param body [String, nil] the request body
|
|
65
|
+
# @param headers [Hash] additional headers for the request
|
|
66
|
+
# @param authenticator [Authenticator] the authenticator for the request
|
|
67
|
+
# @return [Net::HTTPRequest] the built HTTP request
|
|
68
|
+
# @raise [ArgumentError] if the HTTP method is not supported
|
|
69
|
+
# @example Build a GET request
|
|
70
|
+
# builder.build(http_method: :get, uri: URI("https://api.x.com/2/users/me"))
|
|
71
|
+
def build(http_method:, uri:, body: nil, headers: {}, authenticator: Authenticator.new)
|
|
72
|
+
request = create_request(http_method:, uri:, body:)
|
|
73
|
+
add_headers(request:, headers:, body:)
|
|
74
|
+
add_authentication(request:, authenticator:)
|
|
75
|
+
request
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
# Create an HTTP request
|
|
81
|
+
# @api private
|
|
82
|
+
# @param http_method [Symbol] the HTTP method
|
|
83
|
+
# @param uri [URI] the request URI
|
|
84
|
+
# @param body [String, nil] the request body
|
|
85
|
+
# @return [Net::HTTPRequest] the created request
|
|
86
|
+
def create_request(http_method:, uri:, body:)
|
|
87
|
+
http_method_class = HTTP_METHODS[http_method]
|
|
88
|
+
|
|
89
|
+
raise ArgumentError, "Unsupported HTTP method: #{http_method}" unless http_method_class
|
|
90
|
+
|
|
91
|
+
escaped_uri = escape_query_params(uri)
|
|
92
|
+
request = http_method_class.new(escaped_uri)
|
|
93
|
+
request.body = body
|
|
94
|
+
request
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Add authentication to a request
|
|
98
|
+
# @api private
|
|
99
|
+
# @param request [Net::HTTPRequest] the request
|
|
100
|
+
# @param authenticator [Authenticator] the authenticator
|
|
101
|
+
# @return [void]
|
|
102
|
+
def add_authentication(request:, authenticator:)
|
|
103
|
+
authenticator.headers(AuthenticatorRequest.new(request)).each do |key, value|
|
|
104
|
+
request[key] = value
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Add headers to a request
|
|
109
|
+
#
|
|
110
|
+
# A request that carries a body is given the JSON content type the API takes, which a header of the caller, such
|
|
111
|
+
# as the form content type of a request given form fields, replaces. A request without a body, such as a GET or a
|
|
112
|
+
# DELETE, is given no content type at all, rather than one for a body it does not send.
|
|
113
|
+
#
|
|
114
|
+
# @api private
|
|
115
|
+
# @param request [Net::HTTPRequest] the request
|
|
116
|
+
# @param headers [Hash] additional headers
|
|
117
|
+
# @param body [String, nil] the request body, which decides whether a content type is sent
|
|
118
|
+
# @return [void]
|
|
119
|
+
def add_headers(request:, headers:, body:)
|
|
120
|
+
defaults = body.nil? ? DEFAULT_HEADERS : DEFAULT_HEADERS.merge(BODY_HEADERS)
|
|
121
|
+
RequestBuilder.merge_headers(defaults, headers).each do |key, value|
|
|
122
|
+
request[key] = value
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Escape query parameters in a URI
|
|
127
|
+
#
|
|
128
|
+
# A parameter without a value, as the flag of "?flag" is, is sent without one, rather than with the empty value
|
|
129
|
+
# of "?flag=", which an endpoint may read otherwise.
|
|
130
|
+
#
|
|
131
|
+
# @api private
|
|
132
|
+
# @param uri [URI] the URI
|
|
133
|
+
# @return [URI] the URI with escaped query parameters
|
|
134
|
+
def escape_query_params(uri)
|
|
135
|
+
URI(uri).tap do |u|
|
|
136
|
+
u.query = URI.encode_www_form(query_pairs(u.query)).gsub("%2C", ",") if u.query
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Decode the name and value of each parameter of a query
|
|
141
|
+
#
|
|
142
|
+
# A parameter without a value is given nil for one.
|
|
143
|
+
# @api private
|
|
144
|
+
# @param query [String] the query
|
|
145
|
+
# @return [Array<Array(String, String), Array(String, nil)>] the name and value of each parameter
|
|
146
|
+
def query_pairs(query)
|
|
147
|
+
query.split("&").map do |pair|
|
|
148
|
+
name, value = pair.split("=", 2)
|
|
149
|
+
[URI.decode_www_form_component(name.to_s), value&.then { |escaped| URI.decode_www_form_component(escaped) }]
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
private_constant :RequestBuilder
|
|
154
|
+
end
|
|
155
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module X
|
|
4
|
+
module Core
|
|
5
|
+
# The request an error names: the method it was sent with and the URI it was sent to
|
|
6
|
+
#
|
|
7
|
+
# An error says what went wrong, and code that makes many requests needs to know which request it went wrong
|
|
8
|
+
# for. The errors a request raises are built with it, so each of them reads the method and URI of that request,
|
|
9
|
+
# and names it in its message, as "GET /2/users/1: Could not find user" does.
|
|
10
|
+
#
|
|
11
|
+
# A request that names another host is not followed there with the credentials of the API, so the URI is the
|
|
12
|
+
# one the request was sent to, whole, rather than a path read against a base URL.
|
|
13
|
+
#
|
|
14
|
+
# Internal to x-core: the errors of a request include it, and the parsers and the connection build those
|
|
15
|
+
# errors with the request that raised them.
|
|
16
|
+
#
|
|
17
|
+
# @api private
|
|
18
|
+
module RequestContext
|
|
19
|
+
# The HTTP method the request was sent with
|
|
20
|
+
#
|
|
21
|
+
# @api public
|
|
22
|
+
# @return [Symbol, nil] the method, as :get, :post, :put, or :delete, or nil for an error built without one,
|
|
23
|
+
# such as one a test built from a response alone
|
|
24
|
+
# @example Tell a read that failed from a write
|
|
25
|
+
# writes_failed += 1 unless error.http_method.eql?(:get)
|
|
26
|
+
attr_reader :http_method
|
|
27
|
+
|
|
28
|
+
# The URI the request was sent to
|
|
29
|
+
#
|
|
30
|
+
# @api public
|
|
31
|
+
# @return [URI::Generic, nil] the URI, or nil for an error built without one
|
|
32
|
+
# @example Count the failures of each endpoint
|
|
33
|
+
# failures[error.uri.path] += 1
|
|
34
|
+
attr_reader :uri
|
|
35
|
+
|
|
36
|
+
# The method and URI of a request, as the keywords of an error that names it
|
|
37
|
+
#
|
|
38
|
+
# The errors take the method and URI of a request rather than the Net::HTTP request itself, so that code that
|
|
39
|
+
# builds one, as a test does, depends on neither Net::HTTP nor the way x-core sends a request.
|
|
40
|
+
#
|
|
41
|
+
# @api private
|
|
42
|
+
# @param request [Net::HTTPRequest, nil] the request that failed, or nil for none
|
|
43
|
+
# @return [Hash{Symbol => Object}, nil] the http_method and uri of the request, or nil, which splats no keywords,
|
|
44
|
+
# for none
|
|
45
|
+
# @example The keywords of a request
|
|
46
|
+
# RequestContext.of(request) # => {http_method: "GET", uri: #<URI::HTTPS https://api.x.com/2/users/me>}
|
|
47
|
+
def self.of(request) = request && {http_method: request.method, uri: request.uri}
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
# Read the method, URI, and line of the request an error names
|
|
52
|
+
#
|
|
53
|
+
# The line is the method and the path the request was sent for, which Net::HTTP reads off the URI, so that a
|
|
54
|
+
# URI naming a host and nothing else is named as the request for its root that it was sent as.
|
|
55
|
+
#
|
|
56
|
+
# @api private
|
|
57
|
+
# @param http_method [Symbol, String, nil] the method the request was sent with, in any case, or nil for none
|
|
58
|
+
# @param uri [URI::Generic, nil] the URI the request was sent to, or nil for none
|
|
59
|
+
# @return [void]
|
|
60
|
+
def name_request(http_method, uri)
|
|
61
|
+
@http_method = http_method&.downcase&.to_sym
|
|
62
|
+
@uri = uri
|
|
63
|
+
return unless http_method && uri
|
|
64
|
+
|
|
65
|
+
path = uri.path #: String
|
|
66
|
+
@request_line = "#{http_method.upcase} #{path.empty? ? "/" : path}"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# The message of an error, behind the request it names
|
|
70
|
+
#
|
|
71
|
+
# The query is left out, since the object layer asks for every field of a resource, which makes a query
|
|
72
|
+
# longer than the rest of the message.
|
|
73
|
+
#
|
|
74
|
+
# @api private
|
|
75
|
+
# @param message [String] what went wrong
|
|
76
|
+
# @return [String] the message, behind the method and path of the request an error names, or as it is for an
|
|
77
|
+
# error that names none
|
|
78
|
+
def message_naming_request(message)
|
|
79
|
+
line = @request_line
|
|
80
|
+
line.nil? ? message : "#{line}: #{message}"
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
private_constant :RequestContext
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
module X
|
|
7
|
+
module Core
|
|
8
|
+
# Encodes the query strings and bodies of requests
|
|
9
|
+
#
|
|
10
|
+
# Internal to x-core: a client resolves the endpoint of each request, and of each stream it opens, and encodes
|
|
11
|
+
# the body of a request, with it.
|
|
12
|
+
#
|
|
13
|
+
# @api private
|
|
14
|
+
module RequestEncoding
|
|
15
|
+
extend self
|
|
16
|
+
|
|
17
|
+
# The slashes that begin an endpoint, which would resolve against the host of the base URL rather than its path
|
|
18
|
+
LEADING_SLASHES = %r{\A/+}
|
|
19
|
+
private_constant :LEADING_SLASHES
|
|
20
|
+
|
|
21
|
+
# The message of the error raised for a request given both a body and form fields
|
|
22
|
+
BODY_AND_FORM = "Pass a body or form fields, not both, since a request sends one body"
|
|
23
|
+
private_constant :BODY_AND_FORM
|
|
24
|
+
|
|
25
|
+
# The message of the error raised for an endpoint that does not resolve to a URL a request can be sent to
|
|
26
|
+
INVALID_ENDPOINT = "Invalid endpoint %s: %s"
|
|
27
|
+
private_constant :INVALID_ENDPOINT
|
|
28
|
+
|
|
29
|
+
# The message of the error raised for an endpoint that is not a String
|
|
30
|
+
ENDPOINT_NOT_A_STRING = "endpoint must be a String, such as \"users/me\", not a %s"
|
|
31
|
+
private_constant :ENDPOINT_NOT_A_STRING
|
|
32
|
+
|
|
33
|
+
# The message of the error raised for a body that is not a String, a Hash, or an Array
|
|
34
|
+
BODY_NOT_ENCODABLE = "body must be a String, a Hash, or an Array, not a %s; read an IO, or call to_h, to give what it holds"
|
|
35
|
+
private_constant :BODY_NOT_ENCODABLE
|
|
36
|
+
|
|
37
|
+
# Resolve an endpoint and its query parameters against a base URL
|
|
38
|
+
#
|
|
39
|
+
# An endpoint that is not a String, such as a Symbol or a URI, raises ArgumentError naming its class, rather than
|
|
40
|
+
# the NoMethodError of a String method it does not have. An endpoint that is not a valid URL reference, such as one that holds a space or a malformed percent escape,
|
|
41
|
+
# or that resolves to anything but an http or https URL with a host, such as "foo:bar", raises ArgumentError
|
|
42
|
+
# naming it, before any request is built, rather than the URI::InvalidURIError or the ArgumentError of Net::HTTP
|
|
43
|
+
# it would raise as the request was built.
|
|
44
|
+
#
|
|
45
|
+
# @api private
|
|
46
|
+
# @param base_url [String] the base URL the endpoint is relative to
|
|
47
|
+
# @param endpoint [String] the endpoint, with or without a leading slash or a query string
|
|
48
|
+
# @param params [Hash, nil] the query parameters
|
|
49
|
+
# @return [URI::HTTP] the URL of the request
|
|
50
|
+
# @raise [ArgumentError] if the endpoint is not a String, or does not resolve to an http or https URL with a host
|
|
51
|
+
def uri_for(base_url, endpoint, params)
|
|
52
|
+
uri = URI.join(base_url, endpoint_with(endpoint, params))
|
|
53
|
+
return uri if uri.is_a?(URI::HTTP) && !uri.host.to_s.empty?
|
|
54
|
+
|
|
55
|
+
raise ArgumentError, format(INVALID_ENDPOINT, endpoint.inspect, "it does not name an http or https URL")
|
|
56
|
+
rescue URI::InvalidURIError
|
|
57
|
+
raise ArgumentError, format(INVALID_ENDPOINT, endpoint.inspect, "it is not a valid URL; escape what a URL may not hold, such as a space")
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Encode a form as form fields, and a Hash or an Array body as JSON
|
|
61
|
+
#
|
|
62
|
+
# A String body is given back as it is.
|
|
63
|
+
#
|
|
64
|
+
# Form fields are encoded as query parameters are, so a field of nil is dropped, an Array is joined with commas,
|
|
65
|
+
# and a Time is given in UTC in the ISO 8601 form the API takes. A Hash or an Array is encoded as JSON, since
|
|
66
|
+
# Net::HTTP sends nothing but a String. A body of any other class, such as an IO or a Symbol, raises
|
|
67
|
+
# ArgumentError naming its class, before any request is built, rather than be sent as the JSON of its to_s,
|
|
68
|
+
# which is the name of the object rather than what it holds.
|
|
69
|
+
#
|
|
70
|
+
# @api private
|
|
71
|
+
# @param body [String, Hash, Array, nil] the request body
|
|
72
|
+
# @param form [Hash, nil] the form fields
|
|
73
|
+
# @return [String, nil] the encoded body
|
|
74
|
+
# @raise [ArgumentError] if both a body and form fields are given, which would send one and drop the other
|
|
75
|
+
# @raise [ArgumentError] if the body is not a String, a Hash, an Array, or nil
|
|
76
|
+
def encode_body(body, form)
|
|
77
|
+
raise ArgumentError, BODY_AND_FORM if body && form
|
|
78
|
+
return encode_fields(form) unless form.nil?
|
|
79
|
+
|
|
80
|
+
case body
|
|
81
|
+
when nil, String then body
|
|
82
|
+
when Hash, Array then JSON.generate(body)
|
|
83
|
+
else raise ArgumentError, format(BODY_NOT_ENCODABLE, body.class)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
# Append query parameters to an endpoint, relative to the base URL
|
|
90
|
+
#
|
|
91
|
+
# An endpoint resolves against the base URL, which would drop the path of the base URL, such as the /2/ of the
|
|
92
|
+
# API version, for an endpoint that begins with a slash, so leading slashes are removed.
|
|
93
|
+
#
|
|
94
|
+
# @api private
|
|
95
|
+
# @param endpoint [String] the endpoint, with or without a leading slash or a query string
|
|
96
|
+
# @param params [Hash, nil] the query parameters
|
|
97
|
+
# @return [String] the endpoint, without leading slashes, with the parameters in its query string
|
|
98
|
+
# @raise [ArgumentError] if the endpoint is not a String
|
|
99
|
+
def endpoint_with(endpoint, params)
|
|
100
|
+
raise ArgumentError, format(ENDPOINT_NOT_A_STRING, endpoint.class) unless endpoint.is_a?(String)
|
|
101
|
+
|
|
102
|
+
endpoint = endpoint.sub(LEADING_SLASHES, "")
|
|
103
|
+
query = encode_fields(params.to_h)
|
|
104
|
+
return endpoint if query.empty?
|
|
105
|
+
|
|
106
|
+
"#{endpoint}#{endpoint.include?("?") ? "&" : "?"}#{query}"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Encode query parameters or form fields
|
|
110
|
+
#
|
|
111
|
+
# A field of nil is dropped, and the others are encoded with query_value.
|
|
112
|
+
#
|
|
113
|
+
# @api private
|
|
114
|
+
# @param fields [Hash] the parameters or fields
|
|
115
|
+
# @return [String] the encoded fields
|
|
116
|
+
def encode_fields(fields) = URI.encode_www_form(fields.compact.transform_values { |value| query_value(value) })
|
|
117
|
+
|
|
118
|
+
# Encode a query parameter or form field value
|
|
119
|
+
#
|
|
120
|
+
# An Array is joined with commas, and a Time is given in UTC in the ISO 8601 form the API takes.
|
|
121
|
+
#
|
|
122
|
+
# @api private
|
|
123
|
+
# @param value [Object] the value
|
|
124
|
+
# @return [Object] the encoded value
|
|
125
|
+
def query_value(value)
|
|
126
|
+
case value
|
|
127
|
+
when Array then value.join(",")
|
|
128
|
+
when Time then value.getutc.iso8601
|
|
129
|
+
else value
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
private_constant :RequestEncoding
|
|
134
|
+
end
|
|
135
|
+
end
|