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,198 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "simple_oauth"
4
+ require_relative "authenticator"
5
+ require_relative "connection"
6
+ require_relative "credential_validator"
7
+ require_relative "setting_validator"
8
+ require_relative "errors/authorization_error"
9
+ require_relative "errors/unauthorized"
10
+ require_relative "origin"
11
+ require_relative "token_endpoint"
12
+
13
+ module X
14
+ module Core
15
+ # Authenticates as an app with a bearer token, fetched with the API key and secret when first needed
16
+ #
17
+ # A bearer token the API rejects with 401 Unauthorized, as it does one that was invalidated, is dropped, and the
18
+ # request is sent again with one fetched in its place.
19
+ #
20
+ # @api public
21
+ class ::X::AppOnlyAuthenticator < Authenticator
22
+ # The endpoint that exchanges an API key and secret for a bearer token, at X, whose path is requested at the
23
+ # origin of the base URL of the client that takes the authenticator
24
+ TOKEN_URL = "https://api.x.com/oauth2/token"
25
+ # The message raised when the token endpoint describes no reason for the failure
26
+ DEFAULT_ERROR_MESSAGE = "Bearer token request failed"
27
+ private_constant :TOKEN_URL, :DEFAULT_ERROR_MESSAGE
28
+
29
+ # The API key
30
+ # @api public
31
+ # @return [String] the API key
32
+ # @example Get the API key
33
+ # authenticator.api_key
34
+ attr_reader :api_key
35
+
36
+ # Initialize a new app-only authenticator
37
+ #
38
+ # @api public
39
+ # @param api_key [String] the API key
40
+ # @param api_key_secret [String] the API key secret
41
+ # @param bearer_token [String, nil] a bearer token already fetched with these credentials
42
+ # @return [AppOnlyAuthenticator] a new authenticator
43
+ # @raise [ArgumentError] if the API key or secret is nil or empty, or the bearer token is empty
44
+ # @example Create an app-only authenticator
45
+ # X::AppOnlyAuthenticator.new(api_key: "key", api_key_secret: "secret")
46
+ def initialize(api_key:, api_key_secret:, bearer_token: nil)
47
+ CredentialValidator.validate_required!({api_key:, api_key_secret:}, {bearer_token:})
48
+ @api_key, @api_key_secret = SettingValidator.frozen(api_key), SettingValidator.frozen(api_key_secret)
49
+ @bearer_token = SettingValidator.frozen(bearer_token)
50
+ @connection, @token_url, @token_headers = Connection.new, TOKEN_URL, {}
51
+ @mutex = Mutex.new
52
+ end
53
+
54
+ # Generate the authentication headers, fetching the bearer token first if needed
55
+ #
56
+ # @api public
57
+ # @param _request [#http_method, #uri, #body, #[], nil] the request, which app-only authentication does not sign
58
+ # @return [Hash{String => String}] the authorization header
59
+ # @raise [AuthorizationError] if X refuses to issue the bearer token
60
+ # @raise [HTTPError, InvalidResponse] if the token endpoint limits the rate of the request or fails to answer,
61
+ # as a server error, a redirect, or the page of a proxy says
62
+ # @example Generate the header
63
+ # authenticator.headers(request) # => {"Authorization" => "Bearer ..."}
64
+ def headers(_request)
65
+ {AUTHENTICATION_HEADER => "Bearer #{bearer_token}"}
66
+ end
67
+
68
+ private
69
+
70
+ # The bearer token, fetched once with the API key and secret
71
+ #
72
+ # It is a secret, so it is private, as the bearer token of a BearerTokenAuthenticator is. Internal to x-core:
73
+ # a client that authenticates as the app fetches its token through it, and calls it with __send__.
74
+ #
75
+ # @api private
76
+ # @return [String] the bearer token
77
+ # @raise [AuthorizationError] if X refuses to issue the bearer token
78
+ # @raise [HTTPError, InvalidResponse] if the token endpoint limits the rate of the request or fails to answer,
79
+ # as a server error, a redirect, or the page of a proxy says
80
+ # @example Get the bearer token
81
+ # bearer_token
82
+ def bearer_token
83
+ @mutex.synchronize { @bearer_token ||= fetch_bearer_token }
84
+ end
85
+
86
+ # The connection the bearer token is fetched over
87
+ # @api private
88
+ # @return [Core::Connection] the connection
89
+ attr_reader :connection
90
+
91
+ # Fetch the bearer token over the connection of the first client that takes it
92
+ #
93
+ # The token endpoint is requested at the scheme, host, and port of the base URL of the client, rather than of
94
+ # TOKEN_URL, so that a client pointed at another host sends the API key and secret there, as it sends its
95
+ # requests. A client that takes it after the first leaves it fetching over the connection of the first; see
96
+ # {OAuth2Authenticator}. Internal to x-core: a client fetches the token of the authenticator it builds, or is
97
+ # given, over its own connection, with its proxy, timeouts, debug output, and headers, and calls it with
98
+ # __send__, since it is private.
99
+ #
100
+ # @api private
101
+ # @param connection [Core::Connection] the connection to fetch the token over
102
+ # @param base_url [String] the base URL of the client, at whose origin the token endpoint is requested
103
+ # @param headers [Hash{String => String}] the headers of the client, which the token request is sent with
104
+ # @return [AppOnlyAuthenticator] the authenticator
105
+ def token_requests_over(connection, base_url, headers)
106
+ @mutex.synchronize do
107
+ unless @taken
108
+ @connection = connection
109
+ @token_url = TokenEndpoint.url_at(base_url, TOKEN_URL)
110
+ @token_headers = headers
111
+ end
112
+ @taken = true
113
+ end
114
+ self
115
+ end
116
+
117
+ # The API key secret, which buys the bearer token
118
+ # @api private
119
+ # @return [String] the API key secret
120
+ # @example Buy a bearer token with the API key secret
121
+ # api_key_secret
122
+ attr_reader :api_key_secret
123
+
124
+ # Check whether a copy of a client, given these options, holds these credentials
125
+ #
126
+ # Internal to x-core: Client shares the authenticator with a copy that holds its API key and secret and was given
127
+ # no bearer token, and calls it with __send__, since it is private.
128
+ #
129
+ # @api private
130
+ # @param options [Hash] the options the copy was given in place of the client's
131
+ # @return [Boolean] true if the options name no bearer token, and no API key or secret but the ones held
132
+ def holds?(options)
133
+ !options.key?(:bearer_token) && options.slice(:api_key, :api_key_secret) <= {api_key:, api_key_secret:}
134
+ end
135
+
136
+ # Check whether the bearer token is fetched at the origin of a base URL
137
+ #
138
+ # A token is fetched at the origin of the client that took the authenticator first, and sent there alone, so a
139
+ # copy of the client pointed at another origin fetches one of its own, rather than send it the client's.
140
+ # Internal to x-core: Client shares the authenticator with a copy at the same origin alone, and calls it with
141
+ # __send__, since it is private.
142
+ #
143
+ # @api private
144
+ # @param base_url [String] the base URL of the copy
145
+ # @return [Boolean] true if the token endpoint is at the origin of the base URL
146
+ def fetched_for?(base_url) = Origin.same?(URI(@token_url), URI(base_url))
147
+
148
+ # Run a request, again with a bearer token fetched in place of one the API rejects
149
+ #
150
+ # Only a rejection by the origin the token is sent to drops it; see {Core::Origin}. A token fetched for the
151
+ # request itself is not fetched again, since the endpoint that just issued it would issue it again. Internal to
152
+ # x-core: Client runs each request through it, and calls it with __send__, since it is private.
153
+ #
154
+ # @api private
155
+ # @param origin [URI::Generic] the base URL of the client, the origin the token is sent to
156
+ # @yield runs the request
157
+ # @return [Object] what the block returns
158
+ # @raise [Unauthorized] if the API rejects the token fetched in place of the one it rejected
159
+ def retrying_rejected_token(origin)
160
+ token = @bearer_token
161
+ begin
162
+ yield
163
+ rescue Unauthorized => e
164
+ raise unless token && Origin.answered?(e, origin)
165
+
166
+ drop_bearer_token(token)
167
+ yield
168
+ end
169
+ end
170
+
171
+ # Drop a bearer token the API rejected, unless another request already replaced it
172
+ # @api private
173
+ # @param rejected [String] the bearer token the API rejected
174
+ # @return [void]
175
+ def drop_bearer_token(rejected)
176
+ @mutex.synchronize { @bearer_token = nil if @bearer_token.eql?(rejected) }
177
+ end
178
+
179
+ # Exchange the API key and secret for a bearer token
180
+ # @api private
181
+ # @return [String] the bearer token
182
+ # @raise [AuthorizationError] if the token endpoint rejects the request
183
+ # @raise [HTTPError, InvalidResponse] if the token endpoint limits the rate of the request or fails to answer,
184
+ # as a server error, a redirect, or the page of a proxy says
185
+ def fetch_bearer_token
186
+ TokenEndpoint.fetch(token_request, connection:, refusal: DEFAULT_ERROR_MESSAGE, headers: @token_headers).access_token
187
+ end
188
+
189
+ # Build the client credentials request
190
+ # @api private
191
+ # @return [SimpleOAuth::OAuth2::Request] the token request
192
+ def token_request
193
+ SimpleOAuth::OAuth2::Client.new(client_id: api_key, client_secret: api_key_secret, token_endpoint: @token_url)
194
+ .client_credentials_request
195
+ end
196
+ end
197
+ end
198
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "credential_holder"
4
+
5
+ # A Ruby client for the X API
6
+ module X
7
+ module Core
8
+ # Base class for authentication
9
+ #
10
+ # Subclass it to authenticate with a scheme of your own, overriding {#headers}, as the authenticators of x-core do.
11
+ #
12
+ # @api public
13
+ class ::X::Authenticator
14
+ include CredentialHolder
15
+
16
+ # The HTTP header name for authentication
17
+ AUTHENTICATION_HEADER = "Authorization"
18
+
19
+ # Generate the authentication headers for a request, which authenticates as no one
20
+ #
21
+ # A client calls it for every request it sends to the origin of its base URL, and every stream it opens there,
22
+ # once the request is built, just before it is sent, and sends each header it returns, in place of any header of
23
+ # the same name. Its method, URI, headers, and body are set, so an authenticator of your own can sign any of them:
24
+ # subclass X::Authenticator and override this method, and give an instance to the authenticator: of
25
+ # X::Client.new or X::Client#with. A request to another origin, such as one a redirect leads to, is not passed to
26
+ # it, so its headers never leave the origin they were built for. It is called on the thread that sends the
27
+ # request, so one a client shares across threads must be thread-safe.
28
+ #
29
+ # The request answers four methods alone, which are all the authenticators of x-core read of it: http_method, the
30
+ # HTTP method as a Symbol, such as :post, as a response and an error name it; uri, the URI::Generic it is sent
31
+ # to, query included; body, the String it sends, or nil for none; and [], the value of a header by its name, in
32
+ # any case, or nil for one it does not send. It answers them alone whatever request the client sends, so an
33
+ # authenticator of your own reads nothing a later version of 1.x could take away.
34
+ #
35
+ # A client refreshes the token of none but its own OAuth 2.0 authenticator, so an authenticator of your own that
36
+ # holds a token that expires refreshes it here. A client given one takes it to authenticate as the app, as it
37
+ # does an X::Authenticator itself, so app_only returns the client, and a stream is opened with it.
38
+ #
39
+ # @api public
40
+ # @param _request [#http_method, #uri, #body, #[]] the request, which answers http_method, uri, body, and [] alone
41
+ # @return [Hash{String => String}] the headers that authenticate the request, empty for none
42
+ # @example Authenticate every request with a token an application keeps
43
+ # class VaultAuthenticator < X::Authenticator
44
+ # def headers(_request) = {AUTHENTICATION_HEADER => "Bearer #{Vault.read("x/bearer_token")}"}
45
+ # end
46
+ # client = X::Client.new(authenticator: VaultAuthenticator.new)
47
+ def headers(_request)
48
+ {}
49
+ end
50
+
51
+ # The identifier of the user the credentials act for, when they name one
52
+ #
53
+ # Only an OAuth 1.0a access token names its user, so every other set of credentials answers nil, and the caller
54
+ # that wants the user of such a client asks the API for it.
55
+ #
56
+ # @api public
57
+ # @return [Integer, nil] the identifier, or nil for credentials that name no user
58
+ # @example Read the user a client acts for without a request
59
+ # client.authenticator.user_id # => nil
60
+ def user_id
61
+ end
62
+
63
+ # Summarize the authenticator for the console without revealing credentials
64
+ #
65
+ # @api public
66
+ # @return [String] the class name
67
+ # @example Inspect an authenticator
68
+ # authenticator.inspect # => #<X::BearerTokenAuthenticator>
69
+ def inspect
70
+ "#<#{self.class}>"
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module X
4
+ module Core
5
+ # The request an authenticator is passed: what it may read of the request a client is about to send
6
+ #
7
+ # It answers the four methods an authenticator is guaranteed, and no more, so that an authenticator of your own
8
+ # reads nothing of the Net::HTTPRequest a client sends that a later version of 1.x, which may send another, could
9
+ # take away.
10
+ #
11
+ # Internal to x-core: RequestBuilder passes one to the authenticator of each request it builds.
12
+ #
13
+ # @api private
14
+ class AuthenticatorRequest
15
+ # Initialize the request an authenticator reads
16
+ # @api private
17
+ # @param request [Net::HTTPRequest] the request a client is about to send
18
+ # @return [AuthenticatorRequest] a new instance
19
+ # @example Pass a request to an authenticator
20
+ # authenticator.headers(X::Core::AuthenticatorRequest.new(request))
21
+ def initialize(request)
22
+ @request = request
23
+ end
24
+
25
+ # The HTTP method the request is sent with
26
+ # @api private
27
+ # @return [Symbol] the method, as :get, :post, :put, or :delete
28
+ # @example Read the method
29
+ # request.http_method # => :post
30
+ def http_method = @request.method.downcase.to_sym
31
+
32
+ # The URI the request is sent to, query included
33
+ # @api private
34
+ # @return [URI::Generic] the URI
35
+ # @example Read the URI
36
+ # request.uri # => #<URI::HTTPS https://api.x.com/2/tweets>
37
+ def uri = @request.uri
38
+
39
+ # The body the request sends
40
+ # @api private
41
+ # @return [String, nil] the body, or nil for none
42
+ # @example Read the body
43
+ # request.body # => "{\"text\":\"Hello\"}"
44
+ def body = @request.body
45
+
46
+ # The value of a header the request sends
47
+ # @api private
48
+ # @param name [String] the name of the header, in any case
49
+ # @return [String, nil] the value, or nil for a header the request does not send
50
+ # @example Read the content type
51
+ # request["Content-Type"] # => "application/json; charset=utf-8"
52
+ def [](name) = @request[name]
53
+ end
54
+ private_constant :AuthenticatorRequest
55
+ end
56
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "authenticator"
4
+ require_relative "credential_validator"
5
+ require_relative "setting_validator"
6
+
7
+ module X
8
+ module Core
9
+ # Authenticator for Bearer token authentication
10
+ # @api public
11
+ class ::X::BearerTokenAuthenticator < Authenticator
12
+ # Initialize a new BearerTokenAuthenticator
13
+ #
14
+ # @api public
15
+ # @param bearer_token [String] the bearer token for authentication
16
+ # @return [BearerTokenAuthenticator] a new instance
17
+ # @raise [ArgumentError] if the bearer token is nil or empty
18
+ # @example Create a new bearer token authenticator
19
+ # authenticator = X::BearerTokenAuthenticator.new(bearer_token: "token")
20
+ def initialize(bearer_token:)
21
+ CredentialValidator.validate_required!({bearer_token:})
22
+ @bearer_token = SettingValidator.frozen(bearer_token)
23
+ end
24
+
25
+ # Generate the authentication headers for a request
26
+ #
27
+ # @api public
28
+ # @param _request [#http_method, #uri, #body, #[], nil] the request, which a bearer token does not sign
29
+ # @return [Hash{String => String}] the authentication header with bearer token
30
+ # @example Generate a bearer authentication header
31
+ # authenticator.headers(request)
32
+ def headers(_request)
33
+ {AUTHENTICATION_HEADER => "Bearer #{bearer_token}"}
34
+ end
35
+
36
+ private
37
+
38
+ # The bearer token, which authenticates a request
39
+ # @api private
40
+ # @return [String] the bearer token
41
+ # @example Authenticate with the bearer token
42
+ # bearer_token
43
+ attr_reader :bearer_token
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require_relative "setting_validator"
5
+
6
+ module X
7
+ module Core
8
+ # The HTTP response of an error or a summary built by hand, from its status, headers, and body
9
+ #
10
+ # Internal to x-core: HTTPError, InvalidResponse, and Response are built from the Net::HTTP response a client
11
+ # received, or, so that code that rescues one, or an on_response hook, can be tested without building one, from
12
+ # the status, headers, and body of a response, which this builds the Net::HTTP response of.
13
+ #
14
+ # @api private
15
+ module BuiltResponse
16
+ extend self
17
+
18
+ # The message of the error raised for a status that is not one HTTP defines
19
+ INVALID_STATUS = "status must be an Integer from 100 to 599, not %s"
20
+ private_constant :INVALID_STATUS
21
+ # The message of the error raised for a response given beside what one is built of
22
+ RESPONSE_AND_STATUS = "Pass the http_response:, or the status:, headers:, and body: one is built of, not both"
23
+ private_constant :RESPONSE_AND_STATUS
24
+ # The message of the error raised for neither a response nor a status
25
+ NO_RESPONSE = "Pass the status: of the response, with its headers: and body: if it has any, or the http_response: itself"
26
+ private_constant :NO_RESPONSE
27
+ # The statuses HTTP defines
28
+ STATUSES = 100..599
29
+ private_constant :STATUSES
30
+
31
+ # The HTTP response given, or the one built of a status, headers, and body
32
+ #
33
+ # @api private
34
+ # @param http_response [Net::HTTPResponse, nil] the HTTP response, or nil to build one
35
+ # @param status [Integer, nil] the status of the response to build, or nil for the one given
36
+ # @param headers [Hash{String => String}, nil] the headers of the response to build, or nil for none
37
+ # @param body [String, nil] the body of the response to build, or nil for none
38
+ # @return [Net::HTTPResponse] the HTTP response
39
+ # @raise [ArgumentError] if a response is given beside a status, headers, or a body, or neither a response nor a
40
+ # status is given, or the status is not one HTTP defines, or the headers are not a Hash of names to values
41
+ # @example Build a response the API refused a request with
42
+ # X::Core::BuiltResponse.of(nil, status: 404, headers: nil, body: "{}") # => #<Net::HTTPNotFound 404 Not Found>
43
+ def of(http_response, status:, headers:, body:)
44
+ return build(status, headers || {}, body) if http_response.nil? && !status.nil?
45
+ raise ArgumentError, NO_RESPONSE if http_response.nil?
46
+ raise ArgumentError, RESPONSE_AND_STATUS unless [status, headers, body].none?
47
+
48
+ http_response
49
+ end
50
+
51
+ private
52
+
53
+ # Build the HTTP response of a status, headers, and body
54
+ #
55
+ # Its body is tagged UTF-8, as the body of a response the client reads is.
56
+ #
57
+ # @api private
58
+ # @param status [Integer] the status
59
+ # @param headers [Hash{String => String}] the headers
60
+ # @param body [String, nil] the body
61
+ # @return [Net::HTTPResponse] the HTTP response
62
+ # @raise [ArgumentError] if the status is not one HTTP defines, or the headers are not a Hash of names to values
63
+ def build(status, headers, body)
64
+ response_class = response_class_of(status)
65
+ response_class.new("1.1", status.to_s, reason_of(response_class)).tap do |response|
66
+ SettingValidator.headers!(headers).each { |name, value| response[name] = value }
67
+ response.body = body.dup&.force_encoding(Encoding::UTF_8)
68
+ response.instance_variable_set(:@read, true)
69
+ end
70
+ end
71
+
72
+ # The class Net::HTTP reads a response of a status as
73
+ #
74
+ # It is the class of the status, such as Net::HTTPNotFound, or, for a status Net::HTTP names no class of, the
75
+ # class of its kind of status, such as Net::HTTPClientError for a 4xx.
76
+ #
77
+ # @api private
78
+ # @param status [Integer] the status
79
+ # @return [Class] the class
80
+ # @raise [ArgumentError] if the status is not one HTTP defines
81
+ def response_class_of(status)
82
+ raise ArgumentError, format(INVALID_STATUS, status.inspect) unless status.instance_of?(Integer) && STATUSES.cover?(status)
83
+
84
+ code = status.to_s
85
+ Net::HTTPResponse::CODE_TO_OBJ.fetch(code) { Net::HTTPResponse::CODE_CLASS_TO_OBJ.fetch(code[0]) }
86
+ end
87
+
88
+ # The reason phrase of the status of a Net::HTTP response class
89
+ #
90
+ # @api private
91
+ # @param response_class [Class] the class, such as Net::HTTPNotFound
92
+ # @return [String] the reason phrase, such as "Not Found"
93
+ def reason_of(response_class)
94
+ name = response_class.name #: String
95
+ name.delete_prefix("Net::HTTP").gsub(/(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])/, " ")
96
+ end
97
+ end
98
+ private_constant :BuiltResponse
99
+ end
100
+ end