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.
Files changed (83) hide show
  1. checksums.yaml +7 -0
  2. data/.yardopts +8 -0
  3. data/CHANGELOG.md +353 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +54 -0
  6. data/lib/x/core/app_only_authenticator.rb +198 -0
  7. data/lib/x/core/authenticator.rb +74 -0
  8. data/lib/x/core/authenticator_request.rb +56 -0
  9. data/lib/x/core/bearer_token_authenticator.rb +46 -0
  10. data/lib/x/core/built_response.rb +100 -0
  11. data/lib/x/core/client.rb +728 -0
  12. data/lib/x/core/client_app_only.rb +115 -0
  13. data/lib/x/core/client_credentials.rb +193 -0
  14. data/lib/x/core/client_internals.rb +315 -0
  15. data/lib/x/core/client_memo.rb +58 -0
  16. data/lib/x/core/client_settings.rb +166 -0
  17. data/lib/x/core/client_token_refresh.rb +281 -0
  18. data/lib/x/core/connection.rb +328 -0
  19. data/lib/x/core/connection_pool.rb +123 -0
  20. data/lib/x/core/connection_proxy.rb +151 -0
  21. data/lib/x/core/connection_request.rb +90 -0
  22. data/lib/x/core/credential_holder.rb +78 -0
  23. data/lib/x/core/credential_validator.rb +286 -0
  24. data/lib/x/core/errors/authorization_denied.rb +55 -0
  25. data/lib/x/core/errors/authorization_error.rb +41 -0
  26. data/lib/x/core/errors/bad_gateway.rb +9 -0
  27. data/lib/x/core/errors/bad_request.rb +10 -0
  28. data/lib/x/core/errors/callback_error.rb +88 -0
  29. data/lib/x/core/errors/client_error.rb +17 -0
  30. data/lib/x/core/errors/conflict.rb +9 -0
  31. data/lib/x/core/errors/error.rb +66 -0
  32. data/lib/x/core/errors/forbidden.rb +10 -0
  33. data/lib/x/core/errors/gateway_timeout.rb +9 -0
  34. data/lib/x/core/errors/gone.rb +9 -0
  35. data/lib/x/core/errors/http_error.rb +333 -0
  36. data/lib/x/core/errors/internal_server_error.rb +9 -0
  37. data/lib/x/core/errors/invalid_response.rb +96 -0
  38. data/lib/x/core/errors/method_not_allowed.rb +10 -0
  39. data/lib/x/core/errors/network_error.rb +59 -0
  40. data/lib/x/core/errors/not_acceptable.rb +10 -0
  41. data/lib/x/core/errors/not_found.rb +15 -0
  42. data/lib/x/core/errors/payload_too_large.rb +10 -0
  43. data/lib/x/core/errors/payment_required.rb +10 -0
  44. data/lib/x/core/errors/request_timeout.rb +9 -0
  45. data/lib/x/core/errors/server_error.rb +15 -0
  46. data/lib/x/core/errors/service_unavailable.rb +9 -0
  47. data/lib/x/core/errors/token_report_failed.rb +59 -0
  48. data/lib/x/core/errors/too_many_redirects.rb +53 -0
  49. data/lib/x/core/errors/too_many_requests.rb +80 -0
  50. data/lib/x/core/errors/unauthorized.rb +15 -0
  51. data/lib/x/core/errors/unavailable_for_legal_reasons.rb +10 -0
  52. data/lib/x/core/errors/unprocessable_entity.rb +10 -0
  53. data/lib/x/core/errors/unsupported_format.rb +22 -0
  54. data/lib/x/core/errors/unsupported_media_type.rb +10 -0
  55. data/lib/x/core/errors/unsupported_operation.rb +18 -0
  56. data/lib/x/core/oauth1_authenticator.rb +142 -0
  57. data/lib/x/core/oauth2_authenticator.rb +296 -0
  58. data/lib/x/core/oauth2_authorization.rb +405 -0
  59. data/lib/x/core/oauth2_refresh.rb +272 -0
  60. data/lib/x/core/oauth2_tokens.rb +277 -0
  61. data/lib/x/core/origin.rb +95 -0
  62. data/lib/x/core/problem.rb +302 -0
  63. data/lib/x/core/proxy_setting.rb +22 -0
  64. data/lib/x/core/rate_limit.rb +137 -0
  65. data/lib/x/core/rate_limit_handler.rb +166 -0
  66. data/lib/x/core/redirect_handler.rb +208 -0
  67. data/lib/x/core/refresh_reporter.rb +92 -0
  68. data/lib/x/core/request_builder.rb +155 -0
  69. data/lib/x/core/request_context.rb +85 -0
  70. data/lib/x/core/request_encoding.rb +135 -0
  71. data/lib/x/core/response.rb +196 -0
  72. data/lib/x/core/response_headers.rb +31 -0
  73. data/lib/x/core/response_parser.rb +155 -0
  74. data/lib/x/core/retry_handler.rb +149 -0
  75. data/lib/x/core/setting_validator.rb +399 -0
  76. data/lib/x/core/stream_body.rb +77 -0
  77. data/lib/x/core/stream_response.rb +267 -0
  78. data/lib/x/core/token_endpoint.rb +238 -0
  79. data/lib/x/core/version.rb +25 -0
  80. data/lib/x/core.rb +6 -0
  81. data/sig/manifest.yaml +8 -0
  82. data/sig/x-core.rbs +396 -0
  83. 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