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,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "client_error"
|
|
4
|
+
|
|
5
|
+
module X
|
|
6
|
+
# Raised for a 404 Not Found response
|
|
7
|
+
#
|
|
8
|
+
# The API sends one for an endpoint it does not serve. It answers a lookup of a resource that does not exist with
|
|
9
|
+
# 200 OK and no data, or with a 404 that reports the resource as not found, and a request of x-core raises that
|
|
10
|
+
# 404 as this error too. The finders of x-resources read either answer as a missing resource; see
|
|
11
|
+
# X::MissingResource.
|
|
12
|
+
#
|
|
13
|
+
# @api public
|
|
14
|
+
class NotFound < ClientError; end
|
|
15
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "client_error"
|
|
4
|
+
|
|
5
|
+
module X
|
|
6
|
+
# Raised for a 413 Payload Too Large response, which the API sends for a body larger than the endpoint takes, such
|
|
7
|
+
# as a media chunk above the size it accepts
|
|
8
|
+
# @api public
|
|
9
|
+
class PayloadTooLarge < ClientError; end
|
|
10
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "client_error"
|
|
4
|
+
|
|
5
|
+
module X
|
|
6
|
+
# Raised for a 402 Payment Required response, which the API sends for a request the account that pays for the app
|
|
7
|
+
# has no credit left to pay for, and which is refused again until credit is added
|
|
8
|
+
# @api public
|
|
9
|
+
class PaymentRequired < ClientError; end
|
|
10
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "http_error"
|
|
4
|
+
|
|
5
|
+
module X
|
|
6
|
+
# The base class of the errors of a 5xx response, which the API failed to answer
|
|
7
|
+
#
|
|
8
|
+
# The request is not the reason it failed, so the same request may pass later. A client given a max_retries above
|
|
9
|
+
# zero sends an idempotent request again after one, waiting a little longer before each attempt.
|
|
10
|
+
#
|
|
11
|
+
# @api public
|
|
12
|
+
# @example Retry a request the API failed to answer
|
|
13
|
+
# client = X::Client.new(bearer_token: token, max_retries: 2)
|
|
14
|
+
class ServerError < HTTPError; end
|
|
15
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "error"
|
|
4
|
+
|
|
5
|
+
module X
|
|
6
|
+
# Error raised when save_tokens raised for the tokens of an exchange of a code or of a refresh
|
|
7
|
+
#
|
|
8
|
+
# An authorization code works once, and so does a refresh token, so the tokens X issues for either are held in
|
|
9
|
+
# memory alone until save_tokens stores them: the first refresh token of X::OAuth2Authorization#client by the
|
|
10
|
+
# client it builds, and the one a refresh issued by the authenticator that refreshed, since the one it replaced is
|
|
11
|
+
# spent. They are not lost to a failure to store them, such as a database that is briefly down: the error holds
|
|
12
|
+
# them, so they can be stored again, and the client, which the exchange builds, or whose request refreshed. The
|
|
13
|
+
# error save_tokens raised is the cause, whose message the message ends with.
|
|
14
|
+
#
|
|
15
|
+
# @api public
|
|
16
|
+
class TokenReportFailed < Error
|
|
17
|
+
# The client the authorization built, or whose request refreshed the tokens
|
|
18
|
+
# @api public
|
|
19
|
+
# @return [Client, nil] the client, or nil for a refresh made by an authenticator alone, as its refresh! makes one
|
|
20
|
+
# @example Act for the user once the tokens are stored
|
|
21
|
+
# rescue X::TokenReportFailed => e
|
|
22
|
+
# store(e.tokens.refresh_token)
|
|
23
|
+
# e.client.get("users/me")
|
|
24
|
+
attr_reader :client
|
|
25
|
+
|
|
26
|
+
# The tokens of the exchange or the refresh, which save_tokens raised for
|
|
27
|
+
# @api public
|
|
28
|
+
# @return [OAuth2Tokens, nil] the tokens, or nil if none were given
|
|
29
|
+
# @example Store the tokens again
|
|
30
|
+
# rescue X::TokenReportFailed => e
|
|
31
|
+
# store(e.tokens.refresh_token)
|
|
32
|
+
attr_reader :tokens
|
|
33
|
+
|
|
34
|
+
# Initialize the error with the client and the tokens it failed to report
|
|
35
|
+
#
|
|
36
|
+
# @api public
|
|
37
|
+
# @param message [String, nil] the message, or nil for one that says the tokens of an exchange were not stored
|
|
38
|
+
# @param client [Client, nil] the client the authorization built, or whose request refreshed the tokens
|
|
39
|
+
# @param tokens [OAuth2Tokens, nil] the tokens of the exchange or the refresh
|
|
40
|
+
# @return [TokenReportFailed] a new error
|
|
41
|
+
# @example Raise the error for tokens save_tokens raised for
|
|
42
|
+
# raise X::TokenReportFailed.new(client:, tokens:)
|
|
43
|
+
def initialize(message = nil, client: nil, tokens: nil)
|
|
44
|
+
@client = client
|
|
45
|
+
@tokens = tokens
|
|
46
|
+
super(message || "The code was exchanged for tokens, but save_tokens raised for them")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# The message, ending with why save_tokens raised
|
|
50
|
+
#
|
|
51
|
+
# It ends with the message of the error save_tokens raised, which is the cause, if there is one.
|
|
52
|
+
#
|
|
53
|
+
# @api public
|
|
54
|
+
# @return [String] the message
|
|
55
|
+
# @example Read why the tokens were not stored
|
|
56
|
+
# error.message # => "The code was exchanged for tokens, but save_tokens raised for them: connection refused"
|
|
57
|
+
def to_s = [super, cause&.message].compact.join(": ")
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "error"
|
|
4
|
+
require_relative "../request_context"
|
|
5
|
+
|
|
6
|
+
module X
|
|
7
|
+
module Core
|
|
8
|
+
# Raised for a response that redirected more times than the client's max_redirects allows
|
|
9
|
+
#
|
|
10
|
+
# The message names the request whose redirect was one too many, and {#http_method} and {#uri} read it: the
|
|
11
|
+
# request that redirect answered, which is the one a client sent last, rather than the one it was first asked for.
|
|
12
|
+
#
|
|
13
|
+
# @api public
|
|
14
|
+
# @example Tell an endpoint that redirects in a loop from the others
|
|
15
|
+
# rescue X::TooManyRedirects => e
|
|
16
|
+
# logger.warn("#{e.uri} keeps redirecting")
|
|
17
|
+
class ::X::TooManyRedirects < Error
|
|
18
|
+
include RequestContext
|
|
19
|
+
|
|
20
|
+
# @!method http_method
|
|
21
|
+
# The HTTP method the request whose redirect was one too many was sent with
|
|
22
|
+
# @api public
|
|
23
|
+
# @return [Symbol, nil] the method, as :get, :post, :put, or :delete, or nil for an error built without one
|
|
24
|
+
# @example Tell a read that redirected in a loop from a write
|
|
25
|
+
# writes_failed += 1 unless error.http_method.eql?(:get)
|
|
26
|
+
# @!method uri
|
|
27
|
+
# The URI the request whose redirect was one too many was sent to
|
|
28
|
+
# @api public
|
|
29
|
+
# @return [URI::Generic, nil] the URI, or nil for an error built without one
|
|
30
|
+
# @example Get the path that keeps redirecting
|
|
31
|
+
# error.uri.path # => "/2/users/me"
|
|
32
|
+
|
|
33
|
+
# Initialize a new TooManyRedirects
|
|
34
|
+
#
|
|
35
|
+
# Public, so that code that rescues a TooManyRedirects can be tested with one built by hand, as RedirectHandler
|
|
36
|
+
# builds one, or raised with no message, as any exception is. The error names the request, when given its method
|
|
37
|
+
# and URI, as x-core names the request whose redirect was one too many.
|
|
38
|
+
#
|
|
39
|
+
# @api public
|
|
40
|
+
# @param message [String, nil] what went wrong, or nil for the name of the class, as an exception raised with no
|
|
41
|
+
# message is named, which names no request
|
|
42
|
+
# @param http_method [Symbol, String, nil] the method of the request whose redirect was one too many, in any case
|
|
43
|
+
# @param uri [URI::Generic, nil] the URI of the request whose redirect was one too many
|
|
44
|
+
# @return [TooManyRedirects] a new instance
|
|
45
|
+
# @example Create an error
|
|
46
|
+
# error = X::TooManyRedirects.new("Too many redirects", http_method: :get, uri: URI("https://api.x.com/2/users/me"))
|
|
47
|
+
def initialize(message = nil, http_method: nil, uri: nil)
|
|
48
|
+
name_request(http_method, uri)
|
|
49
|
+
super(message && message_naming_request(message))
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "client_error"
|
|
4
|
+
require_relative "../rate_limit"
|
|
5
|
+
|
|
6
|
+
module X
|
|
7
|
+
# Error raised when rate limit is exceeded (HTTP 429)
|
|
8
|
+
# @api public
|
|
9
|
+
class TooManyRequests < ClientError
|
|
10
|
+
# The rate limits the response reports in its headers, as X::Response reports them
|
|
11
|
+
#
|
|
12
|
+
# They are read once and frozen, since the other readers of the error, and the wait before a request is sent
|
|
13
|
+
# again, read them too, so that a caller changing them changes none of those.
|
|
14
|
+
#
|
|
15
|
+
# @api public
|
|
16
|
+
# @return [Array<RateLimit>] the 15-minute limit, and the 24-hour app and user limits when reported, frozen
|
|
17
|
+
# @example Print how many requests remain in each window
|
|
18
|
+
# error.rate_limits.each { |limit| puts "#{limit.type}: #{limit.remaining}" }
|
|
19
|
+
def rate_limits
|
|
20
|
+
@rate_limits ||= RateLimit.__send__(:all_from, http_response).freeze
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# The 15-minute rate limit of the endpoint, which nearly every response reports
|
|
24
|
+
#
|
|
25
|
+
# @api public
|
|
26
|
+
# @return [RateLimit, nil] the rate limit, or nil if the response reports none
|
|
27
|
+
# @example Read how many of the 15-minute requests are left
|
|
28
|
+
# error.rate_limit&.remaining # => 3
|
|
29
|
+
def rate_limit = rate_limits.find { |limit| limit.type.eql?(RateLimit::RATE_LIMIT_TYPE) }
|
|
30
|
+
|
|
31
|
+
# The rate limits with no requests left, one of which refused the request
|
|
32
|
+
#
|
|
33
|
+
# @api public
|
|
34
|
+
# @return [Array<RateLimit>] the reported limits that are exhausted
|
|
35
|
+
# @example Name the windows that are used up
|
|
36
|
+
# error.exhausted_rate_limits.map(&:type) # => ["app-limit-24hour"]
|
|
37
|
+
def exhausted_rate_limits = rate_limits.select(&:exhausted?)
|
|
38
|
+
|
|
39
|
+
# The exhausted rate limit that resets last, which a request waits for
|
|
40
|
+
#
|
|
41
|
+
# @api public
|
|
42
|
+
# @return [RateLimit, nil] the limit, or nil if the response reports none as exhausted
|
|
43
|
+
# @example Name the window that refused the request
|
|
44
|
+
# error.limiting_rate_limit&.type # => "app-limit-24hour"
|
|
45
|
+
def limiting_rate_limit = exhausted_rate_limits.max_by(&:reset_at)
|
|
46
|
+
|
|
47
|
+
# Get the time when the rate limit resets
|
|
48
|
+
#
|
|
49
|
+
# @api public
|
|
50
|
+
# @return [Time, nil] the reset time, or nil if the response does not say when the limit resets
|
|
51
|
+
# @example Get the reset time
|
|
52
|
+
# error.reset_at
|
|
53
|
+
def reset_at
|
|
54
|
+
limiting_rate_limit&.reset_at
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Get the seconds until the rate limit resets
|
|
58
|
+
#
|
|
59
|
+
# @api public
|
|
60
|
+
# @return [Integer, nil] the seconds until reset, or nil if the response does not say when the limit resets
|
|
61
|
+
# @example Get the time until reset
|
|
62
|
+
# error.reset_in
|
|
63
|
+
def reset_in
|
|
64
|
+
limiting_rate_limit&.reset_in
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# The seconds to wait before retrying, as the response asks
|
|
68
|
+
#
|
|
69
|
+
# A Retry-After header counts the seconds from when the response was sent, so it says the same thing however
|
|
70
|
+
# far this machine's clock is from the API's, where reset_in is off by the difference between the two. The
|
|
71
|
+
# header is preferred for that reason, and the limit that refused the request answers for a response that
|
|
72
|
+
# carries none. X recommends waiting a minute, doubling the wait for each retry after, when it says neither.
|
|
73
|
+
#
|
|
74
|
+
# @api public
|
|
75
|
+
# @return [Integer, nil] the seconds to wait before retrying, or nil if the response does not say
|
|
76
|
+
# @example Wait before retrying
|
|
77
|
+
# sleep(error.retry_after || 60)
|
|
78
|
+
def retry_after = super || reset_in
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "client_error"
|
|
4
|
+
|
|
5
|
+
module X
|
|
6
|
+
# Raised for a 401 Unauthorized response, which the API sends for credentials it does not accept, such as a token
|
|
7
|
+
# that has expired or been revoked, or a request that carries none
|
|
8
|
+
#
|
|
9
|
+
# A client that authenticates with OAuth 2.0 refreshes a token the API rejects and sends the request again, so it
|
|
10
|
+
# raises this error for a token that is rejected once refreshed, and an AuthorizationError, whose cause is this
|
|
11
|
+
# error, for a refresh X refuses.
|
|
12
|
+
#
|
|
13
|
+
# @api public
|
|
14
|
+
class Unauthorized < ClientError; end
|
|
15
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "client_error"
|
|
4
|
+
|
|
5
|
+
module X
|
|
6
|
+
# Raised for a 451 Unavailable For Legal Reasons response, which the API sends for a resource withheld in the
|
|
7
|
+
# country the request came from
|
|
8
|
+
# @api public
|
|
9
|
+
class UnavailableForLegalReasons < ClientError; end
|
|
10
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "client_error"
|
|
4
|
+
|
|
5
|
+
module X
|
|
6
|
+
# Raised for a 422 Unprocessable Entity response, which the API sends for a request it can read but will not act
|
|
7
|
+
# on, such as a stream rule it rejects
|
|
8
|
+
# @api public
|
|
9
|
+
class UnprocessableEntity < ClientError; end
|
|
10
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "error"
|
|
4
|
+
|
|
5
|
+
module X
|
|
6
|
+
# Raised when Marshal, YAML, or JSON reads what was written in a format this release does not read
|
|
7
|
+
#
|
|
8
|
+
# What the X gems write with Marshal or YAML, such as a resource, a page, or a problem, and the JSON of the tokens
|
|
9
|
+
# of OAuth 2.0, is plain data led by the number of
|
|
10
|
+
# its format, which every release of 1.x writes, each adding only what an earlier one ignores, so that a cache
|
|
11
|
+
# written by one release of 1.x is read by every other, and one written in another format, as a later major
|
|
12
|
+
# version may write, fails where it is read, rather than read as something it is not. Such a cache is written again.
|
|
13
|
+
#
|
|
14
|
+
# @api public
|
|
15
|
+
# @example Read a cached user again when it was written in another format
|
|
16
|
+
# begin
|
|
17
|
+
# Marshal.load(cached)
|
|
18
|
+
# rescue X::UnsupportedFormat
|
|
19
|
+
# Rails.cache.delete("user")
|
|
20
|
+
# end
|
|
21
|
+
class UnsupportedFormat < Error; end
|
|
22
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "client_error"
|
|
4
|
+
|
|
5
|
+
module X
|
|
6
|
+
# Raised for a 415 Unsupported Media Type response, which the API sends for a body in a format the endpoint does
|
|
7
|
+
# not take, such as a form where it takes JSON
|
|
8
|
+
# @api public
|
|
9
|
+
class UnsupportedMediaType < ClientError; end
|
|
10
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "error"
|
|
4
|
+
|
|
5
|
+
module X
|
|
6
|
+
# Raised when there is no way to do what was asked with what the client holds
|
|
7
|
+
#
|
|
8
|
+
# The API may offer none, as it offers no lookup of lists in a batch, or the credentials a client holds may not
|
|
9
|
+
# reach it: a client that authenticates with OAuth 2.0 as a user, and holds no credentials of the app, has no
|
|
10
|
+
# app-only client for Client#app_only to return, and an OAuth 2.0 authenticator that holds no refresh token, as an
|
|
11
|
+
# authorization without the offline.access scope issues none, cannot refresh its access token. Either is raised
|
|
12
|
+
# before any request. A request the API refuses the credentials of a client for raises the error of its response
|
|
13
|
+
# instead, as a stream, or the count of the full archive, of a client with no app-only client raises the
|
|
14
|
+
# X::Forbidden of the 403 the API answers it with.
|
|
15
|
+
#
|
|
16
|
+
# @api public
|
|
17
|
+
class UnsupportedOperation < Error; end
|
|
18
|
+
end
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "simple_oauth"
|
|
4
|
+
require "uri"
|
|
5
|
+
require_relative "authenticator"
|
|
6
|
+
require_relative "credential_validator"
|
|
7
|
+
require_relative "setting_validator"
|
|
8
|
+
|
|
9
|
+
module X
|
|
10
|
+
module Core
|
|
11
|
+
# Authenticator for OAuth 1.0a authentication
|
|
12
|
+
# @api public
|
|
13
|
+
class ::X::OAuth1Authenticator < Authenticator
|
|
14
|
+
# The media type whose body OAuth 1.0a signs as request parameters
|
|
15
|
+
FORM_CONTENT_TYPE = "application/x-www-form-urlencoded"
|
|
16
|
+
private_constant :FORM_CONTENT_TYPE
|
|
17
|
+
|
|
18
|
+
# The API key (consumer key)
|
|
19
|
+
# @api public
|
|
20
|
+
# @return [String] the API key (consumer key)
|
|
21
|
+
# @example Get the API key
|
|
22
|
+
# authenticator.api_key
|
|
23
|
+
attr_reader :api_key
|
|
24
|
+
|
|
25
|
+
# Initialize a new OAuth1Authenticator
|
|
26
|
+
#
|
|
27
|
+
# @api public
|
|
28
|
+
# @param api_key [String] the API key (consumer key)
|
|
29
|
+
# @param api_key_secret [String] the API key secret (consumer secret)
|
|
30
|
+
# @param access_token [String] the access token
|
|
31
|
+
# @param access_token_secret [String] the access token secret
|
|
32
|
+
# @return [OAuth1Authenticator] a new instance
|
|
33
|
+
# @raise [ArgumentError] if a credential is nil or empty
|
|
34
|
+
# @example Create an OAuth authenticator
|
|
35
|
+
# authenticator = X::OAuth1Authenticator.new(
|
|
36
|
+
# api_key: "key",
|
|
37
|
+
# api_key_secret: "secret",
|
|
38
|
+
# access_token: "token",
|
|
39
|
+
# access_token_secret: "token_secret"
|
|
40
|
+
# )
|
|
41
|
+
def initialize(api_key:, api_key_secret:, access_token:, access_token_secret:)
|
|
42
|
+
CredentialValidator.validate_required!({api_key:, api_key_secret:, access_token:, access_token_secret:})
|
|
43
|
+
@api_key, @api_key_secret = SettingValidator.frozen(api_key), SettingValidator.frozen(api_key_secret)
|
|
44
|
+
@access_token, @access_token_secret = SettingValidator.frozen(access_token), SettingValidator.frozen(access_token_secret)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# The identifier of the user the access token acts for
|
|
48
|
+
#
|
|
49
|
+
# An OAuth 1.0a access token begins with the identifier of the user who authorized it, so a client that signs
|
|
50
|
+
# with one knows the user it acts for without asking the API.
|
|
51
|
+
#
|
|
52
|
+
# @api public
|
|
53
|
+
# @return [Integer, nil] the identifier, or nil for a token that begins with none
|
|
54
|
+
# @example Read the user a client acts for without a request
|
|
55
|
+
# client.authenticator.user_id # => 7505382
|
|
56
|
+
def user_id
|
|
57
|
+
prefix = access_token[/\A(\d+)-/, 1]
|
|
58
|
+
Integer(prefix, 10) if prefix
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Generate the OAuth authentication headers for a request
|
|
62
|
+
#
|
|
63
|
+
# The signature covers the HTTP method, the URL, its query parameters, and a
|
|
64
|
+
# form-encoded body. Bodies of any other media type, such as the JSON and multipart
|
|
65
|
+
# bodies the X API takes, are not signed.
|
|
66
|
+
#
|
|
67
|
+
# @api public
|
|
68
|
+
# @param request [#http_method, #uri, #body, #[]] the request, whose method, uri, body, and Content-Type the signature reads
|
|
69
|
+
# @return [Hash{String => String}] the authentication header with OAuth signature
|
|
70
|
+
# @example Generate an OAuth authentication header
|
|
71
|
+
# authenticator.headers(request)
|
|
72
|
+
def headers(request)
|
|
73
|
+
oauth_header = SimpleOAuth::Header.new(request.http_method, request.uri, form_params(request), credentials)
|
|
74
|
+
{AUTHENTICATION_HEADER => oauth_header.to_s}
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
# The API key secret (consumer secret), which signs a request
|
|
80
|
+
# @api private
|
|
81
|
+
# @return [String] the API key secret (consumer secret)
|
|
82
|
+
# @example Sign with the API key secret
|
|
83
|
+
# api_key_secret
|
|
84
|
+
attr_reader :api_key_secret
|
|
85
|
+
|
|
86
|
+
# The access token, which signs each request and names the user it acts for
|
|
87
|
+
#
|
|
88
|
+
# It is a credential, so it is private, as the access token of a client is: the user it acts for is read with
|
|
89
|
+
# {#user_id}.
|
|
90
|
+
#
|
|
91
|
+
# @api private
|
|
92
|
+
# @return [String] the access token
|
|
93
|
+
# @example Sign with the access token
|
|
94
|
+
# access_token
|
|
95
|
+
attr_reader :access_token
|
|
96
|
+
|
|
97
|
+
# The access token secret, which signs a request
|
|
98
|
+
# @api private
|
|
99
|
+
# @return [String] the access token secret
|
|
100
|
+
# @example Sign with the access token secret
|
|
101
|
+
# access_token_secret
|
|
102
|
+
attr_reader :access_token_secret
|
|
103
|
+
|
|
104
|
+
# The credentials, under the names simple_oauth gives them
|
|
105
|
+
# @api private
|
|
106
|
+
# @return [Hash{Symbol => String}] the credentials for signing
|
|
107
|
+
def credentials
|
|
108
|
+
{consumer_key: api_key, consumer_secret: api_key_secret, token: access_token,
|
|
109
|
+
token_secret: access_token_secret}
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# The parameters a form-encoded body contributes to the signature
|
|
113
|
+
#
|
|
114
|
+
# A parameter that repeats is signed once per value, as RFC 5849 Section 3.4.1.3.2 requires,
|
|
115
|
+
# which the key-value pairs preserve.
|
|
116
|
+
#
|
|
117
|
+
# @api private
|
|
118
|
+
# @param request [#http_method, #uri, #body, #[]] the request
|
|
119
|
+
# @return [Array<Array(String, String)>] the body parameters, empty unless the body is form-encoded
|
|
120
|
+
def form_params(request)
|
|
121
|
+
URI.decode_www_form(form_body(request))
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# The body whose parameters take part in the signature
|
|
125
|
+
#
|
|
126
|
+
# @api private
|
|
127
|
+
# @param request [#http_method, #uri, #body, #[]] the request
|
|
128
|
+
# @return [String] the body, or an empty String when it is not form-encoded
|
|
129
|
+
def form_body(request)
|
|
130
|
+
form_encoded?(request) ? request.body.to_s : ""
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Check whether a request carries a form-encoded body
|
|
134
|
+
# @api private
|
|
135
|
+
# @param request [#http_method, #uri, #body, #[]] the request
|
|
136
|
+
# @return [Boolean] true if the body is form-encoded
|
|
137
|
+
def form_encoded?(request)
|
|
138
|
+
request["Content-Type"].to_s.split(";").first.to_s.strip.downcase.eql?(FORM_CONTENT_TYPE)
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|