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,267 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rate_limit"
4
+ require_relative "response_headers"
5
+ require_relative "stream_body"
6
+
7
+ module X
8
+ module Core
9
+ # The response of a request whose body is read as it arrives, which Client#get_stream passes to its block
10
+ #
11
+ # It is passed to the block before its body is read, so it reads the status and headers the API answered with,
12
+ # and the block reads the body with {#read_body}, for as long as it likes. It is x-core's own, rather than the
13
+ # response of the library the client sends its requests with, so that a block written for one release of 1.x
14
+ # reads the response of every other, whatever sends the request.
15
+ #
16
+ # @api public
17
+ class ::X::StreamResponse
18
+ include ResponseHeaders
19
+
20
+ # The message of the error a second read of the body raises
21
+ READ_ONCE = "The body of a stream is read once, and read_body has read it, whole or in part. Only a body " \
22
+ "read whole, without a block, is returned again"
23
+ # The message of the error a read of the body raises once the block of get_stream has returned
24
+ READ_OUTSIDE_BLOCK = "The body of a stream is read inside the block of get_stream, which has returned, and " \
25
+ "closed the connection the body is read from"
26
+ # The message of the error a read of a body that was read from the response of the transport raises
27
+ READ_ELSEWHERE = "The body of the stream was read from its http_response, which leaves read_body none to read"
28
+ # The message of the error a read of the body of a response that was frozen deeply raises
29
+ READ_DEEP_FROZEN = "The body of a stream cannot be read from a response that was frozen deeply, with what " \
30
+ "its reads are noted in and the connection the body is read from. A response frozen with freeze is read as " \
31
+ "any other"
32
+ # The end of the message of the IOError Net::HTTP raises for a body it is asked to read again
33
+ TRANSPORT_READ_TWICE = "#read_body called twice"
34
+ private_constant :READ_ONCE, :READ_OUTSIDE_BLOCK, :READ_ELSEWHERE, :READ_DEEP_FROZEN, :TRANSPORT_READ_TWICE
35
+
36
+ # What was read of the body of a response, which the response and its copies share
37
+ #
38
+ # It is an object of its own, rather than the state of the response, so that a response that was frozen counts
39
+ # its reads as any other does, and a dup or clone of a response counts them with it: the body they read is one.
40
+ #
41
+ # @api private
42
+ class ReadState
43
+ # Whether read_body has read the body, whole or in part
44
+ # @api private
45
+ # @return [true, nil] true once the body was read
46
+ attr_accessor :read
47
+
48
+ # Whether read_body has read the body whole, to return it again
49
+ # @api private
50
+ # @return [true, nil] true once the body was read whole
51
+ attr_accessor :whole
52
+
53
+ # Whether the block of Client#get_stream has returned
54
+ #
55
+ # The connection the body is read from is closed once it has.
56
+ #
57
+ # @api private
58
+ # @return [true, nil] true once the block has returned
59
+ attr_accessor :left
60
+
61
+ # The body read whole
62
+ # @api private
63
+ # @return [String, nil] the body, or nil for one not read whole, or a response without one
64
+ attr_accessor :body
65
+ end
66
+ private_constant :ReadState
67
+
68
+ # @!method headers
69
+ # The headers of the response
70
+ #
71
+ # The names are lowercase, and a field the API sent more than once is joined with a comma.
72
+ # @api public
73
+ # @return [Hash{String => String}] the headers, frozen
74
+ # @example Read the content type of a stream
75
+ # response.headers["content-type"]
76
+
77
+ # The URI of the request
78
+ # @api public
79
+ # @return [URI::Generic] the request URI
80
+ # @example Get the path of the request
81
+ # response.uri.path # => "/2/tweets/sample/stream"
82
+ attr_reader :uri
83
+
84
+ # The response itself, as the client received it, with its body not yet read
85
+ #
86
+ # It is an escape hatch, for what this does not read: the status is {#status}, the headers are {#headers}, and
87
+ # the body is read with {#read_body}. It is the response of the transport the client sends its requests with, a
88
+ # Net::HTTPResponse today, and its class is not covered by the compatibility promise of 1.x: a later release of
89
+ # 1.x may send its requests with another library, whose response this then returns. A body read from it, rather
90
+ # than with {#read_body}, raises the errors of its socket as they are, rather than as a NetworkError, which
91
+ # Client#get_stream raises as the block's own, and is not known to {#read_body}, which counts the reads of its
92
+ # own alone: once the body was read from it, in part or whole, {#read_body} reads what is left of it, returns
93
+ # the body it read whole, or raises an Error for a body it left none of.
94
+ #
95
+ # @api public
96
+ # @return [Net::HTTPResponse] the response of the transport
97
+ # @example Read the reason phrase of the status line
98
+ # response.http_response.message # => "OK"
99
+ attr_reader :http_response
100
+
101
+ # Initialize the response of a stream
102
+ #
103
+ # Internal to x-core: it takes the Net::HTTP response of a request, so that it can change within 1.x, as that
104
+ # response may, and is private, so the stream Client#get_stream opens builds it with __send__.
105
+ #
106
+ # @api private
107
+ # @param http_response [Net::HTTPResponse] the successful response, whose body is not yet read
108
+ # @param uri [URI::Generic] the URI of the request
109
+ # @return [StreamResponse] a new instance
110
+ # @example Build the response of a stream
111
+ # X::StreamResponse.__send__(:new, http_response:, uri: URI("https://api.x.com/2/tweets/sample/stream"))
112
+ def initialize(http_response:, uri:)
113
+ @http_response = http_response
114
+ @uri = uri
115
+ @state = ReadState.new
116
+ end
117
+ private_class_method :new
118
+
119
+ # The HTTP status code
120
+ #
121
+ # @api public
122
+ # @return [Integer] the status code
123
+ # @example Get the status code
124
+ # response.status # => 200
125
+ def status = Integer(http_response.code)
126
+
127
+ # The rate limits the response reports in its headers
128
+ #
129
+ # @api public
130
+ # @return [Array<RateLimit>] the 15-minute limit, and the 24-hour app and user limits when reported
131
+ # @example Print how many connections remain in each window
132
+ # response.rate_limits.each { |limit| puts "#{limit.type}: #{limit.remaining}" }
133
+ def rate_limits = RateLimit.__send__(:all_from, http_response)
134
+
135
+ # The 15-minute rate limit of the endpoint
136
+ #
137
+ # For a stream, it counts the connections that may be opened in the window.
138
+ #
139
+ # @api public
140
+ # @return [RateLimit, nil] the rate limit, or nil if the response reports none
141
+ # @example Read how many times a stream may connect again before the limit resets
142
+ # response.rate_limit&.remaining # => 49
143
+ def rate_limit = rate_limits.find { |limit| limit.type.eql?(RateLimit::RATE_LIMIT_TYPE) }
144
+
145
+ # Read the body, a chunk at a time as it arrives, or whole
146
+ #
147
+ # Given a block, each chunk of the body is passed to it as it arrives, until the body ends, which the body of a
148
+ # stream does only when its connection closes. A chunk is whatever arrived, in binary, since it may end within a
149
+ # line, or within a character, which no encoding but binary reads half of. Without a block, the body is read to
150
+ # its end, and returned whole, tagged UTF-8, the encoding of the JSON the API sends, as the body of a
151
+ # {Response} is: a body that is not valid UTF-8 keeps its bytes, so valid_encoding? tells it apart, and scrub
152
+ # replaces what is not UTF-8. A response without a body, such as a 204, has none to return.
153
+ #
154
+ # The body is read once, inside the block of Client#get_stream, since it is read from a connection that is
155
+ # closed once that block returns. A body read whole, without a block, is returned again by each read without a
156
+ # block that follows, as a String of its own. Any other second read raises an Error that says so, with or
157
+ # without a block, after a read that passed the chunks to a block, even one the block left partway through, with
158
+ # break, throw, or an error, or after a read that failed; so does any read once the block of Client#get_stream
159
+ # has returned. The Error is the caller's, not the socket's, so it is neither a NetworkError nor retried. Only
160
+ # the reads of this method are counted: a body read from {#http_response} is not known to it, and a read of a
161
+ # body that was read to its end there, a chunk at a time, raises the same Error, for there is none left. A dup or
162
+ # clone of the response reads the same body, so its reads are counted with those of the response, and a
163
+ # response that was frozen is read as any other is. A response that was frozen deeply, as Ractor.make_shareable
164
+ # freezes one on a Ruby before 4.1, which refuses to share the socket of one, is not: its connection is frozen
165
+ # with it, so a read of a body not yet read raises an Error that says so, while a body read whole before is
166
+ # returned again.
167
+ #
168
+ # An error of the socket the body is read from, such as a connection that dropped, or a read that timed out,
169
+ # raises a NetworkError here, inside the block of Client#get_stream, which may rescue it, and which
170
+ # Client#get_stream raises as it is otherwise. It names the request, and its cause is the error of the socket.
171
+ # An error the block raises is raised as it was, whatever its class.
172
+ #
173
+ # @api public
174
+ # @yieldparam chunk [String] each chunk of the body, as it arrives, in binary
175
+ # @return [String, nil] the body, read whole and tagged UTF-8, or nil for a response without a body, or once a
176
+ # block was passed each chunk of it
177
+ # @raise [Error] if the body was read before, unless both reads are whole, or the block of Client#get_stream
178
+ # has returned, or the body was read from {#http_response}, or the response was frozen deeply
179
+ # @raise [NetworkError] if the socket the body is read from fails, as when the connection drops or a read
180
+ # times out
181
+ # @raise [StandardError] whatever the block raises
182
+ # @example Print the body of a stream as it arrives
183
+ # response.read_body { |chunk| print chunk }
184
+ # @example Read a body that ends whole
185
+ # body = response.read_body
186
+ # @example Flush what was read of a stream that dropped
187
+ # begin
188
+ # response.read_body { |chunk| file.write(chunk) }
189
+ # rescue X::NetworkError
190
+ # file.flush
191
+ # raise
192
+ # end
193
+ def read_body(&block)
194
+ raise Error, READ_OUTSIDE_BLOCK if @state.left
195
+ return whole_body if @state.whole && !block
196
+ note_read
197
+ failed = [] #: Array[Exception]
198
+ body = StreamBody.reading(uri, failed) { transport_body(block, failed) }
199
+ return if block
200
+
201
+ @state.body = body
202
+ @state.whole = true
203
+ whole_body
204
+ end
205
+
206
+ private
207
+
208
+ # Note that the body is read, which it is once
209
+ #
210
+ # A response that was frozen deeply, with what its reads are noted in, has nowhere to note it, and no
211
+ # connection to read from that Net::HTTP can read, so its read is refused, rather than raise a FrozenError that
212
+ # names what the reads are counted in.
213
+ #
214
+ # @api private
215
+ # @return [void]
216
+ # @raise [Error] if the body was read before, or the response was frozen deeply
217
+ def note_read
218
+ raise Error, READ_ONCE if @state.read
219
+ raise Error, READ_DEEP_FROZEN if @state.frozen?
220
+
221
+ @state.read = true
222
+ end
223
+
224
+ # The body read whole, as a String of its own, tagged UTF-8
225
+ #
226
+ # @api private
227
+ # @return [String, nil] the body, or nil for a response without one
228
+ def whole_body = @state.body.dup&.force_encoding(Encoding::UTF_8)
229
+
230
+ # Read the body from the response of the transport
231
+ #
232
+ # The transport knows nothing of the reads this counts, nor this of the transport's, so a body the block of
233
+ # Client#get_stream read from {#http_response} is found out here: Net::HTTP raises an IOError for a body it is
234
+ # asked to pass to a block again, which is not an error of the socket, and returns what it passed the chunks to,
235
+ # rather than a String, for one it is asked to return whole.
236
+ #
237
+ # @api private
238
+ # @param block [Proc, nil] the block each chunk is passed to, or nil to read the body whole
239
+ # @param failed [Array<Exception>] the errors of the block, which the error it raises is added to
240
+ # @return [String, nil] the body read whole, or nil for a response without one, or what a block was passed
241
+ # @raise [Error] if the body was read from the response of the transport
242
+ # @raise [StandardError] whatever reading the body raises
243
+ def transport_body(block, failed)
244
+ return http_response.read_body(&StreamBody.passing(block, failed)) if block
245
+
246
+ http_response.read_body.tap { |body| raise Error, READ_ELSEWHERE unless body.nil? || body.instance_of?(String) }
247
+ rescue IOError => e
248
+ raise unless e.message.end_with?(TRANSPORT_READ_TWICE) && !failed.include?(e)
249
+
250
+ raise Error, READ_ELSEWHERE
251
+ end
252
+
253
+ # Note that the block of Client#get_stream has returned
254
+ #
255
+ # The body is read no more after it. Internal to x-core: the stream Client#get_stream opens calls it with
256
+ # __send__ once its block ends, however it ends, since the connection the body is read from is closed then. It
257
+ # is noted in the state the response shares with its copies, so it is noted of a response that was frozen, and
258
+ # of each copy of it.
259
+ #
260
+ # @api private
261
+ # @return [void]
262
+ def leave
263
+ @state.left = true
264
+ end
265
+ end
266
+ end
267
+ end
@@ -0,0 +1,238 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "simple_oauth"
6
+ require "uri"
7
+ require_relative "credential_validator"
8
+ require_relative "errors/authorization_error"
9
+ require_relative "errors/invalid_response"
10
+ require_relative "oauth2_tokens"
11
+ require_relative "authenticator"
12
+ require_relative "request_builder"
13
+ require_relative "request_context"
14
+ require_relative "response_parser"
15
+
16
+ module X
17
+ module Core
18
+ # Sends the token requests that simple_oauth builds over a client's connection
19
+ #
20
+ # Internal to x-core: the app-only and OAuth 2.0 authenticators, and OAuth2Authorization, fetch tokens with it,
21
+ # so that the proxy, timeouts, debug output, and headers of a client apply to token requests too.
22
+ #
23
+ # @api private
24
+ module TokenEndpoint
25
+ extend self
26
+
27
+ # The responses that refuse a token request whatever their body, as RFC 6749 refuses one
28
+ REFUSALS = [Net::HTTPBadRequest, Net::HTTPUnauthorized].freeze
29
+ private_constant :REFUSALS
30
+
31
+ # The classes of the responses that answer a token request, with a token or a refusal, when their body is JSON
32
+ ANSWERS = [Net::HTTPSuccess, Net::HTTPClientError].freeze
33
+ private_constant :ANSWERS
34
+
35
+ # The version of the API that ends the path of a base URL, such as the /2/ of https://api.x.com/2/
36
+ API_VERSION = %r{/\d+(?:\.\d+)*/?\z}
37
+ private_constant :API_VERSION
38
+
39
+ # The message of the error raised for a token a successful response holds that cannot be read as one
40
+ UNREADABLE_TOKEN = "The token endpoint answered with a token that cannot be read"
41
+ private_constant :UNREADABLE_TOKEN
42
+
43
+ # The URL of a token endpoint at a base URL, under the path it serves the API at
44
+ #
45
+ # A token endpoint of X is requested at the scheme, host, and port of the base URL of the client that sends its
46
+ # requests, so that a client pointed at another host, such as a test server or a recording proxy, sends its
47
+ # credentials there, as it sends its requests, rather than to X. The path is the one X serves the endpoint at,
48
+ # under the path the base URL serves the API at, which is its path without the version of the API that ends it,
49
+ # so that a gateway that serves the API under a path of its own, as https://gateway.example/x/2/ serves it under
50
+ # /x, serves the token endpoints under that path too, as X serves them beside the API.
51
+ #
52
+ # @api private
53
+ # @param base_url [String] the base URL of the client
54
+ # @param token_url [String] the URL X serves the endpoint at
55
+ # @return [String] the URL of the endpoint at the origin of the base URL
56
+ # @example The token endpoint of a client pointed at a test server
57
+ # X::Core::TokenEndpoint.url_at("http://localhost:3000/2/", X::AppOnlyAuthenticator::TOKEN_URL)
58
+ # # => "http://localhost:3000/oauth2/token"
59
+ # @example The token endpoint of a client pointed at a gateway that serves the API under a path
60
+ # X::Core::TokenEndpoint.url_at("https://gateway.example/x/2/", X::OAuth2Authenticator::TOKEN_URL)
61
+ # # => "https://gateway.example/x/2/oauth2/token"
62
+ def url_at(base_url, token_url)
63
+ base_path, path = URI(base_url).path, URI(token_url).path #: [String, String]
64
+ mount = base_path.sub(API_VERSION, "").chomp("/")
65
+ String(URI.join(base_url, "#{mount}#{path}"))
66
+ end
67
+
68
+ # The scopes a token names
69
+ #
70
+ # A token response names the scopes it granted as one String, each scope apart from the next by a space.
71
+ #
72
+ # @api private
73
+ # @param token [SimpleOAuth::OAuth2::Token] the token the endpoint returned
74
+ # @return [Array<String>, nil] the scopes, frozen, or nil if the token names none
75
+ # @example Read the scopes of a token
76
+ # X::Core::TokenEndpoint.scopes_of(token) # => ["tweet.read", "users.read", "offline.access"]
77
+ def scopes_of(token)
78
+ scopes = String.try_convert(token.scope).to_s.split
79
+ CredentialValidator.frozen_scopes(scopes) unless scopes.empty?
80
+ end
81
+
82
+ # Send a token request and read the token the endpoint returns
83
+ #
84
+ # The endpoint answers a token request as OAuth 2.0 does: with the token in a successful response of JSON, or
85
+ # with a refusal of the request, which raises AuthorizationError, in a response of 400 Bad Request or 401
86
+ # Unauthorized, or of another status of 4xx whose body is JSON, as X refuses the API key and secret of an app
87
+ # with a 403 Forbidden. Any other response says that the endpoint failed to answer rather than that it refused the credentials, as a
88
+ # redirect does, or the page of a proxy, firewall, or captive portal, and so does a response of 429 Too Many
89
+ # Requests, or of a server error. It raises the error a response of the API with that status raises: the
90
+ # HTTPError of a status that is not successful, which a client waits out or retries as it does that response,
91
+ # and InvalidResponse for a successful one whose body is not JSON, or holds no token, rather than
92
+ # AuthorizationError, which tells a caller to ask the user to authorize the app again, which a failure of the
93
+ # network is no reason to.
94
+ #
95
+ # @api private
96
+ # @param token_request [SimpleOAuth::OAuth2::Request] the token request
97
+ # @param connection [Connection] the connection to send it over
98
+ # @param refusal [String] the message of a refusal that describes no reason
99
+ # @param headers [Hash{String => String}] the headers of the client that sends it; see {#post}
100
+ # @return [SimpleOAuth::OAuth2::Token] the token
101
+ # @raise [TooManyRequests] if the endpoint limits the rate of the request
102
+ # @raise [HTTPError] if the endpoint fails to answer, as a server error, a redirect, or a refusal whose body is
103
+ # not JSON says
104
+ # @raise [InvalidResponse] if the endpoint answers successfully with a body that is not JSON, or holds no token
105
+ # @raise [AuthorizationError] if the endpoint refuses the request, with the response that refused it
106
+ # @example Refresh a token
107
+ # X::Core::TokenEndpoint.fetch(oauth2_client.refresh_token_request(refresh_token:), connection:,
108
+ # refusal: "Token refresh failed", headers: client.headers)
109
+ def fetch(token_request, connection:, refusal:, headers:)
110
+ request = post(token_request, headers)
111
+ response = connection.perform(request:)
112
+ raise failure(response, request) unless answer?(response)
113
+
114
+ token_of(response, request, refusal)
115
+ end
116
+
117
+ private
118
+
119
+ # Check whether the endpoint answered as OAuth 2.0 does, with a token or a refusal
120
+ #
121
+ # A refusal is a response of a status REFUSALS names, whatever its body, or of another status of 4xx, other than
122
+ # 429 Too Many Requests, whose body is JSON.
123
+ #
124
+ # @api private
125
+ # @param response [Net::HTTPResponse] the response of the endpoint
126
+ # @return [Boolean] true for a successful response of JSON, or a refusal
127
+ def answer?(response)
128
+ return true if REFUSALS.include?(response.class)
129
+
130
+ ANSWERS.any? { |answer| response.is_a?(answer) } && !response.instance_of?(Net::HTTPTooManyRequests) &&
131
+ json_object?(response.body)
132
+ end
133
+
134
+ # Check whether a body is a JSON object
135
+ #
136
+ # A response of no content, such as a 204 a gateway answers with, has no body, which is not one.
137
+ #
138
+ # @api private
139
+ # @param body [String, nil] the body of the response
140
+ # @return [Boolean] true if the body parses to a Hash
141
+ def json_object?(body)
142
+ JSON.parse(body.to_s).instance_of?(Hash)
143
+ rescue JSON::ParserError
144
+ false
145
+ end
146
+
147
+ # The token a response of the endpoint holds
148
+ #
149
+ # The error raised in place of it is raised with the cause of the failure simple_oauth reports, rather than the
150
+ # failure, so that its cause is the error of x-core it was raised in rescue of, such as the Unauthorized that led
151
+ # a client to refresh, and nil for none.
152
+ #
153
+ # @api private
154
+ # @param response [Net::HTTPResponse] the response of the endpoint, a token or a refusal
155
+ # @param request [Net::HTTP::Post] the token request, which the error names
156
+ # @param refusal [String] the message of a refusal that describes no reason
157
+ # @return [SimpleOAuth::OAuth2::Token] the token
158
+ # @raise [AuthorizationError] if the response refuses the request
159
+ # @raise [InvalidResponse] if the response is successful, but holds no token, or one that cannot be read
160
+ def token_of(response, request, refusal)
161
+ SimpleOAuth::OAuth2::Token.from_response(status: response.code, body: response.body).tap { |token| readable!(token) }
162
+ rescue SimpleOAuth::OAuth2::Error => e
163
+ raise refused(e, response, request, refusal), cause: e.cause
164
+ rescue ArgumentError
165
+ raise InvalidResponse.new(UNREADABLE_TOKEN, http_response: response, **RequestContext.of(request))
166
+ end
167
+
168
+ # Check that a token can be read as the tokens a client holds
169
+ #
170
+ # It is checked before a client takes any of it. A refresh token that is not a String, an access token that is
171
+ # blank or holds a line break, or scopes that are not scope tokens, is not a token X documents, and taking it
172
+ # would leave the client holding what it cannot send, or report to save_tokens.
173
+ #
174
+ # @api private
175
+ # @param token [SimpleOAuth::OAuth2::Token] the token
176
+ # @return [void]
177
+ # @raise [ArgumentError] if the token cannot be read so
178
+ def readable!(token)
179
+ raise ArgumentError if token.access_token.match?(/[\r\n]/)
180
+
181
+ OAuth2Tokens.new(access_token: token.access_token, refresh_token: token.refresh_token, scopes: scopes_of(token))
182
+ end
183
+
184
+ # Create the error of a response that holds no token
185
+ #
186
+ # Its message is the reason X described, or else the error code it reported, or else the message given.
187
+ #
188
+ # @api private
189
+ # @param error [SimpleOAuth::OAuth2::Error] the failure simple_oauth reports of the response
190
+ # @param response [Net::HTTPResponse] the response of the endpoint
191
+ # @param request [Net::HTTP::Post] the token request, which the error names
192
+ # @param refusal [String] the message of a refusal that describes no reason
193
+ # @return [AuthorizationError, InvalidResponse] the error of a refusal, or InvalidResponse for a successful
194
+ # response that holds no token
195
+ def refused(error, response, request, refusal)
196
+ message = error.description || error.code || refusal
197
+ return InvalidResponse.new(message, http_response: response, **RequestContext.of(request)) if response.is_a?(Net::HTTPSuccess)
198
+
199
+ AuthorizationError.new(message, http_response: response, **RequestContext.of(request))
200
+ end
201
+
202
+ # Create the error of a response in which the endpoint failed to answer
203
+ # @api private
204
+ # @param response [Net::HTTPResponse] the response of the endpoint
205
+ # @param request [Net::HTTP::Post] the token request, which the error names
206
+ # @return [HTTPError, InvalidResponse] the error of the status of the response, or InvalidResponse for a
207
+ # successful one
208
+ def failure(response, request)
209
+ return ResponseParser.new.error(response, request) unless response.is_a?(Net::HTTPSuccess)
210
+
211
+ InvalidResponse.new(http_response: response, **RequestContext.of(request))
212
+ end
213
+
214
+ # Build the POST that sends a token request
215
+ #
216
+ # It is sent with the headers a request of the API is sent with: the User-Agent of the gem, and the headers of
217
+ # the client, which replace it, so that a gateway the base URL names, which may require a header of its own, is
218
+ # sent it with the token requests of the client too. The headers of the token request itself replace both, as
219
+ # its credentials and its form content type, and the client's Authorization header is never sent, since a token
220
+ # request carries its own credentials, or none, as the refresh of a public client does.
221
+ #
222
+ # @api private
223
+ # @param token_request [SimpleOAuth::OAuth2::Request] the token request
224
+ # @param headers [Hash{String => String}] the headers of the client that sends it
225
+ # @return [Net::HTTP::Post] the request
226
+ def post(token_request, headers)
227
+ request = Net::HTTP::Post.new(URI(token_request.url))
228
+ client_headers = headers.reject { |name, _| name.casecmp?(Authenticator::AUTHENTICATION_HEADER) }
229
+ [RequestBuilder::DEFAULT_HEADERS, client_headers, token_request.headers].each do |sent|
230
+ sent.each { |name, value| request[name] = value }
231
+ end
232
+ request.body = token_request.body
233
+ request
234
+ end
235
+ end
236
+ private_constant :TokenEndpoint
237
+ end
238
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rubygems/version"
4
+
5
+ module X
6
+ # The HTTP layer of the X gem
7
+ # @api public
8
+ module Core
9
+ # The current version of the x-core gem
10
+ # @api public
11
+ VERSION = "1.0.0"
12
+
13
+ # The version as a Gem::Version, which compares one release with another
14
+ #
15
+ # VERSION is a String, as a version constant is throughout Ruby, so that what reads it can split it, match it,
16
+ # or send it wherever a String belongs, such as the User-Agent of a request. This builds the Gem::Version that
17
+ # compares it with another version, which a String compares by character rather than by segment.
18
+ #
19
+ # @api public
20
+ # @return [Gem::Version] the version
21
+ # @example Take a path that a later release opened
22
+ # X::Core.gem_version >= Gem::Version.new("1.1")
23
+ def self.gem_version = Gem::Version.new(VERSION)
24
+ end
25
+ end
data/lib/x/core.rb ADDED
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "core/version"
4
+ require_relative "core/client"
5
+ require_relative "core/problem"
6
+ require_relative "core/oauth2_authorization"
data/sig/manifest.yaml ADDED
@@ -0,0 +1,8 @@
1
+ # The standard libraries the signatures of x-core refer to, which rbs collection loads for code that depends on it
2
+ #
3
+ # rbs collection reads every library named here as a standard library, so the gems x-core depends on are left to its
4
+ # gemspec, from which it installs their signatures.
5
+ dependencies:
6
+ - name: json
7
+ - name: net-http
8
+ - name: uri