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,728 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "client_internals"
4
+ require_relative "connection"
5
+ require_relative "credential_holder"
6
+ require_relative "rate_limit_handler"
7
+ require_relative "redirect_handler"
8
+ require_relative "retry_handler"
9
+ require_relative "setting_validator"
10
+
11
+ module X
12
+ module Core
13
+ # A client for interacting with the X API
14
+ #
15
+ # An endpoint is resolved against the base URL, and a request carries the client's credentials to the origin of
16
+ # that base URL alone: the scheme, host, and port it names. An endpoint that names a whole URL of another origin
17
+ # is sent there without them, as a redirect that leads to one is, so that the credentials of the API never reach
18
+ # a host they were not meant for; see {Core::Origin}.
19
+ #
20
+ # The object_class of a request, of a stream, or of a client is one of two things. A class that JSON.parse builds
21
+ # each JSON object of the body into, as it does Hash, the default, OpenStruct, or a Struct, whose new takes no
22
+ # arguments and whose instances take each member with []=. Or anything that responds to from_response, which
23
+ # builds the result from the whole body instead, as the resource classes of x-resources do: it is passed the body
24
+ # parsed into Hashes and Arrays, whatever the array_class, and the client that made the request as client:, and
25
+ # what it returns is what the request returns, or, for a stream, what its block is passed for each object. Later
26
+ # versions of 1.x may pass it keyword arguments of their own, so it accepts the ones it does not read with **, as
27
+ # in def self.from_response(body, client:, **). The signatures of x-core state it as the X::_ResponseBuilder
28
+ # interface.
29
+ #
30
+ # A client keeps its credentials, settings, and connection in an object of x-core it delegates to, and has no
31
+ # private methods but initialize, so that the methods x-resources and x-uploads include into it, which may be
32
+ # named as they like, take the place of none of its own.
33
+ #
34
+ # @api public
35
+ class ::X::Client
36
+ include CredentialHolder
37
+
38
+ # Default base URL for the X API
39
+ DEFAULT_BASE_URL = "https://api.x.com/2/"
40
+ # Default class for parsing JSON arrays
41
+ DEFAULT_ARRAY_CLASS = Array
42
+ # Default class for parsing JSON objects
43
+ DEFAULT_OBJECT_CLASS = Hash
44
+ # Default timeout for opening connections in seconds
45
+ DEFAULT_OPEN_TIMEOUT = Connection::DEFAULT_OPEN_TIMEOUT
46
+ # Default timeout for reading responses in seconds
47
+ DEFAULT_READ_TIMEOUT = Connection::DEFAULT_READ_TIMEOUT
48
+ # Default timeout for writing requests in seconds
49
+ DEFAULT_WRITE_TIMEOUT = Connection::DEFAULT_WRITE_TIMEOUT
50
+ # Default time to keep a connection open for the next request to the same host, in seconds
51
+ DEFAULT_KEEP_ALIVE_TIMEOUT = Connection::DEFAULT_KEEP_ALIVE_TIMEOUT
52
+ # Default maximum number of redirects to follow
53
+ DEFAULT_MAX_REDIRECTS = RedirectHandler::DEFAULT_MAX_REDIRECTS
54
+ # Default maximum number of times to retry a request refused for a rate limit
55
+ DEFAULT_MAX_RATE_LIMIT_RETRIES = RateLimitHandler::DEFAULT_MAX_RETRIES
56
+ # Default maximum number of seconds to wait for a rate limit to reset
57
+ DEFAULT_MAX_RATE_LIMIT_WAIT = RateLimitHandler::DEFAULT_MAX_WAIT
58
+ # Default maximum number of times to send an idempotent request again after a failure
59
+ DEFAULT_MAX_RETRIES = RetryHandler::DEFAULT_MAX_RETRIES
60
+
61
+ # The timeout for opening connections, in seconds
62
+ # @api public
63
+ # @return [Integer, Float, nil] the timeout, or nil for none
64
+ # @example Get the open timeout
65
+ # client.open_timeout # => 10
66
+ def open_timeout = @internals.open_timeout
67
+
68
+ # The timeout for reading responses, in seconds
69
+ # @api public
70
+ # @return [Integer, Float, nil] the timeout, or nil for none
71
+ # @example Get the read timeout
72
+ # client.read_timeout # => 60
73
+ def read_timeout = @internals.read_timeout
74
+
75
+ # The timeout for writing requests, in seconds
76
+ # @api public
77
+ # @return [Integer, Float, nil] the timeout, or nil for none
78
+ # @example Get the write timeout
79
+ # client.write_timeout # => 60
80
+ def write_timeout = @internals.write_timeout
81
+
82
+ # The time to keep an idle connection open for the next request, in seconds
83
+ # @api public
84
+ # @return [Integer, Float] the timeout
85
+ # @example Get the keep-alive timeout
86
+ # client.keep_alive_timeout # => 30
87
+ def keep_alive_timeout = @internals.keep_alive_timeout
88
+
89
+ # The IO debug output is written to
90
+ # @api public
91
+ # @return [IO, #<<, nil] the IO, or anything else that takes a String with <<, or nil for none
92
+ # @example Get the debug output
93
+ # client.debug_output
94
+ def debug_output = @internals.debug_output
95
+
96
+ # The maximum number of redirects to follow
97
+ # @api public
98
+ # @return [Integer] the maximum number of redirects
99
+ # @example Get the maximum number of redirects
100
+ # client.max_redirects # => 10
101
+ def max_redirects = @internals.max_redirects
102
+
103
+ # The maximum number of times to retry a request refused for a rate limit
104
+ # @api public
105
+ # @return [Integer] the maximum number of retries
106
+ # @example Get the maximum number of rate limit retries
107
+ # client.max_rate_limit_retries # => 0
108
+ def max_rate_limit_retries = @internals.max_rate_limit_retries
109
+
110
+ # The maximum number of seconds to wait for a rate limit to reset
111
+ # @api public
112
+ # @return [Integer, Float] the maximum wait, in seconds
113
+ # @example Get the maximum rate limit wait
114
+ # client.max_rate_limit_wait # => 900
115
+ def max_rate_limit_wait = @internals.max_rate_limit_wait
116
+
117
+ # The maximum number of times to send an idempotent request again after a failure
118
+ # @api public
119
+ # @return [Integer] the maximum number of retries
120
+ # @example Get the maximum number of retries
121
+ # client.max_retries # => 2
122
+ def max_retries = @internals.max_retries
123
+
124
+ # The authenticator for API requests
125
+ #
126
+ # It is the one the client was given, or else the one it built of its credentials. A client sends the token
127
+ # requests of an authenticator that makes them, an AppOnlyAuthenticator or an OAuth2Authenticator, over its own
128
+ # connection, with its proxy, timeouts, and debug output, whether it built the authenticator or was given it; an
129
+ # authenticator given to several clients sends them over the connection of the first, but for an OAuth 2.0
130
+ # refresh a request of another of them makes, which goes to the token endpoint at the origin of that client's
131
+ # base URL, over its connection, with its headers. The refreshes of an OAuth2Authenticator reach the save_tokens
132
+ # of each client that authenticates with it, a refresh reads the stored tokens with the load_tokens of the
133
+ # authenticator, or else with the load_tokens of a client that authenticates with it, and the expires_at of the
134
+ # client is the authenticator's.
135
+ #
136
+ # @api public
137
+ # @return [Authenticator] the authenticator instance
138
+ # @example Check if the OAuth 2.0 token has expired
139
+ # client.authenticator.token_expired?
140
+ def authenticator = @internals.authenticator
141
+
142
+ # A callable passed the OAuth2Tokens of each refresh, and of an authorization
143
+ # @api public
144
+ # @return [#call, nil] the callable, or nil for none
145
+ # @example Read the hook a refresh reports to
146
+ # client.save_tokens
147
+ def save_tokens = @internals.save_tokens
148
+
149
+ # The callable a refresh reads the stored OAuth2Tokens with
150
+ #
151
+ # It returns the tokens in the storage that processes sharing the tokens of a user read, or nil for none there.
152
+ #
153
+ # @api public
154
+ # @return [#call, nil] the callable, or nil for none
155
+ # @example Read the loader a refresh reads stored tokens with
156
+ # client.load_tokens
157
+ def load_tokens = @internals.load_tokens
158
+
159
+ # The API key for OAuth 1.0a authentication
160
+ #
161
+ # It is a frozen copy of the one the client was given, or the one the OAuth1Authenticator or AppOnlyAuthenticator
162
+ # it was given in place of credentials holds.
163
+ #
164
+ # @api public
165
+ # @return [String, nil] the API key for OAuth 1.0a authentication
166
+ # @example Get the API key
167
+ # client.api_key
168
+ def api_key = @internals.api_key_in_use
169
+
170
+ # The OAuth 2.0 client ID
171
+ #
172
+ # It is a frozen copy of the one the client was given, or the one the OAuth2Authenticator it was given in place of
173
+ # credentials holds.
174
+ #
175
+ # @api public
176
+ # @return [String, nil] the OAuth 2.0 client ID
177
+ # @example Get the client ID
178
+ # client.client_id
179
+ def client_id = @internals.client_id_in_use
180
+
181
+ # The time the OAuth 2.0 access token expires, as last refreshed
182
+ #
183
+ # A refresh that reports no lifetime leaves it nil, rather than the time the client was given.
184
+ #
185
+ # @api public
186
+ # @return [Time, nil] the expiration time, or nil if it is not known
187
+ # @example Get the expiration time
188
+ # client.expires_at
189
+ def expires_at = @internals.expires_at
190
+
191
+ # The scopes X granted the OAuth 2.0 access token, as last refreshed
192
+ #
193
+ # A refresh that names no scopes keeps those the client held, as OAuth 2.0 has it. They are the ones the client
194
+ # was given, as the client of OAuth2Authorization#client is given those of the exchange of the code, until a
195
+ # refresh names others.
196
+ #
197
+ # @api public
198
+ # @return [Array<String>, nil] the scopes, frozen, or nil if they are not known
199
+ # @example Check that the user let the app post
200
+ # client.scopes&.include?("tweet.write")
201
+ def scopes = @internals.scopes
202
+
203
+ # The base URL for API requests
204
+ # @api public
205
+ # @return [String] the base URL for API requests, which ends with a slash
206
+ # @example Get the base URL
207
+ # client.base_url # => "https://api.x.com/2/"
208
+ def base_url = @internals.base_url
209
+
210
+ # The default class for parsing JSON arrays
211
+ # @api public
212
+ # @return [Class] the default class for parsing JSON arrays
213
+ # @example Get the default array class
214
+ # client.default_array_class # => Array
215
+ def default_array_class = @internals.default_array_class
216
+
217
+ # The default class for parsing JSON objects
218
+ #
219
+ # It is a class that JSON.parse builds each JSON object into, or one that responds to from_response and builds
220
+ # the result from the whole body; see {Client}.
221
+ #
222
+ # @api public
223
+ # @return [Class, #from_response] the default class for parsing JSON objects
224
+ # @example Get the default object class
225
+ # client.default_object_class # => Hash
226
+ def default_object_class = @internals.default_object_class
227
+
228
+ # A callable passed an X::Response after each request and streamed object
229
+ #
230
+ # It is the hook of every request a client makes. A block passed to a single request receives the same
231
+ # summary, after this, for code that reads the response of that one request rather than of all of them.
232
+ #
233
+ # @api public
234
+ # @return [#call, nil] the callable, or nil for none
235
+ # @example Read the hook a client reports to
236
+ # client.on_response
237
+ def on_response = @internals.on_response
238
+
239
+ # The headers sent with every request the client makes
240
+ #
241
+ # They are defaults: a header of the same name passed to a request, or to a stream, is sent in place of the
242
+ # client's, and each of them is sent in place of a default of the gem, such as its User-Agent. A header that
243
+ # carries credentials, such as Authorization or Cookie, is dropped by a redirect to another origin, as one
244
+ # passed to a request is.
245
+ #
246
+ # Each is named by a String, a header the client was given by a Symbol among them, so that a header is read by
247
+ # the name it is sent with, whichever the client was given: a Symbol names the header its underscores name with
248
+ # hyphens, as :user_agent names User-Agent.
249
+ #
250
+ # @api public
251
+ # @return [Hash{String => String}] the headers, frozen, each named by a String
252
+ # @example Read the headers a client sends
253
+ # client.headers # => {"User-Agent" => "my-app/1.0"}
254
+ def headers = @internals.headers
255
+
256
+ # Initialize a new X API client
257
+ #
258
+ # @api public
259
+ # @param api_key [String, nil] the API key for OAuth 1.0a authentication
260
+ # @param api_key_secret [String, nil] the API key secret for OAuth 1.0a authentication
261
+ # @param access_token [String, nil] the access token for OAuth authentication
262
+ # @param access_token_secret [String, nil] the access token secret for OAuth 1.0a authentication
263
+ # @param bearer_token [String, nil] the bearer token for authentication
264
+ # @param client_id [String, nil] the OAuth 2.0 client ID
265
+ # @param client_secret [String, nil] the OAuth 2.0 client secret
266
+ # @param refresh_token [String, nil] the OAuth 2.0 refresh token, or nil beside a client ID and access token issued
267
+ # without offline.access, which authenticate as the user until the access token expires, and cannot refresh
268
+ # @param expires_at [Time, nil] the time the OAuth 2.0 access token expires, after which a request refreshes it,
269
+ # given only beside the client_id and access_token the client authenticates with
270
+ # @param scopes [Array<String>, nil] the scopes X granted the OAuth 2.0 access token, as OAuth2Tokens#scopes
271
+ # holds them, given only beside the client_id and access_token the client authenticates with
272
+ # @param authenticator [Authenticator, nil] an authenticator to authenticate with in place of credentials, such as
273
+ # an OAuth2Authenticator built elsewhere, or nil to build one of the credentials; see {#authenticator}
274
+ # @param base_url [String] the base URL for API requests
275
+ # @param open_timeout [Integer, Float, nil] the timeout for opening connections in seconds, or nil for none
276
+ # @param read_timeout [Integer, Float, nil] the timeout for reading responses in seconds, or nil for none
277
+ # @param write_timeout [Integer, Float, nil] the timeout for writing requests in seconds, or nil for none
278
+ # @param keep_alive_timeout [Integer, Float] the time to keep a connection open for the next request to the same
279
+ # host, in seconds, which a proxy that closes idle connections sooner than X does may need lowered
280
+ # @param debug_output [IO, #<<, nil] the IO object for debug output, or anything else that takes a String with <<,
281
+ # such as a StringIO. It is written every request and response whole, in the clear: the Authorization header,
282
+ # the client secret a token request sends, and the tokens a token response holds. Send it to a file you
283
+ # control while debugging, never to a log that is shipped elsewhere, and leave it nil in production.
284
+ # @param proxy_url [String, URI::Generic, nil] the proxy URL for requests, or nil to take the proxy the
285
+ # environment names for each request: https_proxy for HTTPS and http_proxy for HTTP, unless no_proxy names the
286
+ # host; one of them that cannot be parsed, or is not an http or https URL with a host, as one with no scheme
287
+ # is not, is refused by the request that reads it
288
+ # @param default_array_class [Class] the default class for parsing JSON arrays
289
+ # @param default_object_class [Class, #from_response] the default class for parsing JSON objects, or one that
290
+ # responds to from_response and builds the result from the whole body; see {Client}
291
+ # @param headers [Hash{String, Symbol => String}] headers sent with every request the client makes, as defaults: a
292
+ # header of the same name passed to a request is sent in place of one of these, and each of these is sent in
293
+ # place of a default of the gem, such as its User-Agent; a Symbol names the header its underscores name with
294
+ # hyphens, as :user_agent names User-Agent
295
+ # @param max_redirects [Integer] the maximum number of redirects to follow, beyond which a redirect raises
296
+ # TooManyRedirects; 0 follows none, and raises for each redirect that could be followed
297
+ # @param max_rate_limit_retries [Integer] the maximum number of times to retry a request refused for a rate limit,
298
+ # after waiting for the limit to reset
299
+ # @param max_rate_limit_wait [Integer, Float] the maximum number of seconds to wait for a rate limit to reset; a request
300
+ # whose limit resets later raises TooManyRequests at once, as does a stream that would wait longer to reconnect,
301
+ # and a few seconds are added at random to each wait of a request, so that the requests one reset releases are
302
+ # not sent again in one burst
303
+ # @param max_retries [Integer] the maximum number of times to send a request again after the API failed to answer
304
+ # it, with a 5xx status or a 408, or after its answer never arrived, which is twice by default and is 0 for a client
305
+ # that raises at once; a retry waits up to a second before the first and up to twice as long before each after,
306
+ # but never more than a minute, a random share of each wait taken off so that the requests one failure of the
307
+ # API ended are not sent again together, or for as long as the response asks when it carries a Retry-After
308
+ # header, whichever is longer, and a response that asks for longer than a minute raises at once; a 429 is not
309
+ # among these, and waits for its rate limit to reset as max_rate_limit_wait allows, however long past a minute; only a GET, PUT, or DELETE is sent again, since
310
+ # the API may have acted on a POST whose answer never arrived, and one whose answer never arrived is sent again
311
+ # only when it never reached the API, such as for a connection refused or one that timed out opening, since the
312
+ # API bills a read it answered, such as one that timed out reading its response, whether or not the answer came
313
+ # @param on_response [#call, nil] a callable passed an X::Response after every request, failed ones included, and
314
+ # every object a stream delivers; a block passed to a single request receives the same summary, after this
315
+ # @param save_tokens [#call, nil] a callable passed the OAuth2Tokens of each refresh, to store them; the
316
+ # refreshes are reported one at a time, in the order they were made, and one already replaced is not reported;
317
+ # the client of OAuth2Authorization#client passes it the tokens of the exchange of the code as well; a callable
318
+ # that raises, as one whose storage is briefly down may, raises TokenReportFailed from the request that
319
+ # refreshed, which holds the tokens, since the refresh token they replaced is spent, and the client, with the
320
+ # error of the callable as its cause
321
+ # @param load_tokens [#call, nil] a callable that takes no arguments and returns the OAuth2Tokens in the storage
322
+ # that save_tokens writes to, or nil for none there, for processes that share the tokens of a user: X
323
+ # accepts a refresh token once, so a refresh reads the storage first, under its lock, and takes the tokens there
324
+ # in place of its own when their refresh token is another, as it is once another process has refreshed; it
325
+ # sends the request with them when their access token has not expired, and refreshes with them when it has,
326
+ # and a refresh X refuses for a refresh token another process spent reads the storage again, and takes the
327
+ # tokens there in place of raising once that process has stored them, and raises AuthorizationError if it has
328
+ # yet to, when the next request takes them; the tokens it takes came from the storage, so save_tokens is not
329
+ # passed them; see {#authenticator}
330
+ # @return [Client] a new client instance
331
+ # @raise [ArgumentError] if credentials are given that do not form a complete set, which would send requests
332
+ # without them, or authenticate as the app rather than a user
333
+ # @raise [ArgumentError] if a credential is an empty String, as an environment variable that is not set is often
334
+ # read, which would send an Authorization header that authenticates nothing
335
+ # @raise [ArgumentError] if expires_at is neither a Time nor nil, or scopes neither an Array of Strings that each
336
+ # name a scope nor nil, or either is given to a client that does not authenticate with OAuth 2.0 credentials,
337
+ # which would leave it unused
338
+ # @raise [ArgumentError] if an authenticator is given that is not an Authenticator, or beside credentials,
339
+ # expires_at, or scopes, which it would leave unused
340
+ # @raise [ArgumentError] if a timeout is neither a finite number of seconds of at least 0 nor, for any but
341
+ # keep_alive_timeout, nil, or if a maximum is not a count or a number of seconds of at least 0
342
+ # @raise [ArgumentError] if debug_output is a String, as the name of a file is, which would be written every
343
+ # request in place of the file, or is neither nil nor takes a String with <<, as true and a Proc do not
344
+ # @raise [ArgumentError] if base_url is not an absolute http or https URL with no user, password, query, or
345
+ # fragment, or headers are not a Hash that names each header with a String or a Symbol and gives it a String
346
+ # @raise [ArgumentError] if on_response, save_tokens, or load_tokens is neither nil nor responds to call
347
+ # @raise [ArgumentError] if default_array_class is not a Class, or default_object_class is neither a Class nor
348
+ # responds to from_response, which a response would be parsed with once the API had answered the request
349
+ # @example Create a client with bearer token authentication
350
+ # client = X::Client.new(bearer_token: "your_bearer_token")
351
+ # @example Create a client with OAuth 2.0 authentication that stores the tokens of each refresh
352
+ # client = X::Client.new(client_id: "id", client_secret: "secret", access_token: "token", refresh_token: "refresh",
353
+ # expires_at: Time.now + 7200, save_tokens: ->(tokens) { store.save(tokens.refresh_token) })
354
+ # @example Share the tokens of a user among processes, which store each refresh and read the store before one
355
+ # stored = store.load(user)
356
+ # client = X::Client.new(client_id: "id", **stored.to_h,
357
+ # save_tokens: ->(tokens) { store.save(user, tokens) },
358
+ # load_tokens: -> { store.load(user) })
359
+ # @example Create a client with OAuth 1.0a authentication
360
+ # client = X::Client.new(api_key: "key", api_key_secret: "secret", access_token: "token", access_token_secret: "token_secret")
361
+ # @example Create a client that authenticates with an authenticator built elsewhere
362
+ # client = X::Client.new(authenticator: X::OAuth2Authenticator.new(client_id: "id", access_token: "token",
363
+ # refresh_token: "refresh", expires_at: Time.now + 7200), save_tokens: ->(tokens) { store.save(tokens) })
364
+ # @example Create a client that fetches an app-only bearer token with the API key and secret
365
+ # client = X::Client.new(api_key: "key", api_key_secret: "secret")
366
+ # @example Create a client that retries a rate-limited request up to three times
367
+ # client = X::Client.new(bearer_token: "your_bearer_token", max_rate_limit_retries: 3)
368
+ # @example Create a client that raises at once rather than send a lookup again the API failed to answer
369
+ # client = X::Client.new(bearer_token: "your_bearer_token", max_retries: 0)
370
+ # @example Create a client that names the application in the User-Agent of every request
371
+ # client = X::Client.new(bearer_token: "your_bearer_token", headers: {"User-Agent" => "my-app/1.0"})
372
+ def initialize(api_key: nil, api_key_secret: nil, access_token: nil, access_token_secret: nil,
373
+ bearer_token: nil, client_id: nil, client_secret: nil, refresh_token: nil, expires_at: nil, scopes: nil,
374
+ authenticator: nil,
375
+ base_url: DEFAULT_BASE_URL,
376
+ open_timeout: DEFAULT_OPEN_TIMEOUT,
377
+ read_timeout: DEFAULT_READ_TIMEOUT,
378
+ write_timeout: DEFAULT_WRITE_TIMEOUT,
379
+ keep_alive_timeout: DEFAULT_KEEP_ALIVE_TIMEOUT,
380
+ debug_output: nil,
381
+ proxy_url: nil,
382
+ default_array_class: DEFAULT_ARRAY_CLASS,
383
+ default_object_class: DEFAULT_OBJECT_CLASS,
384
+ headers: {},
385
+ max_redirects: DEFAULT_MAX_REDIRECTS,
386
+ max_rate_limit_retries: DEFAULT_MAX_RATE_LIMIT_RETRIES,
387
+ max_rate_limit_wait: DEFAULT_MAX_RATE_LIMIT_WAIT,
388
+ max_retries: DEFAULT_MAX_RETRIES,
389
+ on_response: nil,
390
+ save_tokens: nil,
391
+ load_tokens: nil)
392
+ @internals = ClientInternals.new(self, api_key:, api_key_secret:, access_token:, access_token_secret:, bearer_token:,
393
+ client_id:, client_secret:, refresh_token:, expires_at:, scopes:, authenticator:, base_url:, open_timeout:, read_timeout:,
394
+ write_timeout:, keep_alive_timeout:, debug_output:, proxy_url:, default_array_class:, default_object_class:,
395
+ headers:, max_redirects:, max_rate_limit_retries:, max_rate_limit_wait:, max_retries:, on_response:,
396
+ save_tokens:, load_tokens:)
397
+ end
398
+
399
+ # Summarize the client for the console without revealing credentials
400
+ #
401
+ # @api public
402
+ # @return [String] the class name, base URL, and authenticator
403
+ # @example Inspect a client
404
+ # client.inspect # => #<X::Client base_url="https://api.x.com/2/" authenticator=#<X::BearerTokenAuthenticator>>
405
+ def inspect
406
+ "#<#{self.class} base_url=#{base_url.inspect} authenticator=#{authenticator.inspect}>"
407
+ end
408
+
409
+ # Copy the client with some of its options changed
410
+ #
411
+ # A copy that authenticates with OAuth 2.0 shares the client's authenticator, so that a refresh by either
412
+ # client reaches the other, since X accepts a refresh token once, unless it is given a client ID, client secret,
413
+ # access token, or refresh token that the authenticator does not hold. It shares it whatever the tokens are when
414
+ # it is built, so a refresh on another thread while it is built reaches it too. A refresh then passes the
415
+ # tokens it issued to the save_tokens of each client that shares it, once for each distinct callable. The
416
+ # expiration time and the scopes belong to the access token they share, so such a copy is refused either: give
417
+ # expires_at or scopes beside the access token and refresh token they describe.
418
+ #
419
+ # A copy that does not share the OAuth 2.0 authenticator, since it is given credentials the authenticator does
420
+ # not hold, an authenticator of its own, or, as a copy of a client given the authenticator, any credential,
421
+ # holds tokens that may be another user's, so it holds none of the refresh token, expiration time, scopes,
422
+ # save_tokens, or load_tokens of the client unless it is given them. So does a copy of a client that does not
423
+ # authenticate with OAuth 2.0 that is given a credential or an authenticator.
424
+ #
425
+ # A copy of a client that was given its authenticator shares it, unless the copy is given a credential, which
426
+ # replaces it, or an authenticator of its own, which also replaces the credentials of a client that holds them.
427
+ #
428
+ # A copy that opens its connections as the client does, with the same timeouts, keep-alive timeout, debug output,
429
+ # and proxy, shares the connections the client keeps open, so that a copy made for each request, such as to send
430
+ # a header of its own, opens none of its own; {#close} on either closes them for both, and a later request of
431
+ # either opens them again. A copy given another debug output, though one equal to the client's, as an empty
432
+ # Array is to another, opens connections of its own, which write to its own.
433
+ #
434
+ # @api public
435
+ # @param options [Hash] the options to change, as accepted by initialize
436
+ # @return [Client] a new client with the same credentials and settings, apart from the options given
437
+ # @raise [ArgumentError] if the copy shares the OAuth 2.0 authenticator of the client and is given expires_at or
438
+ # scopes
439
+ # @example Derive an API v1.1 client
440
+ # v1_client = client.with(base_url: "https://api.x.com/1.1/")
441
+ # @example Derive an app-only client from the API key and secret
442
+ # app_client = client.with(access_token: nil, access_token_secret: nil)
443
+ # @example Derive a client that authenticates with another authenticator
444
+ # user_client = app_client.with(authenticator: X::OAuth2Authenticator.new(**stored_tokens))
445
+ def with(**options) = @internals.with(self, options) # steep:ignore DifferentMethodParameterKind
446
+
447
+ # A client that authenticates as the app, for the endpoints that refuse OAuth 1.0a
448
+ #
449
+ # A client that authenticates as a user, signing with OAuth 1.0a or with OAuth 2.0, returns a copy that
450
+ # authenticates with the app's bearer token: the one it was given, or one it fetches with its API key and secret
451
+ # the first time. It returns the same copy, with the connections it keeps open, from then on, since the
452
+ # credentials and settings of a client never change; threads that ask for the copy together get one. A copy of
453
+ # the client made with {#with} that holds the same API key and secret, and the same base URL, builds a copy of
454
+ # its own, but sends the token the client fetched, or fetches, rather than fetch one of its own from the token
455
+ # endpoint, which X limits the rate of. A client with a bearer token or an API key and secret alone already authenticates as the app, and is returned as it is,
456
+ # as is one given an authenticator that authenticates as the app, or as no one. A client given an
457
+ # OAuth1Authenticator fetches the token with the API key and secret it signs with. A client that authenticates
458
+ # with OAuth 2.0 as a user and holds neither the app's bearer token nor its API key and secret, as a client
459
+ # given an OAuth2Authenticator holds neither, raises, rather than send the user's credentials to an endpoint
460
+ # that would refuse them with 403 Forbidden.
461
+ #
462
+ # @api public
463
+ # @return [Client] a copy that authenticates with the bearer token, or the client itself
464
+ # @raise [UnsupportedOperation] if the client authenticates with OAuth 2.0 as a user and holds no credentials of
465
+ # the app
466
+ # @example Add a filtered stream rule, which takes app-only authentication
467
+ # client.app_only.post("tweets/search/stream/rules", {add: [{value: "ruby"}]})
468
+ def app_only = @internals.app_only(self)
469
+
470
+ # Perform a GET request to the X API
471
+ #
472
+ # @api public
473
+ # @param endpoint [String] the endpoint, relative to the base URL with or without a leading slash, with or
474
+ # without a query string
475
+ # @param params [Hash, nil] query parameters appended to the endpoint; nil values are dropped and arrays are joined with commas
476
+ # @param headers [Hash] additional headers for the request
477
+ # @param array_class [Class] the class for parsing JSON arrays
478
+ # @param object_class [Class, #from_response] the class for parsing JSON objects, or one that responds to
479
+ # from_response and builds the result from the whole body; see {Client}
480
+ # @return [Object, nil] the parsed response body, or what an object_class that responds to from_response builds
481
+ # @raise [ArgumentError] if the endpoint is not a String, is not a valid URL, or does not resolve to an http or
482
+ # https URL, before the request is sent
483
+ # @raise [ArgumentError] if array_class is not a Class, or object_class is neither a Class nor responds to
484
+ # from_response, before the request is sent
485
+ # @raise [ArgumentError] if headers are not a Hash of header names to Strings, before the request is sent
486
+ # @raise [ArgumentError] if the client was given no proxy_url and the proxy the environment names for the request
487
+ # cannot be parsed, or is not an http or https URL with a host, with a message that names the variables read for
488
+ # its scheme and leaves out the value
489
+ # @yieldparam response [Response] the summary of each response the request got, as {#on_response} receives it
490
+ # @example Get a user by username
491
+ # client.get("users/by/username/sferik")
492
+ # @example Get users by identifier, requesting only some fields
493
+ # client.get("users", params: {ids: [1, 2], "user.fields": %w[id username]})
494
+ # @example Read what a response reported of the rate limit it spent
495
+ # user = client.get("users/me") { |response| limit = response.rate_limit }
496
+ def get(endpoint, params: nil, headers: {}, array_class: default_array_class, object_class: default_object_class, &)
497
+ @internals.execute_request(self, :get, endpoint, params:, headers:, array_class:, object_class:, &)
498
+ end
499
+
500
+ # Perform a POST request to the X API
501
+ #
502
+ # @api public
503
+ # @param endpoint [String] the endpoint, relative to the base URL with or without a leading slash, with or
504
+ # without a query string
505
+ # @param body [String, Hash, Array, nil] the request body; a String is sent as given, and a Hash or an Array
506
+ # is encoded as JSON
507
+ # @param params [Hash, nil] query parameters appended to the endpoint
508
+ # @param form [Hash, nil] fields to send as a form-encoded body, in place of a body; as with params, nil values
509
+ # are dropped and arrays are joined with commas
510
+ # @param headers [Hash] additional headers for the request
511
+ # @param array_class [Class] the class for parsing JSON arrays
512
+ # @param object_class [Class, #from_response] the class for parsing JSON objects, or one that responds to
513
+ # from_response and builds the result from the whole body; see {Client}
514
+ # @return [Object, nil] the parsed response body, or what an object_class that responds to from_response builds
515
+ # @raise [ArgumentError] if both a body and form fields are given, or a keyword is given that the method takes
516
+ # none of, as the fields of a body given without the braces of a Hash are, and as body: is, since the body is
517
+ # the second argument, before the request is sent
518
+ # @raise [ArgumentError] if the body is not a String, a Hash, an Array, or nil, as an IO or a Symbol is not,
519
+ # before the request is sent
520
+ # @raise [ArgumentError] if the endpoint is not a String, is not a valid URL, or does not resolve to an http or
521
+ # https URL, before the request is sent
522
+ # @raise [ArgumentError] if array_class is not a Class, or object_class is neither a Class nor responds to
523
+ # from_response, before the request is sent
524
+ # @raise [ArgumentError] if headers are not a Hash of header names to Strings, before the request is sent
525
+ # @raise [ArgumentError] if the client was given no proxy_url and the proxy the environment names for the request
526
+ # cannot be parsed, or is not an http or https URL with a host, with a message that names the variables read for
527
+ # its scheme and leaves out the value
528
+ # @yieldparam response [Response] the summary of each response the request got, as {#on_response} receives it
529
+ # @example Create a post
530
+ # client.post("tweets", {text: "Hello, World!"})
531
+ # @example Post a form to the v1.1 API
532
+ # v1_client.post("account/settings.json", form: {lang: "en"})
533
+ def post(endpoint, body = nil, params: nil, form: nil, headers: {}, array_class: default_array_class, object_class: default_object_class, **unknown, &) # steep:ignore DifferentMethodParameterKind
534
+ SettingValidator.no_unknown_keywords!(:post, endpoint, unknown)
535
+ @internals.execute_request(self, :post, endpoint, body:, params:, form:, headers:, array_class:, object_class:, &)
536
+ end
537
+
538
+ # Perform a PUT request to the X API
539
+ #
540
+ # @api public
541
+ # @param endpoint [String] the endpoint, relative to the base URL with or without a leading slash, with or
542
+ # without a query string
543
+ # @param body [String, Hash, Array, nil] the request body; a String is sent as given, and a Hash or an Array
544
+ # is encoded as JSON
545
+ # @param params [Hash, nil] query parameters appended to the endpoint
546
+ # @param form [Hash, nil] fields to send as a form-encoded body, in place of a body; as with params, nil values
547
+ # are dropped and arrays are joined with commas
548
+ # @param headers [Hash] additional headers for the request
549
+ # @param array_class [Class] the class for parsing JSON arrays
550
+ # @param object_class [Class, #from_response] the class for parsing JSON objects, or one that responds to
551
+ # from_response and builds the result from the whole body; see {Client}
552
+ # @return [Object, nil] the parsed response body, or what an object_class that responds to from_response builds
553
+ # @raise [ArgumentError] if both a body and form fields are given, or a keyword is given that the method takes
554
+ # none of, as the fields of a body given without the braces of a Hash are, and as body: is, since the body is
555
+ # the second argument, before the request is sent
556
+ # @raise [ArgumentError] if the body is not a String, a Hash, an Array, or nil, as an IO or a Symbol is not,
557
+ # before the request is sent
558
+ # @raise [ArgumentError] if the endpoint is not a String, is not a valid URL, or does not resolve to an http or
559
+ # https URL, before the request is sent
560
+ # @raise [ArgumentError] if array_class is not a Class, or object_class is neither a Class nor responds to
561
+ # from_response, before the request is sent
562
+ # @raise [ArgumentError] if headers are not a Hash of header names to Strings, before the request is sent
563
+ # @raise [ArgumentError] if the client was given no proxy_url and the proxy the environment names for the request
564
+ # cannot be parsed, or is not an http or https URL with a host, with a message that names the variables read for
565
+ # its scheme and leaves out the value
566
+ # @yieldparam response [Response] the summary of each response the request got, as {#on_response} receives it
567
+ # @example Update a resource
568
+ # client.put("some/endpoint", {key: "value"})
569
+ def put(endpoint, body = nil, params: nil, form: nil, headers: {}, array_class: default_array_class, object_class: default_object_class, **unknown, &) # steep:ignore DifferentMethodParameterKind
570
+ SettingValidator.no_unknown_keywords!(:put, endpoint, unknown)
571
+ @internals.execute_request(self, :put, endpoint, body:, params:, form:, headers:, array_class:, object_class:, &)
572
+ end
573
+
574
+ # Perform a DELETE request to the X API
575
+ #
576
+ # @api public
577
+ # @param endpoint [String] the endpoint, relative to the base URL with or without a leading slash, with or
578
+ # without a query string
579
+ # @param params [Hash, nil] query parameters appended to the endpoint
580
+ # @param headers [Hash] additional headers for the request
581
+ # @param array_class [Class] the class for parsing JSON arrays
582
+ # @param object_class [Class, #from_response] the class for parsing JSON objects, or one that responds to
583
+ # from_response and builds the result from the whole body; see {Client}
584
+ # @return [Object, nil] the parsed response body, or what an object_class that responds to from_response builds
585
+ # @raise [ArgumentError] if the endpoint is not a String, is not a valid URL, or does not resolve to an http or
586
+ # https URL, before the request is sent
587
+ # @raise [ArgumentError] if array_class is not a Class, or object_class is neither a Class nor responds to
588
+ # from_response, before the request is sent
589
+ # @raise [ArgumentError] if headers are not a Hash of header names to Strings, before the request is sent
590
+ # @raise [ArgumentError] if the client was given no proxy_url and the proxy the environment names for the request
591
+ # cannot be parsed, or is not an http or https URL with a host, with a message that names the variables read for
592
+ # its scheme and leaves out the value
593
+ # @yieldparam response [Response] the summary of each response the request got, as {#on_response} receives it
594
+ # @example Delete a post
595
+ # client.delete("tweets/1234567890")
596
+ def delete(endpoint, params: nil, headers: {}, array_class: default_array_class, object_class: default_object_class, &)
597
+ @internals.execute_request(self, :delete, endpoint, params:, headers:, array_class:, object_class:, &)
598
+ end
599
+
600
+ # Open a GET request whose body the block reads as it arrives, as a stream's is
601
+ #
602
+ # The response is passed to the block as a {StreamResponse} before its body is read, so the block reads it, with
603
+ # its read_body, for as long as it likes; the connection is opened for the request alone, with the client's
604
+ # timeouts and proxy, and closed once the block returns. The request carries the client's credentials and headers as any other does, and
605
+ # a token the API rejects is refreshed, or fetched again, and the request sent once more, as for any other. It is
606
+ # neither retried after a failure nor redirected, and a response that is not successful raises the HTTPError of
607
+ # its status, once on_response is passed it, without reaching the block. Nothing the block reads is passed to
608
+ # on_response, since the body is the block's to read.
609
+ #
610
+ # An error the block raises reaches the caller as it was raised. An error of the socket the body is read from,
611
+ # such as a connection that dropped, or a read that timed out, is raised by the read_body of the response, inside
612
+ # the block, as a NetworkError that names the request, as a connection that fails does, with the error of the
613
+ # socket as its cause, so the block rescues a stream that dropped as X::NetworkError, with no class of the
614
+ # transport, and one it does not rescue reaches the caller as it is, told apart from a block that failed. An
615
+ # error is the socket's when read_body raises it from the socket, whatever its class, so the IOError or
616
+ # Errno::ENOSPC of a file the block writes to, in the block passed to read_body or out of it, is the block's,
617
+ # and raised as it was, as is an error of the socket read from the http_response of the response, the escape
618
+ # hatch, rather than with read_body.
619
+ #
620
+ # The body is read once, and inside the block, since the connection it is read from is closed once the block
621
+ # returns: a second read_body, unless both read the body whole, without a block, and a read_body of a response
622
+ # that outlived the block, raise an Error that says so. It is the caller's, as any other error of the block is,
623
+ # rather than a NetworkError, so with_retries does not open the stream again for it.
624
+ #
625
+ # @api public
626
+ # @param endpoint [String] the endpoint, relative to the base URL with or without a leading slash, with or
627
+ # without a query string
628
+ # @param params [Hash, nil] query parameters appended to the endpoint
629
+ # @param headers [Hash] additional headers for the request
630
+ # @yieldparam response [StreamResponse] the successful response, whose body is not yet read
631
+ # @return [Object] what the block returns
632
+ # @raise [ArgumentError] if no block is given, or the endpoint is not a String, is not a valid URL, or does not
633
+ # resolve to an http or https URL, before the request is sent
634
+ # @raise [ArgumentError] if headers are not a Hash of header names to Strings, before the request is sent
635
+ # @raise [ArgumentError] if the client was given no proxy_url and the proxy the environment names for the request
636
+ # cannot be parsed, or is not an http or https URL with a host, with a message that names the variables read for
637
+ # its scheme and leaves out the value
638
+ # @raise [HTTPError] if the response is not successful
639
+ # @raise [NetworkError] if the request cannot be sent, or its body cannot be read, which read_body raises
640
+ # inside the block
641
+ # @example Print the body of the sample stream as it arrives
642
+ # client.app_only.get_stream("tweets/sample/stream") { |response| response.read_body { |chunk| print chunk } }
643
+ # @example Rescue a stream that dropped inside the block
644
+ # client.app_only.get_stream("tweets/sample/stream") do |response|
645
+ # response.read_body { |chunk| file.write(chunk) }
646
+ # rescue X::NetworkError
647
+ # file.flush
648
+ # end
649
+ # @example Read the connections a stream may open before its rate limit resets, then its body
650
+ # client.app_only.get_stream("tweets/sample/stream") do |response|
651
+ # logger.info("#{response.status}: #{response.rate_limit&.remaining} connections left")
652
+ # response.read_body { |chunk| print chunk }
653
+ # end
654
+ def get_stream(endpoint, params: nil, headers: {}, &block)
655
+ Kernel.raise ArgumentError, "get_stream takes a block, which reads the body of the response" if block.nil?
656
+
657
+ @internals.execute_stream(self, endpoint, params:, headers:, &block)
658
+ end
659
+
660
+ # Close the connections the client keeps open between requests
661
+ #
662
+ # A later request opens a connection again. A client and the copies made of it with {#with} that open their
663
+ # connections as it does share their connections, as the app-only copy of a client that signs with OAuth 1.0a
664
+ # does, so closing one closes them for all of them.
665
+ #
666
+ # @api public
667
+ # @return [void]
668
+ # @example Close the connections before a long pause
669
+ # client.close
670
+ def close = @internals.close
671
+
672
+ # Send a request that is safe to send twice again after a failure
673
+ #
674
+ # The client sends no POST again, since the API may have acted on one whose answer never arrived, and sends no
675
+ # request again after its answer failed to arrive, since the API bills a read it answered whether or not the
676
+ # answer arrived. A request that is safe to send again anyway, such as the chunk of an upload, which names the
677
+ # segment it is appended at and which the API bills nothing for, is sent again with this: after a ServerError, a
678
+ # RequestTimeout, or a NetworkError of any kind, even a timeout, up to max_retries times, after the wait a
679
+ # response asks for, or a backoff that doubles with each retry up to a minute and is cut short at random. A
680
+ # response that asks to be left alone for longer than a minute raises at once. The block must build its request
681
+ # anew each time, so that each attempt is signed afresh, as a request of the client is.
682
+ #
683
+ # Wrap a request the client sends no more than once, such as a POST: the client sends a GET, a PUT, or a DELETE
684
+ # again itself, so one wrapped in this is sent max_retries times more for each time this sends it, nine times in
685
+ # all with the defaults, rather than three.
686
+ #
687
+ # For the gems that extend a client, such as x-uploads, which sends each chunk of an upload with it, so that a
688
+ # later x-core 1.x, which installs beside an earlier x-uploads 1.x, keeps its name and behavior throughout 1.x.
689
+ #
690
+ # @api semipublic
691
+ # @yield sends the request
692
+ # @return [Object] what the block returns
693
+ # @raise [NetworkError] if the request fails once more than the retries allow
694
+ # @raise [ServerError, RequestTimeout] if the API fails to answer once more than the retries allow, or asks for a
695
+ # wait longer than a minute
696
+ # @example Append a chunk of an upload, again after a failure
697
+ # client.with_retries { client.post("media/upload/1/append", body, headers:) }
698
+ def with_retries(&) = @internals.with_retries(&)
699
+
700
+ # The value kept under a key for the authenticator the client holds
701
+ #
702
+ # For the gems that extend a client, such as x-resources, which keeps the identifier of the user its credentials
703
+ # act for with it, so that a later x-core 1.x, which installs beside an earlier x-resources 1.x, keeps its name and
704
+ # behavior throughout 1.x. A value is read only while the client holds the authenticator it was kept with, and a
705
+ # client keeps values though it is frozen, which a copy made with dup or clone shares, and a copy made with
706
+ # {#with} does not.
707
+ #
708
+ # @api semipublic
709
+ # @param key [Symbol] the key, which names the gem that keeps it
710
+ # @return [Object, nil] the value, or nil if none is kept for the authenticator of the client
711
+ # @example Read the identifier of the authenticated user that x-resources kept
712
+ # client.memoized(:x_resources_current_user_id) # => 7505382
713
+ def memoized(key) = @internals.memoized(key)
714
+
715
+ # Keep a value under a key, for the authenticator of the client
716
+ #
717
+ # For the gems that extend a client, and kept throughout 1.x, as {#memoized} is.
718
+ #
719
+ # @api semipublic
720
+ # @param key [Symbol] the key, which names the gem that keeps it
721
+ # @param value [Object] the value
722
+ # @return [Object] the value
723
+ # @example Keep the identifier of the authenticated user
724
+ # client.memoize(:x_resources_current_user_id, 7_505_382) # => 7505382
725
+ def memoize(key, value) = @internals.memoize(key, value)
726
+ end
727
+ end
728
+ end