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,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "openssl"
5
+ require "zlib"
6
+ require_relative "request_builder"
7
+
8
+ module X
9
+ module Core
10
+ # How a connection sends one request, and what it counts as a failure of the network, included into Connection
11
+ # @api private
12
+ module ConnectionRequest
13
+ # Network errors that should be wrapped in NetworkError
14
+ #
15
+ # IOError covers EOFError, and a read from a socket closed under it. SystemCallError covers every error the
16
+ # operating system reports for a socket, such as a refused, reset, or aborted connection, a network that is down
17
+ # or has no route, or a write refused with EPIPE. The open, read, and write timeouts are those of Net::HTTP, and
18
+ # no other Timeout::Error, such as one Timeout.timeout raises around a request, which is raised as it is, so that
19
+ # a rescue of it still catches it, and no handler sends the request again for it. A connection cut off
20
+ # mid-response can leave Net::HTTP a status line it cannot parse, or a compressed body that Zlib cannot inflate,
21
+ # and a proxy that refuses to open a tunnel, such as with 407 Proxy Authentication Required, raises a
22
+ # Net::ProtocolError. A response whose Content-Length or Content-Range Net::HTTP cannot read raises a
23
+ # Net::HTTPHeaderSyntaxError.
24
+ NETWORK_ERRORS = [
25
+ IOError,
26
+ Net::HTTPBadResponse,
27
+ Net::HTTPHeaderSyntaxError,
28
+ Net::ProtocolError,
29
+ OpenSSL::SSL::SSLError,
30
+ SocketError,
31
+ SystemCallError,
32
+ Net::OpenTimeout,
33
+ Net::ReadTimeout,
34
+ Net::WriteTimeout,
35
+ Zlib::Error
36
+ ].freeze
37
+
38
+ # Errors that say a connection kept open had been closed by the time a request was sent on it
39
+ #
40
+ # EOFError is read from a socket the peer closed, and a write to one it closed or reset is refused with EPIPE,
41
+ # ECONNRESET, or ECONNABORTED. A timeout is not among them: a request that timed out waiting for its response
42
+ # may have reached the API, which may have acted on it. Nor is any of them once the response has begun: a body
43
+ # cut off as it is read is one the API sent.
44
+ STALE_CONNECTION_ERRORS = [EOFError, Errno::ECONNABORTED, Errno::ECONNRESET, Errno::EPIPE].freeze
45
+ private_constant :NETWORK_ERRORS, :STALE_CONNECTION_ERRORS
46
+
47
+ private
48
+
49
+ # Send a request, once more on a new connection when a kept one had gone stale
50
+ #
51
+ # X, or a proxy between, can close a connection that is being kept open for the next request, and the request
52
+ # that takes it then fails as it is written or its response is first read, as one of STALE_CONNECTION_ERRORS.
53
+ # That request never reached the API, so an idempotent one is sent again, on a connection opened for it rather
54
+ # than taken from the pool, where another connection may have gone stale too. A request that failed any other
55
+ # way, such as by timing out, or on a connection opened for it, is not sent again: nothing about it says the API
56
+ # did not act on it. Nor is one whose response had begun, which Net::HTTP says by passing it to the block of
57
+ # the request once its status and headers are read: a connection that drops as the body is read was not stale,
58
+ # and the API answered the request, so it raises as a read that timed out does. Net::HTTP would send a request
59
+ # again of its own, after a timeout as well, with the OAuth 1.0a nonce and signature of the attempt that failed,
60
+ # which is why its retries are turned off and the request the caller built is sent again here.
61
+ #
62
+ # @api private
63
+ # @param request [Net::HTTPRequest] the HTTP request to send
64
+ # @param key [Array] the scheme, host, and port to connect to
65
+ # @param open [Proc] builds a connection to the host
66
+ # @return [Net::HTTPResponse] the HTTP response
67
+ # @raise [StandardError] whatever the request raised, once it may not be sent again
68
+ def send_request(request, key, open)
69
+ stale = false
70
+ begin
71
+ @pool.with(key, open) do |http_client, from_pool|
72
+ stale = from_pool
73
+ http_client.request(request) { stale = false }
74
+ end
75
+ rescue *STALE_CONNECTION_ERRORS
76
+ raise unless stale && idempotent?(request)
77
+
78
+ @pool.with(key, open, fresh: true) { |http_client, _| http_client.request(request) }
79
+ end
80
+ end
81
+
82
+ # Check whether sending a request again has the same effect as sending it once
83
+ # @api private
84
+ # @param request [Net::HTTPRequest] the HTTP request
85
+ # @return [Boolean] true for a GET, PUT, or DELETE
86
+ def idempotent?(request) = RequestBuilder.idempotent?(request.method.downcase.to_sym)
87
+ end
88
+ private_constant :ConnectionRequest
89
+ end
90
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module X
4
+ module Core
5
+ # Refuses Marshal, YAML, and JSON for what holds credentials, included into a client and its internals, an
6
+ # authenticator, and an authorization
7
+ #
8
+ # Marshal and YAML would write the credentials such an object holds, in the clear, wherever what they write is
9
+ # kept, such as a cache, where a client in a Hash that is cached would carry them without a word, or the arguments
10
+ # of a job a queue writes as YAML. Where Marshal would not raise TypeError, for a lock the object holds, it would
11
+ # write them silently, and YAML writes every instance variable, a lock or not, so each refuses alike, with the
12
+ # TypeError Marshal raises for what it cannot write, such as a Proc, so that code that rescues it around
13
+ # Marshal.dump, as a cache or a deep copy does, rescues this too. JSON is refused alike, since ActiveSupport's
14
+ # Object#as_json reads every instance variable as YAML does, so that rendering a client, or a Hash that holds one,
15
+ # as JSON would write its credentials into a response or a log.
16
+ #
17
+ # Internal to x-core: the methods it gives these classes, marshal_dump, encode_with, as_json, and to_json, are
18
+ # public API, but the module is only how they are shared, and which classes include it can change within 1.x.
19
+ # They call raise and format on Kernel, so that a module included into X::Client after it, as x-resources and
20
+ # x-uploads are, cannot change what they raise, whatever the module defines.
21
+ #
22
+ # @api private
23
+ module CredentialHolder
24
+ # The message of the error raised for Marshal, YAML, or JSON, which names the class refused and what refused it
25
+ REFUSAL_MESSAGE = "%s holds credentials, which %s would write in the clear wherever it is kept; keep the " \
26
+ "credentials in a secret store, and the X::OAuth2Tokens save_tokens is passed, and build it again from them"
27
+ private_constant :REFUSAL_MESSAGE
28
+
29
+ # Refuse to be written with Marshal, which would write the credentials
30
+ #
31
+ # @api public
32
+ # @return [void]
33
+ # @raise [TypeError] always
34
+ # @example Store the tokens a refresh issued, rather than the client
35
+ # X::Client.new(**credentials, save_tokens: ->(tokens) { store.save(**tokens.to_h) })
36
+ def marshal_dump = Kernel.raise(TypeError, Kernel.format(REFUSAL_MESSAGE, self.class, "Marshal"))
37
+
38
+ # Refuse to be written as YAML, which would write the credentials
39
+ #
40
+ # YAML reads no marshal_dump, and writes every instance variable of an object that does not say how it is
41
+ # written, credentials and all, so it is refused as Marshal is.
42
+ #
43
+ # @api public
44
+ # @param _coder [Psych::Coder] the coder YAML would write the object with
45
+ # @return [void]
46
+ # @raise [TypeError] always
47
+ # @example Enqueue the identifier of a user, rather than a client, for a job its queue writes as YAML
48
+ # PostJob.perform_later(user_id: client.authenticator.user_id)
49
+ def encode_with(_coder) = Kernel.raise(TypeError, Kernel.format(REFUSAL_MESSAGE, self.class, "YAML"))
50
+
51
+ # Refuse to be read as JSON, which would write the credentials
52
+ #
53
+ # ActiveSupport's Object#as_json reads every instance variable of an object that does not say how it is read,
54
+ # credentials and all, so it is refused as YAML is.
55
+ #
56
+ # @api public
57
+ # @return [void]
58
+ # @raise [TypeError] always
59
+ # @example Render the user a client acts for, rather than the client
60
+ # render json: {user_id: client.authenticator.user_id}
61
+ def as_json(*) = Kernel.raise(TypeError, Kernel.format(REFUSAL_MESSAGE, self.class, "JSON"))
62
+
63
+ # Refuse to be written as JSON, which would write the credentials
64
+ #
65
+ # It raises as {#as_json} does, for the reason that says, so that JSON.generate refuses a client within what it
66
+ # writes as well.
67
+ #
68
+ # @api public
69
+ # @param _state [JSON::State, nil] the state JSON would write the object with
70
+ # @return [void]
71
+ # @raise [TypeError] always
72
+ # @example Log the user a client acts for, rather than the client
73
+ # logger.info(JSON.generate(user_id: client.authenticator.user_id))
74
+ def to_json(_state = nil) = Kernel.raise(TypeError, Kernel.format(REFUSAL_MESSAGE, self.class, "JSON"))
75
+ end
76
+ private_constant :CredentialHolder
77
+ end
78
+ end
@@ -0,0 +1,286 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "authenticator"
4
+
5
+ module X
6
+ module Core
7
+ # Checks that the credentials of a new client form complete sets
8
+ #
9
+ # A client authenticates with the first complete set of credentials it has, and ignores the rest. Credentials that
10
+ # form no set would send requests without credentials, an access token without the rest of its set would
11
+ # authenticate as the app, or with a bearer token, rather than as the user it belongs to, and any other credential
12
+ # of a set that is not complete, such as a client ID beside a bearer token, is a mistake that a client would
13
+ # otherwise hide. So every credential must belong to a complete set. A client may hold several, such as the
14
+ # bearer token of an app beside its API key and secret, but not OAuth 2.0 credentials beside OAuth 1.0a ones, which
15
+ # share the access token, and which it authenticates with first, so that the OAuth 2.0 credentials would go unused.
16
+ # A client never changes the credentials it was built with, so this runs once, when the client is built.
17
+ #
18
+ # @api private
19
+ module CredentialValidator
20
+ extend self
21
+
22
+ # The message of the error raised for credentials that do not form a complete set
23
+ INCOMPLETE_CREDENTIALS = "The credentials given do not form a complete set. Pass api_key, api_key_secret, " \
24
+ "access_token, and access_token_secret for OAuth 1.0a; client_id and access_token, with the refresh_token " \
25
+ "that refreshes it and the client_secret of a confidential client, for OAuth 2.0; bearer_token for the app's " \
26
+ "bearer token; or api_key and api_key_secret to authenticate as the app. Leave out any credential of a set " \
27
+ "that is not complete"
28
+ private_constant :INCOMPLETE_CREDENTIALS
29
+
30
+ # The credentials of each set: OAuth 1.0a, OAuth 2.0 for a confidential and for a public client, with a refresh
31
+ # token and without one, as an authorization without offline.access issues them, a bearer token, and the app's
32
+ # API key and secret
33
+ CREDENTIAL_SETS = [
34
+ %i[api_key api_key_secret access_token access_token_secret],
35
+ %i[client_id client_secret access_token refresh_token],
36
+ %i[client_id access_token refresh_token],
37
+ %i[client_id client_secret access_token],
38
+ %i[client_id access_token],
39
+ %i[bearer_token],
40
+ %i[api_key api_key_secret]
41
+ ].freeze
42
+
43
+ # The credentials a client authenticates with before OAuth 2.0 credentials, when it holds both
44
+ OAUTH1_CREDENTIALS = CREDENTIAL_SETS.first
45
+ # The credentials of the least set of OAuth 2.0, which an expiration time is the expiration time of the token of
46
+ OAUTH2_CREDENTIALS = CREDENTIAL_SETS.fetch(4)
47
+ # The credentials of OAuth 2.0 that no other set holds, which a client that authenticates with OAuth 1.0a ignores
48
+ OAUTH2_ONLY_CREDENTIALS = %i[client_id client_secret refresh_token].freeze
49
+ private_constant :OAUTH1_CREDENTIALS, :OAUTH2_CREDENTIALS, :OAUTH2_ONLY_CREDENTIALS
50
+
51
+ # The message of the error raised for OAuth 2.0 credentials the client would leave unused
52
+ UNUSED_OAUTH2_CREDENTIALS = "%s are OAuth 2.0 credentials, which a client given OAuth 1.0a credentials would " \
53
+ "leave unused, since it authenticates with those, and the access_token they share is the OAuth 1.0a one. Pass " \
54
+ "the credentials of one or the other"
55
+ private_constant :UNUSED_OAUTH2_CREDENTIALS
56
+
57
+ # The message of the error raised for an expiration time the client would leave unused
58
+ UNUSED_EXPIRES_AT = "expires_at is the time an OAuth 2.0 access token expires, so it is given beside the " \
59
+ "client_id and access_token the client authenticates with, rather than beside OAuth 1.0a credentials, a " \
60
+ "bearer_token, an api_key and api_key_secret, or none, which would leave it unused. Leave it out"
61
+ private_constant :UNUSED_EXPIRES_AT
62
+
63
+ # The message of the error raised for an expiration time that is not a Time
64
+ INVALID_EXPIRES_AT = "expires_at must be a Time, such as Time.at(seconds) for a time stored as seconds since the " \
65
+ "epoch, or nil if it is not known"
66
+ private_constant :INVALID_EXPIRES_AT
67
+
68
+ # The message of the error raised for scopes the client would leave unused
69
+ UNUSED_SCOPES = "scopes are the scopes X granted an OAuth 2.0 access token, so they are given beside the " \
70
+ "client_id and access_token the client authenticates with, rather than beside OAuth 1.0a credentials, a " \
71
+ "bearer_token, an api_key and api_key_secret, or none, which would leave them unused. Leave them out"
72
+ private_constant :UNUSED_SCOPES
73
+
74
+ # The message of the error raised for scopes that are not an Array of scopes
75
+ INVALID_SCOPES = "scopes must be an Array of Strings that each name a scope, such as %w[tweet.read users.read], " \
76
+ "or nil if they are not known"
77
+ private_constant :INVALID_SCOPES
78
+
79
+ # A scope, which OAuth 2.0 names with printable characters other than a space, a quote, and a backslash
80
+ SCOPE = /\A[\x21\x23-\x5B\x5D-\x7E]+\z/
81
+ private_constant :SCOPE
82
+
83
+ # The message of the error raised for a credential that is an empty String
84
+ EMPTY_CREDENTIAL = "%s is empty. Pass the credential, or leave it out, since an empty one authenticates nothing"
85
+ private_constant :EMPTY_CREDENTIAL
86
+
87
+ # The message of the error raised for a credential that is not a String
88
+ NOT_A_STRING = "%s must be a String, not a %s"
89
+ private_constant :NOT_A_STRING
90
+
91
+ # The message raised for a credential an authenticator requires that is nil or empty
92
+ MISSING_CREDENTIAL = "%s is nil or empty. Pass the credential, which the authenticator cannot authenticate without"
93
+ private_constant :MISSING_CREDENTIAL
94
+
95
+ # The message of the error raised for an authenticator that is not one
96
+ NOT_AN_AUTHENTICATOR = "authenticator must be an X::Authenticator, such as an X::OAuth2Authenticator, or nil, " \
97
+ "not a %s"
98
+ private_constant :NOT_AN_AUTHENTICATOR
99
+
100
+ # The message of the error raised for an authenticator given beside credentials
101
+ AUTHENTICATOR_AND_CREDENTIALS = "An authenticator holds the credentials it authenticates with, so it cannot be " \
102
+ "given beside %s. Pass the authenticator, or the credentials, and leave out the other"
103
+ private_constant :AUTHENTICATOR_AND_CREDENTIALS
104
+
105
+ # Raise for a credential not a String or empty, or an expiration time not a Time
106
+ #
107
+ # An environment variable that is not set is often read as an empty String, as ENV.fetch("X_BEARER_TOKEN", "")
108
+ # reads it, which would send an Authorization header that authenticates nothing, for the API to refuse. A
109
+ # credential that is not a String, such as the Integer of a client ID read from YAML, would be signed with as the
110
+ # String it converts to, if it converts to one at all, so it is refused rather than sent as something else.
111
+ #
112
+ # @api private
113
+ # @param credentials [Hash{Symbol => String, Time, nil}] the credentials, as Client#initialize accepts them
114
+ # @return [void]
115
+ # @raise [ArgumentError] if a credential is neither a String nor nil, is an empty String, or one of whitespace
116
+ # alone, if the expiration time is neither a Time nor nil, or if the scopes are neither an Array of scopes nor
117
+ # nil
118
+ # @example Check the credentials of a client
119
+ # X::Core::CredentialValidator.validate_values!(bearer_token: "", expires_at: nil)
120
+ def validate_values!(credentials)
121
+ credentials.except(:expires_at, :scopes).each do |name, value|
122
+ next if value.nil?
123
+ raise ArgumentError, format(NOT_A_STRING, name, value.class) unless String === value
124
+ raise ArgumentError, format(EMPTY_CREDENTIAL, name) unless value.match?(/\S/)
125
+ end
126
+ validate_expires_at!(credentials[:expires_at])
127
+ validate_scopes!(credentials[:scopes])
128
+ end
129
+
130
+ # Raise for a credential an authenticator requires that is nil or empty
131
+ #
132
+ # An authenticator is given the credentials it authenticates with, so each is required, and one given as nil,
133
+ # as ENV[] reads a variable that is not set, or as an empty String, would authenticate nothing. They, and those it
134
+ # takes beside them, are checked as {#validate_values!} checks those of a client.
135
+ #
136
+ # @api private
137
+ # @param required [Hash{Symbol => String, nil}] the credentials the authenticator requires, by the names it
138
+ # takes them by
139
+ # @param others [Hash{Symbol => String, Time, nil}] the credentials and expiration time it takes beside them
140
+ # @return [void]
141
+ # @raise [ArgumentError] if a credential required is nil, an empty String, or one of whitespace alone
142
+ # @raise [ArgumentError] if a credential is neither a String nor nil, another credential is empty, or the
143
+ # expiration time is neither a Time nor nil
144
+ # @example Check the credential of a bearer token authenticator
145
+ # X::Core::CredentialValidator.validate_required!({bearer_token: ENV["X_BEARER_TOKEN"]})
146
+ def validate_required!(required, others = {})
147
+ required.each { |name, value| raise ArgumentError, format(MISSING_CREDENTIAL, name) unless value.to_s.match?(/\S/) }
148
+ validate_values!(required.merge(others))
149
+ end
150
+
151
+ # Raise for an expiration time that is not a Time
152
+ #
153
+ # A token refresh compares the expiration time with the current time before every request, which fails for a time
154
+ # stored as a number or a String.
155
+ #
156
+ # @api private
157
+ # @param expires_at [Object] the expiration time
158
+ # @return [void]
159
+ # @raise [ArgumentError] if the expiration time is neither a Time nor nil
160
+ # @example Check an expiration time
161
+ # X::Core::CredentialValidator.validate_expires_at!(Time.now + 7200)
162
+ def validate_expires_at!(expires_at)
163
+ raise ArgumentError, INVALID_EXPIRES_AT unless expires_at.nil? || expires_at.is_a?(Time)
164
+ end
165
+
166
+ # Raise for scopes that are not an Array of scopes
167
+ #
168
+ # @api private
169
+ # @param scopes [Object] the scopes
170
+ # @return [void]
171
+ # @raise [ArgumentError] if the scopes are neither an Array of Strings that each name a scope nor nil
172
+ # @example Check scopes
173
+ # X::Core::CredentialValidator.validate_scopes!(%w[tweet.read users.read])
174
+ def validate_scopes!(scopes)
175
+ raise ArgumentError, INVALID_SCOPES unless scopes.nil? || scopes?(scopes)
176
+ end
177
+
178
+ # Check whether scopes are an Array of Strings that each name a scope
179
+ #
180
+ # @api private
181
+ # @param scopes [Object] the scopes
182
+ # @return [Boolean] true if the scopes are an Array of Strings that each name a scope
183
+ # @example Check scopes
184
+ # X::Core::CredentialValidator.scopes?(%w[tweet.read users.read]) # => true
185
+ def scopes?(scopes) = scopes.is_a?(Array) && scopes.all? { |scope| scope.is_a?(String) && SCOPE.match?(scope) }
186
+
187
+ # The scopes as the tokens, an authenticator, or a client holds them
188
+ #
189
+ # They are frozen, the Array and each String, apart from those given, so that the caller that gave them can
190
+ # change neither what the client holds nor what save_tokens is passed.
191
+ #
192
+ # @api private
193
+ # @param scopes [Array<String>, nil] the scopes, which validate_scopes! accepted
194
+ # @return [Array<String>, nil] the scopes, frozen, or nil for none
195
+ # @example Hold scopes
196
+ # X::Core::CredentialValidator.frozen_scopes(%w[tweet.read]) # => ["tweet.read"]
197
+ def frozen_scopes(scopes) = scopes&.map { |scope| -scope }.freeze
198
+
199
+ # Raise for an authenticator that is not one, or that is given beside credentials
200
+ #
201
+ # A client given an authenticator authenticates with it alone, so a credential given beside it, the expiration
202
+ # time of an OAuth 2.0 access token included, which an OAuth2Authenticator is built with, would go unused. The
203
+ # error names the class of something that is not an authenticator, rather than inspect it, since a Hash of
204
+ # credentials passed in its place would show them.
205
+ #
206
+ # @api private
207
+ # @param authenticator [Object] the authenticator, or nil for none
208
+ # @param credentials [Hash{Symbol => String, Time, nil}] the credentials, as Client#initialize accepts them
209
+ # @return [void]
210
+ # @raise [ArgumentError] if the authenticator is neither an Authenticator nor nil, or is given beside a
211
+ # credential
212
+ # @example Check the authenticator of a client
213
+ # X::Core::CredentialValidator.validate_authenticator!(authenticator, bearer_token: nil)
214
+ def validate_authenticator!(authenticator, credentials)
215
+ return if authenticator.nil?
216
+ raise ArgumentError, format(NOT_AN_AUTHENTICATOR, authenticator.class) unless authenticator.is_a?(Authenticator)
217
+
218
+ given = credentials.compact.keys
219
+ raise ArgumentError, format(AUTHENTICATOR_AND_CREDENTIALS, given.join(", ")) unless given.empty?
220
+ end
221
+
222
+ # Raise for incomplete credentials, or ones that leave others unused
223
+ #
224
+ # A client given OAuth 1.0a credentials authenticates with them, so OAuth 2.0 credentials given beside them, which
225
+ # would share their access token, raise. An expiration time and scopes are those of an OAuth 2.0 access token,
226
+ # which a client reads only when it authenticates with OAuth 2.0 credentials, so either given to a client that
227
+ # authenticates otherwise raises, as it does beside an authenticator.
228
+ #
229
+ # @api private
230
+ # @param credentials [Hash{Symbol => String, Time, Array<String>, nil}] the credentials, as Client#initialize
231
+ # accepts them
232
+ # @return [void]
233
+ # @raise [ArgumentError] if a credential belongs to no complete set, OAuth 2.0 credentials are given beside
234
+ # OAuth 1.0a ones, or an expiration time or scopes are given to a client that does not authenticate with OAuth
235
+ # 2.0 credentials
236
+ # @example Check the credentials of a client
237
+ # X::Core::CredentialValidator.validate!(api_key: "key")
238
+ def validate!(credentials)
239
+ raise ArgumentError, INCOMPLETE_CREDENTIALS if incomplete?(credentials)
240
+
241
+ unused = unused_oauth2_credentials(credentials)
242
+ raise ArgumentError, format(UNUSED_OAUTH2_CREDENTIALS, unused.join(", ")) unless unused.empty?
243
+ raise ArgumentError, UNUSED_EXPIRES_AT if unused?(credentials, :expires_at)
244
+ raise ArgumentError, UNUSED_SCOPES if unused?(credentials, :scopes)
245
+ end
246
+
247
+ private
248
+
249
+ # Check whether a credential was given that belongs to no complete set
250
+ #
251
+ # The expiration time and scopes are no credentials, so they belong to no set, and unused? checks them.
252
+ #
253
+ # @api private
254
+ # @param credentials [Hash{Symbol => String, Time, Array<String>, nil}] the credentials
255
+ # @return [Boolean] true if the credentials do not form complete sets
256
+ def incomplete?(credentials)
257
+ given = credentials.except(:expires_at, :scopes).compact.keys
258
+ complete = CREDENTIAL_SETS.select { |set| (set - given).empty? }
259
+ (given - complete.flatten).any?
260
+ end
261
+
262
+ # The OAuth 2.0 credentials given beside OAuth 1.0a ones, which would go unused
263
+ #
264
+ # @api private
265
+ # @param credentials [Hash{Symbol => String, Time, Array<String>, nil}] the credentials
266
+ # @return [Array<Symbol>] the names of the OAuth 2.0 credentials given, or none when OAuth 1.0a ones are not
267
+ def unused_oauth2_credentials(credentials)
268
+ given = credentials.compact.keys
269
+ (OAUTH1_CREDENTIALS - given).empty? ? OAUTH2_ONLY_CREDENTIALS & given : []
270
+ end
271
+
272
+ # Check whether expires_at or scopes were given that the client would leave unused
273
+ #
274
+ # @api private
275
+ # @param credentials [Hash{Symbol => String, Time, Array<String>, nil}] the credentials
276
+ # @param name [Symbol] the name of what was given of the access token, expires_at or scopes
277
+ # @return [Boolean] true if it was given, and the client holds no OAuth 2.0 credentials, which validate! refuses
278
+ # beside OAuth 1.0a ones before it checks this
279
+ def unused?(credentials, name)
280
+ given = credentials.compact.keys
281
+ given.include?(name) && !(OAUTH2_CREDENTIALS - given).empty?
282
+ end
283
+ end
284
+ private_constant :CredentialValidator
285
+ end
286
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "error"
4
+
5
+ module X
6
+ # Error raised when the redirect back from X reports that the app was not authorized
7
+ #
8
+ # X redirects the user back to the app with an error, rather than a code, when the user declines to authorize the
9
+ # app, or when X cannot ask them, and the redirect is refused when its state does not match the one the app sent,
10
+ # or when it is not a valid URL. The redirect is a request of the user's browser, not a response of X, so the error
11
+ # holds no response, and descends from Error directly; a refusal of X to exchange the code raises
12
+ # AuthorizationError, a ClientError that holds the response of X.
13
+ #
14
+ # @api public
15
+ class AuthorizationDenied < Error
16
+ # The OAuth 2.0 error code the redirect reported, such as access_denied
17
+ # @api public
18
+ # @return [String, nil] the error code, or nil for a redirect that reported none, as one whose state does not
19
+ # match reports none
20
+ # @example Tell a user who declined from a failure
21
+ # rescue X::AuthorizationDenied => e
22
+ # redirect_to root_path if e.error_code.eql?("access_denied")
23
+ attr_reader :error_code
24
+
25
+ # Build the error of a redirect that simple_oauth refused
26
+ #
27
+ # Internal to x-core: it takes an error of simple_oauth, whose type may change within 1.x, and is private, so
28
+ # OAuth2Authorization calls it with __send__.
29
+ #
30
+ # @api private
31
+ # @param error [SimpleOAuth::OAuth2::Error] the failure
32
+ # @param default_message [String] the message when the redirect describes no reason
33
+ # @return [AuthorizationDenied] a new instance
34
+ # @example Raise the error of a redirect that reported a user who declined
35
+ # raise X::AuthorizationDenied.__send__(:from, error, "Authorization failed")
36
+ def self.from(error, default_message) = new(error.description || error.code || default_message, error_code: error.code)
37
+ private_class_method :from
38
+
39
+ # Initialize a new AuthorizationDenied
40
+ #
41
+ # @api public
42
+ # @param message [String, nil] the reason the app was not authorized, or nil for the name of the class, as an
43
+ # exception raised with no message is named
44
+ # @param error_code [String, nil] the OAuth 2.0 error code, or nil for none
45
+ # @return [AuthorizationDenied] a new instance
46
+ # @example Create an error
47
+ # X::AuthorizationDenied.new("The user denied the request", error_code: "access_denied")
48
+ # @example Raise the error with no message, as a test stub may
49
+ # raise X::AuthorizationDenied
50
+ def initialize(message = nil, error_code: nil)
51
+ super(message)
52
+ @error_code = error_code
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "client_error"
5
+
6
+ module X
7
+ # Error raised when X refuses to issue a token
8
+ #
9
+ # X refuses a token when it declines an authorization code, a refresh token, such as one that was revoked or already
10
+ # used, or an app's API key and secret. It answers with a response of 4xx, which the error holds, as any other
11
+ # ClientError holds the response that raised it, and the error code of the response tells the refusals apart. A
12
+ # token endpoint that fails to answer, with 429 Too Many Requests, a server error, a redirect, or a page that is not
13
+ # JSON, such as that of a proxy, firewall, or captive portal, refuses nothing, so it raises the HTTPError, such as
14
+ # the TooManyRequests or ServerError a client retries, or the InvalidResponse, a response of the API raises, rather
15
+ # than this error.
16
+ #
17
+ # It is a ClientError, so code that rescues the failures of a response, as rescue X::HTTPError does around the
18
+ # requests of a client, catches the refresh that X refuses in the middle of one. A client that refreshes an OAuth 2.0
19
+ # token the API rejected raises it when X refuses the refresh, with the Unauthorized that rejected the token as its
20
+ # cause, so rescue both to ask the user to authorize the app again: Unauthorized for credentials the API rejects,
21
+ # and this error for a refresh token X no longer accepts. The redirect back from X that reports a user who declined,
22
+ # or a state that does not match, carries no response of X, so it raises AuthorizationDenied instead.
23
+ #
24
+ # @api public
25
+ class AuthorizationError < ClientError
26
+ # The OAuth 2.0 error code X reported, such as invalid_grant or invalid_request
27
+ #
28
+ # It is read from the JSON of the body, whatever the content type of the response says, as OAuth 2.0 reads it.
29
+ #
30
+ # @api public
31
+ # @return [String, nil] the error code, or nil if X reported none
32
+ # @example Forget the tokens of a user whose refresh token X no longer accepts
33
+ # rescue X::AuthorizationError => e
34
+ # store.forget(user) if e.error_code.eql?("invalid_request")
35
+ def error_code
36
+ String.try_convert(Hash.try_convert(JSON.parse(body.to_s))&.[]("error"))
37
+ rescue JSON::ParserError
38
+ nil
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "server_error"
4
+
5
+ module X
6
+ # Raised for a 502 Bad Gateway response, which the API sends when it cannot reach the service behind it
7
+ # @api public
8
+ class BadGateway < ServerError; end
9
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "client_error"
4
+
5
+ module X
6
+ # Raised for a 400 Bad Request response, which the API sends for a request it cannot read, such as one whose
7
+ # parameters or body it does not take
8
+ # @api public
9
+ class BadRequest < ClientError; end
10
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module X
4
+ module Core
5
+ # Stands in for an error a callback raised, so that what runs it does not take it for an error of its own
6
+ #
7
+ # A request runs the client's on_response hook, the block it was passed, and whatever an object_class builds its
8
+ # objects with, inside the handlers that send it again, wait out a rate limit, and refresh a rejected token, where
9
+ # an X::ServerError, an X::TooManyRequests, or an X::Unauthorized a callback raised, such as one of a request it
10
+ # made itself, would otherwise send the request again, reading what the API billed again, or spend a refresh
11
+ # token. The block of Client#get_stream runs inside the request that reads its body, where an X::Unauthorized it
12
+ # raised would otherwise refresh a token, and the stream be opened again.
13
+ #
14
+ # Internal to x-core: Client#perform, ResponseParser, and the stream Client#get_stream opens tag the errors of a
15
+ # callback with it, and Client raises the error it holds in its place, noted as a callback's, so that
16
+ # Client#with_retries, which runs outside the request, does not send it again for that error either.
17
+ #
18
+ # @api private
19
+ class CallbackError < StandardError
20
+ # The errors of callbacks raised in place of the CallbackError that tagged them, each held as its own key for as
21
+ # long as anything else holds it
22
+ UNTAGGED = ObjectSpace::WeakMap.new
23
+ private_constant :UNTAGGED
24
+
25
+ # The error the callback raised
26
+ # @api private
27
+ # @return [StandardError] the error the callback raised
28
+ # @example Raise the error a callback raised
29
+ # raise error.error
30
+ attr_reader :error
31
+
32
+ # Run a callback, tagging the error it raises
33
+ #
34
+ # An error already tagged, by a callback that runs within another, is raised as it is.
35
+ #
36
+ # @api private
37
+ # @yield [] runs the callback
38
+ # @return [Object] what the callback returned
39
+ # @raise [CallbackError] if the callback raised
40
+ # @example Run the hook of a response
41
+ # X::Core::CallbackError.tagging { on_response.call(summary) }
42
+ def self.tagging
43
+ yield
44
+ rescue CallbackError
45
+ raise
46
+ rescue => e
47
+ raise new(e)
48
+ end
49
+
50
+ # The error a CallbackError holds, noted as a callback's, to raise in its place
51
+ #
52
+ # @api private
53
+ # @param tagged [CallbackError] the CallbackError that tagged the error
54
+ # @return [StandardError] the error the callback raised
55
+ # @example Raise the error a callback raised once the request is left
56
+ # raise X::Core::CallbackError.untag(e)
57
+ def self.untag(tagged)
58
+ UNTAGGED[tagged.error] = tagged.error
59
+ end
60
+
61
+ # Check whether an error is one a callback raised, which a request raised untagged
62
+ #
63
+ # A request raises it in place of the CallbackError that tagged it, once its handlers are left.
64
+ #
65
+ # @api private
66
+ # @param error [StandardError] the error a request raised
67
+ # @return [Boolean] true if a callback raised it
68
+ # @example Check whether an error is a callback's
69
+ # X::Core::CallbackError.untagged?(error) # => false
70
+ def self.untagged?(error) = UNTAGGED[error].equal?(error)
71
+
72
+ # Initialize a new CallbackError
73
+ #
74
+ # It takes the message of the error it stands in for, which is read only if it escapes what runs the callback.
75
+ #
76
+ # @api private
77
+ # @param error [StandardError] the error the callback raised
78
+ # @return [CallbackError] a new instance
79
+ # @example Tag the error a callback raised
80
+ # raise X::Core::CallbackError, error
81
+ def initialize(error)
82
+ super
83
+ @error = error
84
+ end
85
+ end
86
+ private_constant :CallbackError
87
+ end
88
+ end