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,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "monitor"
4
+ require_relative "app_only_authenticator"
5
+ require_relative "errors/unsupported_operation"
6
+
7
+ module X
8
+ module Core
9
+ # The app-only copy of a client, for the endpoints that refuse OAuth 1.0a, included into ClientInternals
10
+ # @api private
11
+ module ClientAppOnly
12
+ # The message of the error raised for a client that holds no credentials of the app to authenticate with
13
+ NO_APP_CREDENTIALS = "A client that authenticates with OAuth 2.0 as a user, and holds neither the app's bearer " \
14
+ "token nor its API key and secret, cannot authenticate as the app. Pass the client one of them, beside the " \
15
+ "OAuth 2.0 credentials rather than an OAuth2Authenticator, which is given alone"
16
+ private_constant :NO_APP_CREDENTIALS
17
+
18
+ # A client that authenticates as the app, which Client#app_only returns
19
+ #
20
+ # A client that authenticates as a user returns a copy that authenticates with the app's bearer token, built
21
+ # once; any other returns itself; see {Client#app_only}.
22
+ #
23
+ # @api private
24
+ # @param client [Client] the client these are the internals of
25
+ # @return [Client] a copy that authenticates with the bearer token, or the client itself
26
+ # @raise [UnsupportedOperation] if the client authenticates with OAuth 2.0 as a user and holds no credentials of
27
+ # the app
28
+ def app_only(client)
29
+ case authenticator
30
+ when OAuth1Authenticator, OAuth2Authenticator
31
+ raise UnsupportedOperation, NO_APP_CREDENTIALS unless bearer_token || app_credentials
32
+
33
+ app_only_copy(client)
34
+ else client
35
+ end
36
+ end
37
+
38
+ private
39
+
40
+ # The app-only copy of the client, built once
41
+ #
42
+ # The credentials and settings of a client never change, so the copy is built once and returned from then on.
43
+ # It is built under a lock, so that threads which ask for it together build one copy and fetch one token.
44
+ #
45
+ # @api private
46
+ # @param client [Client] the client these are the internals of
47
+ # @return [Client] the copy
48
+ def app_only_copy(client)
49
+ @app_only_monitor.synchronize { @app_only ||= build_app_only(client) }
50
+ end
51
+
52
+ # Build a copy that holds the app's credentials and bearer token
53
+ # @api private
54
+ # @param client [Client] the client these are the internals of
55
+ # @return [Client] the copy
56
+ def build_app_only(client)
57
+ key, secret = app_credentials
58
+ with(client, {**credentials.to_h { |name, _| [name, nil] }, api_key: key, api_key_secret: secret, bearer_token: app_bearer_token})
59
+ end
60
+
61
+ # The app-only bearer token, the client's own or one it fetches
62
+ # @api private
63
+ # @return [String] the bearer token
64
+ def app_bearer_token = bearer_token || app_token.__send__(:bearer_token)
65
+
66
+ # The authenticator that fetches the app-only bearer token, shared with copies
67
+ #
68
+ # It is built once, and fetches the token when it is first asked for it, and holds it from then on, so the client,
69
+ # and each copy of it that {#share_app_token} gave it to, fetch it once between them.
70
+ #
71
+ # @api private
72
+ # @return [AppOnlyAuthenticator] the authenticator
73
+ def app_token
74
+ @app_only_monitor.synchronize do
75
+ @app_token ||= begin
76
+ key, secret = app_credentials #: [String, String]
77
+ AppOnlyAuthenticator.new(api_key: key, api_key_secret: secret).__send__(:token_requests_over, @connection, base_url, headers)
78
+ end
79
+ end
80
+ end
81
+
82
+ # Share the app-only bearer token of the client this one was copied from
83
+ #
84
+ # A copy that holds the same credentials of the app, and the same base URL, would fetch the same token, from an
85
+ # endpoint X limits the rate of, so it takes the authenticator that fetches the token of the client it was
86
+ # copied from, rather than fetch one of its own for each copy, as a copy made for each request would. The
87
+ # internals of the client copied call it on those of the copy with __send__, since it is private.
88
+ #
89
+ # @api private
90
+ # @param other [ClientInternals] the internals of the client this one was copied from
91
+ # @return [void]
92
+ def share_app_token(other)
93
+ credentials = app_credentials or return
94
+ @app_token = other.__send__(:app_token) if credentials.eql?(other.__send__(:app_credentials)) && base_url.eql?(other.base_url)
95
+ end
96
+
97
+ # The API key and secret of the app
98
+ #
99
+ # They are the ones an OAuth1Authenticator signs with, which a client given one holds no copy of, or else the
100
+ # client's own, of which it holds both or neither, since a credential outside a complete set is refused.
101
+ #
102
+ # @api private
103
+ # @return [Array(String, String), nil] the API key and secret, or nil for a client that holds neither
104
+ def app_credentials
105
+ current = authenticator
106
+ return [current.api_key, current.__send__(:api_key_secret)] if current.is_a?(OAuth1Authenticator)
107
+
108
+ key = api_key
109
+ secret = api_key_secret #: String
110
+ [key, secret] if key
111
+ end
112
+ end
113
+ private_constant :ClientAppOnly
114
+ end
115
+ end
@@ -0,0 +1,193 @@
1
+ # frozen_string_literal: true
2
+
3
+ module X
4
+ module Core
5
+ # The authentication credentials of a client, which it reads but never changes, included into ClientInternals
6
+ #
7
+ # A client is built with the credentials it keeps for as long as it lives. {Client#with} derives a client whose
8
+ # credentials differ, rather than replacing the ones a client holds, so that a request never signs with a mix of
9
+ # old and new credentials.
10
+ #
11
+ # @api private
12
+ module ClientCredentials
13
+ # The API key for OAuth 1.0a authentication, as the client was given it
14
+ #
15
+ # It is nil for a client given an authenticator in place of credentials; see {#api_key_in_use}.
16
+ #
17
+ # @api private
18
+ # @return [String, nil] the API key for OAuth 1.0a authentication
19
+ attr_reader :api_key
20
+
21
+ # The OAuth 2.0 client ID, as the client was given it
22
+ #
23
+ # It is nil for a client given an authenticator in place of credentials; see {#client_id_in_use}.
24
+ #
25
+ # @api private
26
+ # @return [String, nil] the OAuth 2.0 client ID
27
+ attr_reader :client_id
28
+
29
+ # The API key of the client, or of the authenticator it was given
30
+ #
31
+ # An OAuth1Authenticator or AppOnlyAuthenticator given in place of credentials holds the API key of the client.
32
+ # {Client#api_key} returns it.
33
+ #
34
+ # @api private
35
+ # @return [String, nil] the API key, or nil if neither holds one
36
+ def api_key_in_use
37
+ case (given = @given_authenticator)
38
+ when OAuth1Authenticator, AppOnlyAuthenticator then given.api_key
39
+ else api_key
40
+ end
41
+ end
42
+
43
+ # The OAuth 2.0 client ID of the client, or of the authenticator it was given
44
+ #
45
+ # An OAuth2Authenticator given in place of credentials holds the client ID of the client.
46
+ # {Client#client_id} returns it.
47
+ #
48
+ # @api private
49
+ # @return [String, nil] the client ID, or nil if neither holds one
50
+ def client_id_in_use
51
+ given = @given_authenticator
52
+ given.is_a?(OAuth2Authenticator) ? given.client_id : client_id
53
+ end
54
+
55
+ private
56
+
57
+ # The API key secret for OAuth 1.0a authentication
58
+ #
59
+ # It is private, as {Client#inspect} hides it, and the client holds it here rather than in a reader of its own,
60
+ # so that code that reflects over a client never reads a secret out of it. {Client#with} carries it to a copy
61
+ # without revealing it, and the authenticator of a client holds the credentials it signs with.
62
+ #
63
+ # @api private
64
+ # @return [String, nil] the API key secret for OAuth 1.0a authentication
65
+ attr_reader :api_key_secret
66
+
67
+ # The access token secret for OAuth 1.0a authentication
68
+ #
69
+ # It is private for the reason {#api_key_secret} is.
70
+ #
71
+ # @api private
72
+ # @return [String, nil] the access token secret for OAuth 1.0a authentication
73
+ attr_reader :access_token_secret
74
+
75
+ # The bearer token for authentication
76
+ #
77
+ # It is private for the reason {#api_key_secret} is.
78
+ #
79
+ # @api private
80
+ # @return [String, nil] the bearer token for authentication
81
+ attr_reader :bearer_token
82
+
83
+ # The OAuth 2.0 client secret
84
+ #
85
+ # It is private for the reason {#api_key_secret} is.
86
+ #
87
+ # @api private
88
+ # @return [String, nil] the OAuth 2.0 client secret
89
+ attr_reader :client_secret
90
+
91
+ # The credentials, as initialize accepts them
92
+ # @api private
93
+ # @return [Hash{Symbol => String, Time, Array<String>, nil}] the credentials
94
+ def credentials = {api_key:, api_key_secret:, access_token:, access_token_secret:, bearer_token:, client_id:, client_secret:, refresh_token:, expires_at:, scopes:}
95
+
96
+ # Initialize credential instance variables
97
+ # @api private
98
+ # @return [void]
99
+ def initialize_credentials(api_key:, api_key_secret:, access_token:, access_token_secret:, bearer_token:,
100
+ client_id:, client_secret:, refresh_token:, expires_at:, scopes:)
101
+ CredentialValidator.validate_values!(api_key:, api_key_secret:, access_token:, access_token_secret:, bearer_token:, client_id:, client_secret:, refresh_token:, expires_at:, scopes:)
102
+ @api_key, @api_key_secret = SettingValidator.frozen(api_key), SettingValidator.frozen(api_key_secret)
103
+ @access_token, @access_token_secret = SettingValidator.frozen(access_token), SettingValidator.frozen(access_token_secret)
104
+ @bearer_token = SettingValidator.frozen(bearer_token)
105
+ @client_id, @client_secret, @refresh_token = SettingValidator.frozen(client_id), SettingValidator.frozen(client_secret), SettingValidator.frozen(refresh_token)
106
+ @expires_at, @scopes = expires_at, scopes
107
+ end
108
+
109
+ # Refuse an authenticator beside credentials, and credentials of no complete set
110
+ # @api private
111
+ # @param authenticator [Authenticator, nil] the authenticator the client was given, or nil
112
+ # @return [void]
113
+ # @raise [ArgumentError] if the authenticator is not an Authenticator, or is given beside credentials
114
+ # @raise [ArgumentError] if the credentials do not form complete sets
115
+ def validate_credentials!(authenticator)
116
+ CredentialValidator.validate_authenticator!(authenticator, credentials)
117
+ CredentialValidator.validate!(credentials)
118
+ end
119
+
120
+ # Take the authenticator the client was given, or build one of its credentials
121
+ #
122
+ # The authenticator built is the one of the first complete set of credentials held, and a client that holds no
123
+ # complete set sends its requests without credentials. Taking an authenticator binds it to the connection of the
124
+ # client and reports its refreshes to the client, so the client checks the authenticator, and every other
125
+ # option, before it takes it, and a client that raises leaves the authenticator as it was.
126
+ #
127
+ # @api private
128
+ # @param client [Client] the client these are the internals of
129
+ # @param given [Authenticator, nil] the authenticator the client was given, or nil to build one
130
+ # @return [void]
131
+ def initialize_authenticator(client, given)
132
+ @given_authenticator = given
133
+ @authenticator = given ? take(client, given) : built_authenticator(client)
134
+ end
135
+
136
+ # Build the authenticator of the first complete set of credentials held
137
+ # @api private
138
+ # @param client [Client] the client these are the internals of
139
+ # @return [Authenticator] the authenticator, which sends no credentials when no set is complete
140
+ def built_authenticator(client)
141
+ oauth1_authenticator || oauth2_authenticator(client) || app_only_authenticator || bearer_authenticator || Authenticator.new
142
+ end
143
+
144
+ # The options of a copy of the client, beside the credentials it is built with
145
+ #
146
+ # An authenticator given to the copy replaces the credentials of the client, a credential given to it replaces
147
+ # the authenticator the client was given, and a copy given neither shares that authenticator. An expiration
148
+ # time and scopes are no credentials, and are refused beside an authenticator, as they are when a client is built.
149
+ #
150
+ # @api private
151
+ # @param options [Hash{Symbol => Object}] the options the copy is given
152
+ # @return [Hash{Symbol => Object}] the options, beside the credentials or the authenticator the copy shares
153
+ def with_credentials(options)
154
+ given = @given_authenticator
155
+ return {authenticator: given, **options} if given && !options.keys.intersect?(credentials.except(:expires_at, :scopes).keys)
156
+
157
+ options[:authenticator] ? options : {**credentials, **options}
158
+ end
159
+
160
+ # Build an OAuth 1.0a authenticator if credentials are available
161
+ # @api private
162
+ # @return [OAuth1Authenticator, nil] the OAuth 1.0a authenticator or nil
163
+ def oauth1_authenticator
164
+ access_token = @access_token
165
+ return unless api_key && api_key_secret && access_token && access_token_secret
166
+
167
+ OAuth1Authenticator.new(api_key:, api_key_secret:, access_token:, access_token_secret:)
168
+ end
169
+
170
+ # Build an app-only authenticator on the client's connection, given API keys
171
+ #
172
+ # A bearer token given beside them is sent until the API rejects it, and one is fetched with them in its place.
173
+ #
174
+ # @api private
175
+ # @return [AppOnlyAuthenticator, nil] the app-only authenticator or nil
176
+ def app_only_authenticator
177
+ return unless api_key && api_key_secret
178
+
179
+ AppOnlyAuthenticator.new(api_key:, api_key_secret:, bearer_token:).__send__(:token_requests_over, @connection, base_url, headers)
180
+ end
181
+
182
+ # Build a bearer token authenticator if credentials are available
183
+ # @api private
184
+ # @return [BearerTokenAuthenticator, nil] the bearer token authenticator or nil
185
+ def bearer_authenticator
186
+ return unless bearer_token
187
+
188
+ BearerTokenAuthenticator.new(bearer_token:)
189
+ end
190
+ end
191
+ private_constant :ClientCredentials
192
+ end
193
+ end
@@ -0,0 +1,315 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "monitor"
4
+ require "uri"
5
+ require_relative "app_only_authenticator"
6
+ require_relative "authenticator"
7
+ require_relative "bearer_token_authenticator"
8
+ require_relative "client_app_only"
9
+ require_relative "client_credentials"
10
+ require_relative "client_memo"
11
+ require_relative "client_settings"
12
+ require_relative "client_token_refresh"
13
+ require_relative "connection"
14
+ require_relative "credential_holder"
15
+ require_relative "credential_validator"
16
+ require_relative "errors/callback_error"
17
+ require_relative "oauth1_authenticator"
18
+ require_relative "oauth2_authenticator"
19
+ require_relative "origin"
20
+ require_relative "proxy_setting"
21
+ require_relative "request_builder"
22
+ require_relative "request_encoding"
23
+ require_relative "response_parser"
24
+ require_relative "setting_validator"
25
+ require_relative "stream_body"
26
+ require_relative "stream_response"
27
+
28
+ module X
29
+ module Core
30
+ # The credentials, settings, connection, handlers, and authenticator of a client, and the methods that send its
31
+ # requests with them
32
+ #
33
+ # X::Client is the class that x-resources and x-uploads include their methods into, and each is released apart
34
+ # from x-core, so a later version of either may name a method as it likes. A method of theirs would take the
35
+ # place of a private method of the client of the same name, and a client whose own methods called that one would
36
+ # call theirs in its place. So a client holds its state here and calls on this object, whose methods none of
37
+ # theirs can take the place of, and has no methods of its own but those of its public API and initialize.
38
+ #
39
+ # A client holds one of these for as long as it lives, and passes itself to a method that needs it, such as one
40
+ # that parses a response for it, rather than this holding the client, so that a copy of the client made with dup,
41
+ # which holds the same internals, as it held the same state before, passes itself in turn.
42
+ #
43
+ # @api private
44
+ class ClientInternals
45
+ # The headers a stream is opened with unless its own or the client's replace them, which ask for a body that is
46
+ # not compressed, so that each line reaches the stream as it arrives
47
+ STREAM_HEADERS = {"Accept-Encoding" => "identity"}.freeze
48
+ private_constant :STREAM_HEADERS
49
+
50
+ include ClientAppOnly
51
+ include ClientCredentials
52
+ include ClientMemo
53
+ include ClientSettings
54
+ include CredentialHolder
55
+ include ClientTokenRefresh
56
+ include ProxySetting
57
+
58
+ # Content type of a form-encoded request body
59
+ FORM_CONTENT_TYPE = "application/x-www-form-urlencoded; charset=utf-8"
60
+ private_constant :FORM_CONTENT_TYPE
61
+
62
+ # The authenticator for API requests
63
+ #
64
+ # {Client#authenticator} returns it.
65
+ #
66
+ # @api private
67
+ # @return [Authenticator] the authenticator instance
68
+ attr_reader :authenticator
69
+
70
+ # The callable passed the OAuth2Tokens of each refresh
71
+ #
72
+ # {Client#save_tokens} returns it.
73
+ #
74
+ # @api private
75
+ # @return [#call, nil] the callable, or nil for none
76
+ attr_reader :save_tokens
77
+
78
+ # The callable a refresh reads the stored OAuth2Tokens with
79
+ #
80
+ # {Client#load_tokens} returns it.
81
+ #
82
+ # @api private
83
+ # @return [#call, nil] the callable, or nil for none
84
+ attr_reader :load_tokens
85
+
86
+ # The internals of a client
87
+ #
88
+ # A copy of a client, which {#with} builds, reads the internals of the client it copies with this.
89
+ #
90
+ # @api private
91
+ # @param client [Client] the client
92
+ # @return [ClientInternals] the internals of the client
93
+ def self.of(client) = client.instance_variable_get(:@internals)
94
+
95
+ # Initialize the internals of a client, with the options its initialize was given
96
+ #
97
+ # The authenticator is taken last, since a client that raised must leave an authenticator it was given alone.
98
+ #
99
+ # @api private
100
+ # @param client [Client] the client these are the internals of
101
+ # @return [ClientInternals] a new instance
102
+ # @raise [ArgumentError] if an option is refused, as {Client#initialize} states
103
+ def initialize(client, api_key:, api_key_secret:, access_token:, access_token_secret:, bearer_token:, client_id:,
104
+ client_secret:, refresh_token:, expires_at:, scopes:, authenticator:, base_url:, open_timeout:, read_timeout:,
105
+ write_timeout:, keep_alive_timeout:, debug_output:, proxy_url:, default_array_class:, default_object_class:,
106
+ headers:, max_redirects:, max_rate_limit_retries:, max_rate_limit_wait:, max_retries:, on_response:,
107
+ save_tokens:, load_tokens:)
108
+ @connection = Connection.new(open_timeout:, read_timeout:, write_timeout:, keep_alive_timeout:, debug_output:, proxy_url:)
109
+ @proxy_url = @connection.__send__(:proxy_url)
110
+ initialize_state
111
+ initialize_credentials(api_key:, api_key_secret:, access_token:, access_token_secret:, bearer_token:, client_id:, client_secret:, refresh_token:, expires_at:, scopes:)
112
+ validate_credentials!(authenticator)
113
+ initialize_settings(base_url:, default_array_class:, default_object_class:, headers:, on_response:, max_redirects:, max_rate_limit_retries:, max_rate_limit_wait:, max_retries:)
114
+ initialize_token_hooks(save_tokens:, load_tokens:)
115
+ initialize_authenticator(client, authenticator)
116
+ end
117
+
118
+ # Summarize the internals of a client without revealing credentials
119
+ #
120
+ # They hold the credentials of the client, so they are summarized as {Client#inspect} summarizes the client.
121
+ #
122
+ # @api private
123
+ # @return [String] the class name, base URL, and authenticator
124
+ def inspect = "#<#{self.class} base_url=#{base_url.inspect} authenticator=#{authenticator.inspect}>"
125
+
126
+ # Copy a client with some of its options changed
127
+ #
128
+ # It is what {Client#with} does. The internals of the copy share the authenticator and the connections of these
129
+ # as that states.
130
+ #
131
+ # @api private
132
+ # @param client [Client] the client these are the internals of
133
+ # @param options [Hash{Symbol => Object}] the options to change, as accepted by initialize
134
+ # @return [Client] a new client with the same credentials and settings, apart from the options given
135
+ def with(client, options)
136
+ client.class.new(**without_tokens_of_client({**settings, **with_credentials(options)}, options)).tap do |copy|
137
+ internals = ClientInternals.of(copy)
138
+ internals.__send__(:share_authenticator, copy, authenticator, options)
139
+ internals.__send__(:share_app_token, self)
140
+ internals.__send__(:share_connection, @connection)
141
+ end
142
+ end
143
+
144
+ # Close the connections the client keeps open between requests
145
+ #
146
+ # It is what {Client#close} does.
147
+ #
148
+ # @api private
149
+ # @return [void]
150
+ def close = @connection.close
151
+
152
+ # Execute an HTTP request to the X API
153
+ #
154
+ # An error a callback raised, which the request tags as a CallbackError so that no handler sends the request
155
+ # again, waits out a rate limit, or refreshes a token for it, is raised as it was, once the handlers are left,
156
+ # noted as a callback's, so that Client#with_retries does not send the request again for it either.
157
+ #
158
+ # @api private
159
+ # @param client [Client] the client these are the internals of, which a response is parsed for
160
+ # @return [Object, nil] the parsed response body, or what an object_class that responds to from_response builds
161
+ def execute_request(client, http_method, endpoint, params:, headers:, array_class:, object_class:, body: nil, form: nil, &block)
162
+ SettingValidator.parsing_classes!(array_class:, object_class:)
163
+ headers = SettingValidator.headers!(headers)
164
+ uri = RequestEncoding.uri_for(base_url, endpoint, params)
165
+ headers = headers_for(form.nil? ? headers : RequestBuilder.merge_headers({"Content-Type" => FORM_CONTENT_TYPE}, headers))
166
+ retrying(http_method) { refreshing_rejected_token(client) { perform(client, http_method, uri, body: RequestEncoding.encode_body(body, form), headers:, array_class:, object_class:, &block) } }
167
+ rescue CallbackError => e
168
+ raise CallbackError.untag(e)
169
+ end
170
+
171
+ # Run a request, again after a server error or a rate limit, as the client allows
172
+ #
173
+ # The retries for a rate limit are counted across the attempts a server error sends again, so that
174
+ # max_rate_limit_retries bounds the retries of the request.
175
+ #
176
+ # @api private
177
+ # @param http_method [Symbol] the HTTP method of the request, which says whether it is sent again after a failure
178
+ # @yield runs the request
179
+ # @return [Object] what the block returns
180
+ def retrying(http_method, &)
181
+ @rate_limit_handler.counting do
182
+ @retry_handler.handle(idempotent: RequestBuilder.idempotent?(http_method)) { @rate_limit_handler.handle(&) }
183
+ end
184
+ end
185
+
186
+ # Open a GET request whose body the block reads as it arrives
187
+ #
188
+ # It is what {Client#get_stream} does. An error the block raises, but for the NetworkError its read_body raises
189
+ # for an error of the socket, is tagged as a CallbackError, so that no rejected token is refreshed for it, and
190
+ # raised as it was once the request is left. A stream is counted as a request of its own, so that a request its block sends counts
191
+ # its rate limit retries afresh, even inside with_retries.
192
+ #
193
+ # @api private
194
+ # @param client [Client] the client these are the internals of
195
+ # @param endpoint [String] the endpoint, relative to the base URL
196
+ # @param params [Hash, nil] query parameters appended to the endpoint
197
+ # @param headers [Hash] additional headers for the request
198
+ # @yieldparam response [StreamResponse] the successful response, whose body is not yet read
199
+ # @return [Object] what the block returns
200
+ def execute_stream(client, endpoint, params:, headers:, &)
201
+ uri = RequestEncoding.uri_for(base_url, endpoint, params)
202
+ headers = headers_for(SettingValidator.headers!(headers))
203
+ @rate_limit_handler.counting { refreshing_rejected_token(client) { perform_stream(uri, headers:, &) } }
204
+ rescue CallbackError => e
205
+ raise CallbackError.untag(e)
206
+ end
207
+
208
+ private
209
+
210
+ # Build what the client keeps beside its options
211
+ #
212
+ # They are the helpers that build its requests and parse its responses, and the locks of what it builds once
213
+ # and keeps: its app-only copy, and the values of its memo.
214
+ #
215
+ # @api private
216
+ # @return [void]
217
+ def initialize_state
218
+ @app_only_monitor = Monitor.new
219
+ @request_builder = RequestBuilder.new
220
+ @response_parser = ResponseParser.new
221
+ initialize_memo
222
+ end
223
+
224
+ # Open a GET request once, and pass its response to the block if it succeeded
225
+ #
226
+ # A request to another origin than the base URL carries none of the client's credentials, as any other does. It
227
+ # asks for a body that is not compressed, unless its headers or the client's name an Accept-Encoding of their
228
+ # own, since Net::HTTP inflates a compressed body in blocks of many kilobytes, and would hold back each line of a
229
+ # stream until enough of them had arrived to fill one.
230
+ #
231
+ # @api private
232
+ # @param uri [URI::Generic] the URI of the request
233
+ # @param headers [Hash] the headers of the request, beside those of the client
234
+ # @yieldparam response [StreamResponse] the successful response
235
+ # @return [Object] what the block returns
236
+ # @raise [HTTPError] if the response is not successful
237
+ # @raise [NetworkError] if the request cannot be sent, or read_body raises it for an error of the socket
238
+ # @raise [CallbackError] if on_response raises, or the block raises an error that read_body did not raise for
239
+ # the socket
240
+ def perform_stream(uri, headers:)
241
+ authenticator, headers = Origin.credentials_for(from: URI(base_url), to: uri, authenticator: self.authenticator, headers:)
242
+ request = @request_builder.build(http_method: :get, uri:, headers: RequestBuilder.merge_headers(STREAM_HEADERS, headers), authenticator:)
243
+ @connection.perform_stream(request:) do |response|
244
+ stream_failed(uri, response, request) unless response.is_a?(Net::HTTPSuccess)
245
+ stream = StreamResponse.__send__(:new, http_response: response, uri:)
246
+ reading(stream) { yield stream }
247
+ end
248
+ end
249
+
250
+ # Raise the error of a stream that failed
251
+ #
252
+ # Its body is read whole, tagged UTF-8 as the body of {Connection#perform} is, before the block of the request
253
+ # ends, so that on_response and the error can read it however long after it was raised.
254
+ #
255
+ # @api private
256
+ # @param uri [URI::Generic] the URI of the request
257
+ # @param response [Net::HTTPResponse] the failed response, whose body is not yet read
258
+ # @param request [Net::HTTPRequest] the request the response answers
259
+ # @return [void]
260
+ # @raise [HTTPError] the error of the response
261
+ # @raise [CallbackError] if on_response raises
262
+ def stream_failed(uri, response, request)
263
+ response.body_encoding = Encoding::UTF_8
264
+ response.body
265
+ CallbackError.tagging { report(:get, uri, response) }
266
+ raise @response_parser.error(response, request)
267
+ end
268
+
269
+ # Run the block of a stream, tagging its errors but those of its socket
270
+ #
271
+ # The errors of the socket are the NetworkErrors StreamResponse#read_body raised for it, as StreamBody notes
272
+ # them, which are raised as they are, rather than wrapped in another, and any other error is the block's own,
273
+ # whatever its class, which is raised as it was, as is the error of a socket the block raised of its own, such
274
+ # as the IOError of a file it wrote to, or one read from the response of the transport. However the block ends,
275
+ # the response is told that it has, so that it raises for a body read once the connection is closed, rather
276
+ # than read from it.
277
+ #
278
+ # @api private
279
+ # @param response [StreamResponse] the response the block is passed
280
+ # @yield runs the block of the stream, passing it the response
281
+ # @return [Object] what the block returns
282
+ # @raise [NetworkError] if read_body raised it for an error of the socket
283
+ # @raise [CallbackError] if the block raises an error that read_body did not raise for the socket
284
+ def reading(response)
285
+ yield
286
+ rescue => e
287
+ raise if StreamBody.socket_error?(e)
288
+
289
+ raise CallbackError, e
290
+ ensure
291
+ response.__send__(:leave)
292
+ end
293
+
294
+ # Perform a request once, following redirects and parsing the response
295
+ #
296
+ # A request to another origin than the base URL carries none of the client's credentials, as a redirect to one
297
+ # carries none of them. The error on_response or the block of the request raises, as the error from_response
298
+ # raises, is tagged as a CallbackError, so that it is not taken for an error of the response. The response is
299
+ # reported, and its error named, for the request it answers, which a redirect may have sent to another URI with
300
+ # another method than the request was made with.
301
+ #
302
+ # @api private
303
+ # @param client [Client] the client these are the internals of, which a response is parsed for
304
+ # @return [Object, nil] the parsed response body, or what an object_class that responds to from_response builds
305
+ def perform(client, http_method, uri, body:, headers:, array_class:, object_class:, &)
306
+ authenticator, headers = Origin.credentials_for(from: URI(base_url), to: uri, authenticator: self.authenticator, headers:)
307
+ request = @request_builder.build(http_method:, uri:, body:, headers:, authenticator:)
308
+ response, request = @redirect_handler.follow(response: @connection.perform(request:), request:, headers:, authenticator:)
309
+ CallbackError.tagging { report(request.method, request.uri, response, &) }
310
+ @response_parser.parse(response:, array_class:, object_class:, client:, request:)
311
+ end
312
+ end
313
+ private_constant :ClientInternals
314
+ end
315
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module X
4
+ module Core
5
+ # What the gems that extend a client keep on it, for as long as it authenticates as it does, included into
6
+ # ClientInternals
7
+ #
8
+ # x-resources keeps the identifier of the user a lookup found for the credentials of a client here, so that it looks
9
+ # the user up once. A value is kept with the authenticator of the client when it was kept, and read only while the
10
+ # client authenticates with that authenticator, since what it says may be true of those credentials alone. It is
11
+ # kept under a lock, so that threads that share a client read and keep values together.
12
+ #
13
+ # The memo is held by the internals of a client, as the app-only copy of a client is, rather than by the client,
14
+ # so a frozen client keeps values as well, and a copy made with dup or clone, which holds the same internals and
15
+ # authenticator, shares them. A copy made with {Client#with} holds internals of its own, and keeps its own values.
16
+ #
17
+ # @api private
18
+ module ClientMemo
19
+ # The value kept under a key for the authenticator the client holds
20
+ #
21
+ # It is what {Client#memoized} does.
22
+ #
23
+ # @api private
24
+ # @param key [Symbol] the key, which names the gem that keeps it
25
+ # @return [Object, nil] the value, or nil if none is kept for the authenticator of the client
26
+ def memoized(key)
27
+ @memo_lock.synchronize do
28
+ owner, value = @memo[key]
29
+ value if owner.equal?(authenticator)
30
+ end
31
+ end
32
+
33
+ # Keep a value under a key, for the authenticator of the client
34
+ #
35
+ # It is what {Client#memoize} does.
36
+ #
37
+ # @api private
38
+ # @param key [Symbol] the key, which names the gem that keeps it
39
+ # @param value [Object] the value
40
+ # @return [Object] the value
41
+ def memoize(key, value)
42
+ @memo_lock.synchronize { @memo[key] = [authenticator, value] }
43
+ value
44
+ end
45
+
46
+ private
47
+
48
+ # Start with nothing kept
49
+ # @api private
50
+ # @return [void]
51
+ def initialize_memo
52
+ @memo = {}
53
+ @memo_lock = Mutex.new
54
+ end
55
+ end
56
+ private_constant :ClientMemo
57
+ end
58
+ end