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.
- checksums.yaml +7 -0
- data/.yardopts +8 -0
- data/CHANGELOG.md +353 -0
- data/LICENSE.txt +21 -0
- data/README.md +54 -0
- data/lib/x/core/app_only_authenticator.rb +198 -0
- data/lib/x/core/authenticator.rb +74 -0
- data/lib/x/core/authenticator_request.rb +56 -0
- data/lib/x/core/bearer_token_authenticator.rb +46 -0
- data/lib/x/core/built_response.rb +100 -0
- data/lib/x/core/client.rb +728 -0
- data/lib/x/core/client_app_only.rb +115 -0
- data/lib/x/core/client_credentials.rb +193 -0
- data/lib/x/core/client_internals.rb +315 -0
- data/lib/x/core/client_memo.rb +58 -0
- data/lib/x/core/client_settings.rb +166 -0
- data/lib/x/core/client_token_refresh.rb +281 -0
- data/lib/x/core/connection.rb +328 -0
- data/lib/x/core/connection_pool.rb +123 -0
- data/lib/x/core/connection_proxy.rb +151 -0
- data/lib/x/core/connection_request.rb +90 -0
- data/lib/x/core/credential_holder.rb +78 -0
- data/lib/x/core/credential_validator.rb +286 -0
- data/lib/x/core/errors/authorization_denied.rb +55 -0
- data/lib/x/core/errors/authorization_error.rb +41 -0
- data/lib/x/core/errors/bad_gateway.rb +9 -0
- data/lib/x/core/errors/bad_request.rb +10 -0
- data/lib/x/core/errors/callback_error.rb +88 -0
- data/lib/x/core/errors/client_error.rb +17 -0
- data/lib/x/core/errors/conflict.rb +9 -0
- data/lib/x/core/errors/error.rb +66 -0
- data/lib/x/core/errors/forbidden.rb +10 -0
- data/lib/x/core/errors/gateway_timeout.rb +9 -0
- data/lib/x/core/errors/gone.rb +9 -0
- data/lib/x/core/errors/http_error.rb +333 -0
- data/lib/x/core/errors/internal_server_error.rb +9 -0
- data/lib/x/core/errors/invalid_response.rb +96 -0
- data/lib/x/core/errors/method_not_allowed.rb +10 -0
- data/lib/x/core/errors/network_error.rb +59 -0
- data/lib/x/core/errors/not_acceptable.rb +10 -0
- data/lib/x/core/errors/not_found.rb +15 -0
- data/lib/x/core/errors/payload_too_large.rb +10 -0
- data/lib/x/core/errors/payment_required.rb +10 -0
- data/lib/x/core/errors/request_timeout.rb +9 -0
- data/lib/x/core/errors/server_error.rb +15 -0
- data/lib/x/core/errors/service_unavailable.rb +9 -0
- data/lib/x/core/errors/token_report_failed.rb +59 -0
- data/lib/x/core/errors/too_many_redirects.rb +53 -0
- data/lib/x/core/errors/too_many_requests.rb +80 -0
- data/lib/x/core/errors/unauthorized.rb +15 -0
- data/lib/x/core/errors/unavailable_for_legal_reasons.rb +10 -0
- data/lib/x/core/errors/unprocessable_entity.rb +10 -0
- data/lib/x/core/errors/unsupported_format.rb +22 -0
- data/lib/x/core/errors/unsupported_media_type.rb +10 -0
- data/lib/x/core/errors/unsupported_operation.rb +18 -0
- data/lib/x/core/oauth1_authenticator.rb +142 -0
- data/lib/x/core/oauth2_authenticator.rb +296 -0
- data/lib/x/core/oauth2_authorization.rb +405 -0
- data/lib/x/core/oauth2_refresh.rb +272 -0
- data/lib/x/core/oauth2_tokens.rb +277 -0
- data/lib/x/core/origin.rb +95 -0
- data/lib/x/core/problem.rb +302 -0
- data/lib/x/core/proxy_setting.rb +22 -0
- data/lib/x/core/rate_limit.rb +137 -0
- data/lib/x/core/rate_limit_handler.rb +166 -0
- data/lib/x/core/redirect_handler.rb +208 -0
- data/lib/x/core/refresh_reporter.rb +92 -0
- data/lib/x/core/request_builder.rb +155 -0
- data/lib/x/core/request_context.rb +85 -0
- data/lib/x/core/request_encoding.rb +135 -0
- data/lib/x/core/response.rb +196 -0
- data/lib/x/core/response_headers.rb +31 -0
- data/lib/x/core/response_parser.rb +155 -0
- data/lib/x/core/retry_handler.rb +149 -0
- data/lib/x/core/setting_validator.rb +399 -0
- data/lib/x/core/stream_body.rb +77 -0
- data/lib/x/core/stream_response.rb +267 -0
- data/lib/x/core/token_endpoint.rb +238 -0
- data/lib/x/core/version.rb +25 -0
- data/lib/x/core.rb +6 -0
- data/sig/manifest.yaml +8 -0
- data/sig/x-core.rbs +396 -0
- 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
|