x 0.7.1 → 0.8.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 +4 -4
- data/CHANGELOG.md +6 -0
- data/README.md +7 -3
- data/Rakefile +1 -1
- data/lib/x/bearer_token_authenticator.rb +14 -0
- data/lib/x/client.rb +43 -17
- data/lib/x/connection.rb +20 -11
- data/lib/x/errors/error.rb +4 -12
- data/lib/x/errors/too_many_redirects_error.rb +5 -0
- data/lib/x/errors/too_many_requests_error.rb +1 -4
- data/lib/x/oauth_authenticator.rb +95 -0
- data/lib/x/redirect_handler.rb +56 -0
- data/lib/x/request_builder.rb +11 -6
- data/lib/x/response_handler.rb +22 -6
- data/lib/x/version.rb +1 -1
- data/sig/x.rbs +78 -49
- metadata +6 -5
- data/lib/x/authenticator.rb +0 -82
- data/lib/x/client_defaults.rb +0 -14
- data/lib/x/errors/errors.rb +0 -28
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 0b97ab44334b647867e9fdd400531d6e925a10533fb51be399c669c28d441c67
|
4
|
+
data.tar.gz: 03e6b5b78f62cdef778dea21128e8ea1b56955a1077c5ea040b6640694e7577a
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 47b47a9e6f98e6a61933d7f8f53d2477b2e921206894b2907171f04c2d94da05ebdbb25b501824240997d07c935cc0d2ea1269c6082e7488800c6c7d141f9c0e
|
7
|
+
data.tar.gz: a642eb6fa5e9b61b3cbcf7fff91506e7f44c6b5b5e7a7f16c4cc5ce7ed57d8d19191583fad4bd2bc174c8e9f010546c34d8c83a1b46444de3685805a4b6471dc
|
data/CHANGELOG.md
CHANGED
@@ -1,5 +1,11 @@
|
|
1
1
|
## [Unreleased]
|
2
2
|
|
3
|
+
## [0.8.0] - 2023-09-14
|
4
|
+
|
5
|
+
- Add (back) bearer token authentication (62e141d)
|
6
|
+
- Follow redirects (90a8c55)
|
7
|
+
- Parse error responses with Content-Type: application/problem+json (0b697d9)
|
8
|
+
|
3
9
|
## [0.7.1] - 2023-09-02
|
4
10
|
|
5
11
|
- Fix bug in X::Authenticator#split_uri (ebc9d5f)
|
data/README.md
CHANGED
@@ -1,6 +1,8 @@
|
|
1
|
-
# X
|
1
|
+
# A [Ruby](https://www.ruby-lang.org) interface to the [X API](https://developer.x.com)
|
2
2
|
|
3
|
-
|
3
|
+
## Follow
|
4
|
+
|
5
|
+
For updates and announcements, follow [this gem](https://x.com/gem) and [its creator](https://x.com/sferik) on X.
|
4
6
|
|
5
7
|
## Installation
|
6
8
|
|
@@ -14,9 +16,11 @@ Or, if Bundler is not being used to manage dependencies:
|
|
14
16
|
|
15
17
|
## Usage
|
16
18
|
|
17
|
-
First, obtain X credentails from https://developer.x.com
|
19
|
+
First, obtain X credentails from <https://developer.x.com>.
|
18
20
|
|
19
21
|
```ruby
|
22
|
+
require "x"
|
23
|
+
|
20
24
|
x_credentials = {
|
21
25
|
api_key: "INSERT YOUR X API KEY HERE",
|
22
26
|
api_key_secret: "INSERT YOUR X API KEY SECRET HERE",
|
data/Rakefile
CHANGED
@@ -0,0 +1,14 @@
|
|
1
|
+
module X
|
2
|
+
# Handles bearer token authentication
|
3
|
+
class BearerTokenAuthenticator
|
4
|
+
attr_accessor :bearer_token
|
5
|
+
|
6
|
+
def initialize(bearer_token)
|
7
|
+
@bearer_token = bearer_token
|
8
|
+
end
|
9
|
+
|
10
|
+
def header(_request)
|
11
|
+
"Bearer #{bearer_token}"
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
data/lib/x/client.rb
CHANGED
@@ -1,7 +1,8 @@
|
|
1
1
|
require "forwardable"
|
2
|
-
require_relative "
|
3
|
-
require_relative "
|
2
|
+
require_relative "bearer_token_authenticator"
|
3
|
+
require_relative "oauth_authenticator"
|
4
4
|
require_relative "connection"
|
5
|
+
require_relative "redirect_handler"
|
5
6
|
require_relative "request_builder"
|
6
7
|
require_relative "response_handler"
|
7
8
|
|
@@ -9,25 +10,36 @@ module X
|
|
9
10
|
# Main public interface
|
10
11
|
class Client
|
11
12
|
extend Forwardable
|
12
|
-
include ClientDefaults
|
13
13
|
|
14
|
-
def_delegators :@authenticator, :api_key, :api_key_secret, :access_token, :access_token_secret
|
15
|
-
def_delegators :@authenticator, :api_key=, :api_key_secret=, :access_token=, :access_token_secret=
|
16
|
-
def_delegators :@connection, :
|
17
|
-
def_delegators :@connection, :
|
14
|
+
def_delegators :@authenticator, :bearer_token, :api_key, :api_key_secret, :access_token, :access_token_secret
|
15
|
+
def_delegators :@authenticator, :bearer_token=, :api_key=, :api_key_secret=, :access_token=, :access_token_secret=
|
16
|
+
def_delegators :@connection, :base_uri, :open_timeout, :read_timeout, :write_timeout, :debug_output
|
17
|
+
def_delegators :@connection, :base_uri=, :open_timeout=, :read_timeout=, :write_timeout=, :debug_output=
|
18
18
|
def_delegators :@request_builder, :content_type, :user_agent
|
19
19
|
def_delegators :@request_builder, :content_type=, :user_agent=
|
20
20
|
def_delegators :@response_handler, :array_class, :object_class
|
21
21
|
def_delegators :@response_handler, :array_class=, :object_class=
|
22
22
|
|
23
|
-
def initialize(
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
23
|
+
def initialize(bearer_token: nil,
|
24
|
+
api_key: nil, api_key_secret: nil, access_token: nil, access_token_secret: nil,
|
25
|
+
base_url: Connection::DEFAULT_BASE_URL,
|
26
|
+
open_timeout: Connection::DEFAULT_OPEN_TIMEOUT,
|
27
|
+
read_timeout: Connection::DEFAULT_READ_TIMEOUT,
|
28
|
+
write_timeout: Connection::DEFAULT_WRITE_TIMEOUT,
|
29
|
+
content_type: RequestBuilder::DEFAULT_CONTENT_TYPE,
|
30
|
+
user_agent: RequestBuilder::DEFAULT_USER_AGENT,
|
31
|
+
debug_output: nil,
|
32
|
+
array_class: ResponseHandler::DEFAULT_ARRAY_CLASS,
|
33
|
+
object_class: ResponseHandler::DEFAULT_OBJECT_CLASS,
|
34
|
+
max_redirects: RedirectHandler::DEFAULT_MAX_REDIRECTS)
|
35
|
+
|
36
|
+
initialize_authenticator(bearer_token, api_key, api_key_secret, access_token, access_token_secret)
|
37
|
+
@connection = Connection.new(base_url: base_url, open_timeout: open_timeout, read_timeout: read_timeout,
|
38
|
+
write_timeout: write_timeout, debug_output: debug_output)
|
39
|
+
@request_builder = RequestBuilder.new(content_type: content_type, user_agent: user_agent)
|
40
|
+
@redirect_handler = RedirectHandler.new(@authenticator, @connection, @request_builder,
|
41
|
+
max_redirects: max_redirects)
|
42
|
+
@response_handler = ResponseHandler.new(array_class: array_class, object_class: object_class)
|
31
43
|
end
|
32
44
|
|
33
45
|
def get(endpoint)
|
@@ -48,10 +60,24 @@ module X
|
|
48
60
|
|
49
61
|
private
|
50
62
|
|
63
|
+
def initialize_authenticator(bearer_token, api_key, api_key_secret, access_token, access_token_secret)
|
64
|
+
@authenticator = if bearer_token
|
65
|
+
BearerTokenAuthenticator.new(bearer_token)
|
66
|
+
elsif api_key && api_key_secret && access_token && access_token_secret
|
67
|
+
OauthAuthenticator.new(api_key, api_key_secret, access_token, access_token_secret)
|
68
|
+
else
|
69
|
+
raise ArgumentError,
|
70
|
+
"Client must be initialized with either a bearer_token or " \
|
71
|
+
"an api_key, api_key_secret, access_token, and access_token_secret"
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
51
75
|
def send_request(http_method, endpoint, body = nil)
|
52
|
-
|
76
|
+
uri = URI.join(base_uri.to_s, endpoint)
|
77
|
+
request = @request_builder.build(@authenticator, http_method, uri, body: body)
|
53
78
|
response = @connection.send_request(request)
|
54
|
-
@
|
79
|
+
final_response = @redirect_handler.handle_redirects(response, request, base_uri)
|
80
|
+
@response_handler.handle(final_response)
|
55
81
|
end
|
56
82
|
end
|
57
83
|
end
|
data/lib/x/connection.rb
CHANGED
@@ -1,25 +1,34 @@
|
|
1
1
|
require "forwardable"
|
2
2
|
require "net/http"
|
3
3
|
require "uri"
|
4
|
-
require_relative "errors/errors"
|
5
4
|
require_relative "errors/network_error"
|
6
5
|
|
7
6
|
module X
|
8
7
|
# Sends HTTP requests
|
9
8
|
class Connection
|
10
9
|
extend Forwardable
|
11
|
-
include Errors
|
12
10
|
|
13
|
-
|
11
|
+
DEFAULT_BASE_URL = "https://api.twitter.com/2/".freeze
|
12
|
+
DEFAULT_OPEN_TIMEOUT = 60 # seconds
|
13
|
+
DEFAULT_READ_TIMEOUT = 60 # seconds
|
14
|
+
DEFAULT_WRITE_TIMEOUT = 60 # seconds
|
15
|
+
NETWORK_ERRORS = [
|
16
|
+
Errno::ECONNREFUSED,
|
17
|
+
Net::OpenTimeout,
|
18
|
+
Net::ReadTimeout
|
19
|
+
].freeze
|
20
|
+
|
21
|
+
attr_reader :base_uri
|
14
22
|
|
15
23
|
def_delegators :@http_client, :open_timeout, :read_timeout, :write_timeout
|
16
24
|
def_delegators :@http_client, :open_timeout=, :read_timeout=, :write_timeout=
|
17
25
|
def_delegator :@http_client, :set_debug_output, :debug_output=
|
18
26
|
|
19
|
-
def initialize(
|
20
|
-
|
21
|
-
|
22
|
-
@http_client
|
27
|
+
def initialize(base_url: DEFAULT_BASE_URL, open_timeout: DEFAULT_OPEN_TIMEOUT,
|
28
|
+
read_timeout: DEFAULT_READ_TIMEOUT, write_timeout: DEFAULT_WRITE_TIMEOUT, debug_output: nil)
|
29
|
+
self.base_uri = base_url
|
30
|
+
@http_client = Net::HTTP.new(base_uri.host, base_uri.port) if base_uri.host
|
31
|
+
@http_client.use_ssl = base_uri.scheme == "https"
|
23
32
|
@http_client.open_timeout = open_timeout
|
24
33
|
@http_client.read_timeout = read_timeout
|
25
34
|
@http_client.write_timeout = write_timeout
|
@@ -32,11 +41,11 @@ module X
|
|
32
41
|
raise NetworkError, "Network error: #{e.message}"
|
33
42
|
end
|
34
43
|
|
35
|
-
def
|
36
|
-
|
37
|
-
raise ArgumentError, "Invalid base URL" unless
|
44
|
+
def base_uri=(base_url)
|
45
|
+
base_uri = URI(base_url)
|
46
|
+
raise ArgumentError, "Invalid base URL" unless base_uri.is_a?(URI::HTTPS) || base_uri.is_a?(URI::HTTP)
|
38
47
|
|
39
|
-
@
|
48
|
+
@base_uri = base_uri
|
40
49
|
end
|
41
50
|
|
42
51
|
def debug_output
|
data/lib/x/errors/error.rb
CHANGED
@@ -1,24 +1,16 @@
|
|
1
1
|
require "json"
|
2
2
|
require "net/http"
|
3
|
-
require_relative "../client_defaults"
|
4
3
|
|
5
4
|
module X
|
6
5
|
# Base error class
|
7
6
|
class Error < ::StandardError
|
8
|
-
|
7
|
+
JSON_CONTENT_TYPE_REGEXP = %r{application/(problem\+|)json}
|
8
|
+
|
9
9
|
attr_reader :object
|
10
10
|
|
11
|
-
def initialize(msg, response
|
12
|
-
if
|
13
|
-
@object = JSON.parse(response.body, array_class: array_class, object_class: object_class)
|
14
|
-
end
|
11
|
+
def initialize(msg, response:)
|
12
|
+
@object = JSON.parse(response.body || "{}") if JSON_CONTENT_TYPE_REGEXP.match?(response["content-type"])
|
15
13
|
super(msg)
|
16
14
|
end
|
17
|
-
|
18
|
-
private
|
19
|
-
|
20
|
-
def json_response?(response)
|
21
|
-
response.is_a?(Net::HTTPResponse) && response.body && response["content-type"] == DEFAULT_CONTENT_TYPE
|
22
|
-
end
|
23
15
|
end
|
24
16
|
end
|
@@ -1,12 +1,9 @@
|
|
1
1
|
require_relative "client_error"
|
2
|
-
require_relative "../client_defaults"
|
3
2
|
|
4
3
|
module X
|
5
4
|
# Rate limit error
|
6
5
|
class TooManyRequestsError < ClientError
|
7
|
-
|
8
|
-
|
9
|
-
def initialize(msg, response:, array_class: DEFAULT_ARRAY_CLASS, object_class: DEFAULT_OBJECT_CLASS)
|
6
|
+
def initialize(msg, response:)
|
10
7
|
@response = response
|
11
8
|
super
|
12
9
|
end
|
@@ -0,0 +1,95 @@
|
|
1
|
+
require "base64"
|
2
|
+
require "cgi"
|
3
|
+
require "json"
|
4
|
+
require "openssl"
|
5
|
+
require "securerandom"
|
6
|
+
require "uri"
|
7
|
+
|
8
|
+
module X
|
9
|
+
# Handles OAuth authentication
|
10
|
+
class OauthAuthenticator
|
11
|
+
OAUTH_VERSION = "1.0".freeze
|
12
|
+
OAUTH_SIGNATURE_METHOD = "HMAC-SHA1".freeze
|
13
|
+
OAUTH_SIGNATURE_ALGORITHM = "sha1".freeze
|
14
|
+
|
15
|
+
attr_accessor :api_key, :api_key_secret, :access_token, :access_token_secret
|
16
|
+
|
17
|
+
def initialize(api_key, api_key_secret, access_token, access_token_secret)
|
18
|
+
@api_key = api_key
|
19
|
+
@api_key_secret = api_key_secret
|
20
|
+
@access_token = access_token
|
21
|
+
@access_token_secret = access_token_secret
|
22
|
+
end
|
23
|
+
|
24
|
+
def header(request)
|
25
|
+
method, url, query_params = parse_request(request)
|
26
|
+
build_oauth_header(method, url, query_params)
|
27
|
+
end
|
28
|
+
|
29
|
+
private
|
30
|
+
|
31
|
+
def parse_request(request)
|
32
|
+
uri = request.uri
|
33
|
+
query_params = parse_query_params(uri.query.to_s)
|
34
|
+
[request.method, uri_without_query(uri), query_params]
|
35
|
+
end
|
36
|
+
|
37
|
+
def parse_query_params(query_string)
|
38
|
+
URI.decode_www_form(query_string).to_h
|
39
|
+
end
|
40
|
+
|
41
|
+
def uri_without_query(uri)
|
42
|
+
uri.to_s.chomp("?#{uri.query}")
|
43
|
+
end
|
44
|
+
|
45
|
+
def build_oauth_header(method, url, query_params)
|
46
|
+
oauth_params = default_oauth_params
|
47
|
+
all_params = query_params.merge(oauth_params)
|
48
|
+
oauth_params["oauth_signature"] = generate_signature(method, url, all_params)
|
49
|
+
format_oauth_header(oauth_params)
|
50
|
+
end
|
51
|
+
|
52
|
+
def default_oauth_params
|
53
|
+
{
|
54
|
+
"oauth_consumer_key" => api_key,
|
55
|
+
"oauth_nonce" => SecureRandom.hex,
|
56
|
+
"oauth_signature_method" => OAUTH_SIGNATURE_METHOD,
|
57
|
+
"oauth_timestamp" => Time.now.utc.to_i.to_s,
|
58
|
+
"oauth_token" => access_token,
|
59
|
+
"oauth_version" => OAUTH_VERSION
|
60
|
+
}
|
61
|
+
end
|
62
|
+
|
63
|
+
def generate_signature(method, url, params)
|
64
|
+
base_string = signature_base_string(method, url, params)
|
65
|
+
hmac_signature(base_string)
|
66
|
+
end
|
67
|
+
|
68
|
+
def hmac_signature(base_string)
|
69
|
+
digest = OpenSSL::Digest.new(OAUTH_SIGNATURE_ALGORITHM)
|
70
|
+
hmac = OpenSSL::HMAC.digest(digest, signing_key, base_string)
|
71
|
+
Base64.strict_encode64(hmac)
|
72
|
+
end
|
73
|
+
|
74
|
+
def signature_base_string(method, url, params)
|
75
|
+
"#{method}&#{encode(url)}&#{encode(encode_params(params))}"
|
76
|
+
end
|
77
|
+
|
78
|
+
def encode_params(params)
|
79
|
+
params.sort.map { |k, v| "#{k}=#{encode(v.to_s)}" }.join("&")
|
80
|
+
end
|
81
|
+
|
82
|
+
def signing_key
|
83
|
+
"#{encode(api_key_secret)}&#{encode(access_token_secret)}"
|
84
|
+
end
|
85
|
+
|
86
|
+
def format_oauth_header(params)
|
87
|
+
"OAuth #{params.sort.map { |k, v| "#{k}=\"#{encode(v.to_s)}\"" }.join(", ")}"
|
88
|
+
end
|
89
|
+
|
90
|
+
def encode(value)
|
91
|
+
# TODO: Replace CGI.escape with CGI.escapeURIComponent when support for Ruby 3.1 is dropped
|
92
|
+
CGI.escape(value.to_s).gsub("+", "%20")
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|
@@ -0,0 +1,56 @@
|
|
1
|
+
require "net/http"
|
2
|
+
require "uri"
|
3
|
+
require_relative "connection"
|
4
|
+
require_relative "errors/too_many_redirects_error"
|
5
|
+
|
6
|
+
module X
|
7
|
+
# Handles HTTP redirects
|
8
|
+
class RedirectHandler
|
9
|
+
DEFAULT_MAX_REDIRECTS = 10
|
10
|
+
|
11
|
+
attr_reader :authenticator, :connection, :request_builder, :max_redirects
|
12
|
+
|
13
|
+
def initialize(authenticator, connection, request_builder, max_redirects: DEFAULT_MAX_REDIRECTS)
|
14
|
+
@authenticator = authenticator
|
15
|
+
@connection = connection
|
16
|
+
@request_builder = request_builder
|
17
|
+
@max_redirects = max_redirects
|
18
|
+
end
|
19
|
+
|
20
|
+
def handle_redirects(response, original_request, original_base_url, redirect_count = 0)
|
21
|
+
if response.is_a?(Net::HTTPRedirection)
|
22
|
+
raise TooManyRedirectsError.new("Too many redirects", response: response) if redirect_count >= max_redirects
|
23
|
+
|
24
|
+
new_uri = build_new_uri(response, original_base_url)
|
25
|
+
new_request = build_request(original_request, new_uri)
|
26
|
+
new_response = send_new_request(new_uri, new_request)
|
27
|
+
|
28
|
+
handle_redirects(new_response, new_request, original_base_url, redirect_count + 1)
|
29
|
+
else
|
30
|
+
response
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
private
|
35
|
+
|
36
|
+
def build_new_uri(response, original_base_url)
|
37
|
+
location = response["location"].to_s
|
38
|
+
new_uri = URI.parse(location)
|
39
|
+
new_uri = URI.join(original_base_url.to_s, location) if new_uri.relative?
|
40
|
+
new_uri
|
41
|
+
end
|
42
|
+
|
43
|
+
def build_request(original_request, new_uri)
|
44
|
+
http_method = original_request.method.downcase.to_sym
|
45
|
+
body = original_request.body if original_request.body
|
46
|
+
request_builder.build(authenticator, http_method, new_uri, body: body)
|
47
|
+
end
|
48
|
+
|
49
|
+
def send_new_request(new_uri, new_request)
|
50
|
+
@connection = Connection.new(base_url: new_uri, open_timeout: connection.open_timeout,
|
51
|
+
read_timeout: connection.read_timeout, write_timeout: connection.write_timeout,
|
52
|
+
debug_output: connection.debug_output)
|
53
|
+
connection.send_request(new_request)
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
data/lib/x/request_builder.rb
CHANGED
@@ -1,5 +1,6 @@
|
|
1
1
|
require "net/http"
|
2
2
|
require "uri"
|
3
|
+
require_relative "version"
|
3
4
|
|
4
5
|
module X
|
5
6
|
# Creates HTTP requests
|
@@ -10,16 +11,20 @@ module X
|
|
10
11
|
put: Net::HTTP::Put,
|
11
12
|
delete: Net::HTTP::Delete
|
12
13
|
}.freeze
|
14
|
+
DEFAULT_CONTENT_TYPE = "application/json; charset=utf-8".freeze
|
15
|
+
DEFAULT_USER_AGENT = "X-Client/#{VERSION} Ruby/#{RUBY_VERSION}".freeze
|
16
|
+
AUTHORIZATION_HEADER = "Authorization".freeze
|
17
|
+
CONTENT_TYPE_HEADER = "Content-Type".freeze
|
18
|
+
USER_AGENT_HEADER = "User-Agent".freeze
|
13
19
|
|
14
20
|
attr_accessor :content_type, :user_agent
|
15
21
|
|
16
|
-
def initialize(content_type, user_agent)
|
22
|
+
def initialize(content_type: DEFAULT_CONTENT_TYPE, user_agent: DEFAULT_USER_AGENT)
|
17
23
|
@content_type = content_type
|
18
24
|
@user_agent = user_agent
|
19
25
|
end
|
20
26
|
|
21
|
-
def build(authenticator, http_method,
|
22
|
-
url = URI.join(base_url.to_s, endpoint)
|
27
|
+
def build(authenticator, http_method, url, body: nil)
|
23
28
|
request = create_request(http_method, url, body)
|
24
29
|
add_authorization(request, authenticator)
|
25
30
|
add_content_type(request)
|
@@ -40,15 +45,15 @@ module X
|
|
40
45
|
end
|
41
46
|
|
42
47
|
def add_authorization(request, authenticator)
|
43
|
-
authenticator.
|
48
|
+
request.add_field(AUTHORIZATION_HEADER, authenticator.header(request))
|
44
49
|
end
|
45
50
|
|
46
51
|
def add_content_type(request)
|
47
|
-
request.add_field(
|
52
|
+
request.add_field(CONTENT_TYPE_HEADER, content_type) if content_type
|
48
53
|
end
|
49
54
|
|
50
55
|
def add_user_agent(request)
|
51
|
-
request.add_field(
|
56
|
+
request.add_field(USER_AGENT_HEADER, user_agent) if user_agent
|
52
57
|
end
|
53
58
|
end
|
54
59
|
end
|
data/lib/x/response_handler.rb
CHANGED
@@ -1,16 +1,32 @@
|
|
1
1
|
require "json"
|
2
2
|
require "net/http"
|
3
|
-
require_relative "errors/
|
3
|
+
require_relative "errors/bad_request_error"
|
4
|
+
require_relative "errors/authentication_error"
|
5
|
+
require_relative "errors/forbidden_error"
|
6
|
+
require_relative "errors/not_found_error"
|
7
|
+
require_relative "errors/too_many_requests_error"
|
8
|
+
require_relative "errors/server_error"
|
9
|
+
require_relative "errors/service_unavailable_error"
|
4
10
|
|
5
11
|
module X
|
6
12
|
# Process HTTP responses
|
7
13
|
class ResponseHandler
|
8
|
-
|
9
|
-
|
14
|
+
DEFAULT_ARRAY_CLASS = Array
|
15
|
+
DEFAULT_OBJECT_CLASS = Hash
|
16
|
+
ERROR_CLASSES = {
|
17
|
+
400 => BadRequestError,
|
18
|
+
401 => AuthenticationError,
|
19
|
+
403 => ForbiddenError,
|
20
|
+
404 => NotFoundError,
|
21
|
+
429 => TooManyRequestsError,
|
22
|
+
500 => ServerError,
|
23
|
+
503 => ServiceUnavailableError
|
24
|
+
}.freeze
|
25
|
+
JSON_CONTENT_TYPE_REGEXP = %r{application/(problem\+|)json}
|
10
26
|
|
11
27
|
attr_accessor :array_class, :object_class
|
12
28
|
|
13
|
-
def initialize(array_class, object_class)
|
29
|
+
def initialize(array_class: DEFAULT_ARRAY_CLASS, object_class: DEFAULT_OBJECT_CLASS)
|
14
30
|
@array_class = array_class
|
15
31
|
@object_class = object_class
|
16
32
|
end
|
@@ -22,13 +38,13 @@ module X
|
|
22
38
|
|
23
39
|
error_class = ERROR_CLASSES[response.code.to_i] || Error
|
24
40
|
error_message = "#{response.code} #{response.message}"
|
25
|
-
raise error_class.new(error_message, response: response
|
41
|
+
raise error_class.new(error_message, response: response)
|
26
42
|
end
|
27
43
|
|
28
44
|
private
|
29
45
|
|
30
46
|
def successful_json_response?(response)
|
31
|
-
response.is_a?(Net::HTTPSuccess) && response.body && response["content-type"]
|
47
|
+
response.is_a?(Net::HTTPSuccess) && response.body && JSON_CONTENT_TYPE_REGEXP.match?(response["content-type"])
|
32
48
|
end
|
33
49
|
end
|
34
50
|
end
|
data/lib/x/version.rb
CHANGED
data/sig/x.rbs
CHANGED
@@ -1,44 +1,44 @@
|
|
1
1
|
module X
|
2
2
|
VERSION: Gem::Version
|
3
3
|
|
4
|
-
class
|
4
|
+
class BearerTokenAuthenticator
|
5
|
+
attr_accessor bearer_token: String
|
6
|
+
def initialize: (String bearer_token) -> void
|
7
|
+
def header: (Net::HTTPRequest _request) -> String
|
8
|
+
end
|
9
|
+
|
10
|
+
class OauthAuthenticator
|
5
11
|
OAUTH_VERSION: String
|
6
12
|
OAUTH_SIGNATURE_METHOD: String
|
13
|
+
OAUTH_SIGNATURE_ALGORITHM: String
|
7
14
|
|
8
15
|
attr_accessor api_key: String
|
9
16
|
attr_accessor api_key_secret: String
|
10
17
|
attr_accessor access_token: String
|
11
18
|
attr_accessor access_token_secret: String
|
12
19
|
def initialize: (String api_key, String api_key_secret, String access_token, String access_token_secret) -> void
|
13
|
-
def
|
20
|
+
def header: (Net::HTTPRequest request) -> String
|
14
21
|
|
15
22
|
private
|
16
|
-
def
|
17
|
-
def
|
23
|
+
def parse_request: (Net::HTTPRequest request) -> [String, String, Hash[String, String]]
|
24
|
+
def parse_query_params: (String query_string) -> Hash[String, String]
|
25
|
+
def uri_without_query: (URI::Generic uri) -> String
|
26
|
+
def build_oauth_header: (String method, String url, Hash[String, String] query_params) -> String
|
18
27
|
def default_oauth_params: -> Hash[String, String]
|
19
|
-
def generate_signature: (String method, String
|
20
|
-
def
|
28
|
+
def generate_signature: (String method, String url, Hash[String, String] params) -> String
|
29
|
+
def hmac_signature: (String base_string) -> String
|
30
|
+
def signature_base_string: (String method, String url, Hash[String, String] params) -> String
|
21
31
|
def encode_params: (Hash[String, String] params) -> String
|
22
32
|
def signing_key: -> String
|
23
|
-
def
|
24
|
-
|
25
|
-
|
26
|
-
module ClientDefaults
|
27
|
-
DEFAULT_BASE_URL: String
|
28
|
-
DEFAULT_CONTENT_TYPE: String
|
29
|
-
DEFAULT_OPEN_TIMEOUT: Integer
|
30
|
-
DEFAULT_READ_TIMEOUT: Integer
|
31
|
-
DEFAULT_WRITE_TIMEOUT: Integer
|
32
|
-
DEFAULT_USER_AGENT: String
|
33
|
-
DEFAULT_ARRAY_CLASS: Class
|
34
|
-
DEFAULT_OBJECT_CLASS: Class
|
33
|
+
def format_oauth_header: (Hash[String, String] params) -> String
|
34
|
+
def encode: (String value) -> String
|
35
35
|
end
|
36
36
|
|
37
37
|
class Error < StandardError
|
38
|
-
|
38
|
+
JSON_CONTENT_TYPE_REGEXP: Regexp
|
39
39
|
|
40
|
-
attr_reader object: untyped
|
41
|
-
def initialize: (String msg, response: Net::HTTPResponse
|
40
|
+
attr_reader object: Hash[String, untyped]
|
41
|
+
def initialize: (String msg, response: Net::HTTPResponse) -> void
|
42
42
|
|
43
43
|
private
|
44
44
|
def json_response?: (Net::HTTPResponse response) -> bool
|
@@ -59,11 +59,13 @@ module X
|
|
59
59
|
class NotFoundError < ClientError
|
60
60
|
end
|
61
61
|
|
62
|
+
class TooManyRedirectsError < ClientError
|
63
|
+
end
|
64
|
+
|
62
65
|
class TooManyRequestsError < ClientError
|
63
|
-
include ClientDefaults
|
64
66
|
@response: Net::HTTPResponse
|
65
67
|
|
66
|
-
def initialize: (String msg, response: Net::HTTPResponse
|
68
|
+
def initialize: (String msg, response: Net::HTTPResponse) -> void
|
67
69
|
def limit: -> Integer
|
68
70
|
def remaining: -> Integer
|
69
71
|
def reset_at: -> Time
|
@@ -76,49 +78,75 @@ module X
|
|
76
78
|
class ServiceUnavailableError < ServerError
|
77
79
|
end
|
78
80
|
|
79
|
-
module Errors
|
80
|
-
ERROR_CLASSES: Hash[Integer, singleton(AuthenticationError) | singleton(BadRequestError) | singleton(ForbiddenError) | singleton(NotFoundError) | singleton(ServerError) | singleton(ServiceUnavailableError) | singleton(TooManyRequestsError)]
|
81
|
-
NETWORK_ERRORS: Array[(singleton(::Errno::ECONNREFUSED) | singleton(::Net::OpenTimeout) | singleton(::Net::ReadTimeout))]
|
82
|
-
end
|
83
|
-
|
84
81
|
class NetworkError < Error
|
85
82
|
end
|
86
83
|
|
87
84
|
class Connection
|
88
85
|
extend Forwardable
|
89
|
-
|
86
|
+
|
87
|
+
DEFAULT_BASE_URL: String
|
88
|
+
DEFAULT_OPEN_TIMEOUT: Integer
|
89
|
+
DEFAULT_READ_TIMEOUT: Integer
|
90
|
+
DEFAULT_WRITE_TIMEOUT: Integer
|
91
|
+
NETWORK_ERRORS: Array[(singleton(::Errno::ECONNREFUSED) | singleton(::Net::OpenTimeout) | singleton(::Net::ReadTimeout))]
|
90
92
|
@http_client: Net::HTTP
|
91
93
|
|
92
|
-
attr_reader
|
93
|
-
|
94
|
+
attr_reader base_uri: URI::Generic
|
95
|
+
attr_reader open_timeout: Float | Integer
|
96
|
+
attr_reader read_timeout: Float | Integer
|
97
|
+
attr_reader write_timeout: Float | Integer
|
98
|
+
def initialize: (?base_url: URI::Generic | String, ?open_timeout: Float | Integer, ?read_timeout: Float | Integer, ?write_timeout: Float | Integer, ?debug_output: IO?) -> void
|
94
99
|
def send_request: (Net::HTTPRequest request) -> Net::HTTPResponse
|
95
|
-
def
|
100
|
+
def base_uri=: (URI::Generic | String base_url) -> URI::Generic
|
96
101
|
def debug_output: -> IO?
|
97
102
|
end
|
98
103
|
|
99
104
|
class RequestBuilder
|
100
105
|
HTTP_METHODS: Hash[::Symbol, (singleton(::Net::HTTP::Get) | singleton(::Net::HTTP::Post) | singleton(::Net::HTTP::Put) | singleton(::Net::HTTP::Delete))]
|
106
|
+
DEFAULT_CONTENT_TYPE: String
|
107
|
+
DEFAULT_USER_AGENT: String
|
108
|
+
AUTHORIZATION_HEADER: String
|
109
|
+
CONTENT_TYPE_HEADER: String
|
110
|
+
USER_AGENT_HEADER: String
|
101
111
|
|
102
112
|
attr_accessor content_type: String
|
103
113
|
attr_accessor user_agent: String
|
104
|
-
def initialize: (
|
105
|
-
def build: (
|
114
|
+
def initialize: (?content_type: String, ?user_agent: String) -> void
|
115
|
+
def build: (BearerTokenAuthenticator | OauthAuthenticator authenticator, Symbol http_method, URI::Generic url, ?body: String?) -> (Net::HTTPRequest)
|
106
116
|
|
107
117
|
private
|
108
|
-
def create_request: (
|
109
|
-
def add_authorization: (Net::HTTPRequest request,
|
118
|
+
def create_request: (Symbol http_method, URI::Generic url, String? body) -> (Net::HTTPRequest)
|
119
|
+
def add_authorization: (Net::HTTPRequest request, BearerTokenAuthenticator | OauthAuthenticator authenticator) -> void
|
110
120
|
def add_content_type: (Net::HTTPRequest request) -> void
|
111
121
|
def add_user_agent: (Net::HTTPRequest request) -> void
|
112
122
|
end
|
113
123
|
|
124
|
+
class RedirectHandler
|
125
|
+
DEFAULT_MAX_REDIRECTS: Integer
|
126
|
+
|
127
|
+
attr_reader authenticator: BearerTokenAuthenticator | OauthAuthenticator
|
128
|
+
attr_reader connection: Connection
|
129
|
+
attr_reader request_builder: RequestBuilder
|
130
|
+
attr_reader max_redirects: Integer
|
131
|
+
def initialize: (BearerTokenAuthenticator | OauthAuthenticator authenticator, Connection connection, RequestBuilder request_builder, ?max_redirects: Integer) -> void
|
132
|
+
def handle_redirects: (Net::HTTPResponse response, Net::HTTPRequest original_request, URI::Generic | String original_base_url, ?Integer redirect_count) -> Net::HTTPResponse
|
133
|
+
|
134
|
+
private
|
135
|
+
def build_new_uri: (Net::HTTPResponse response, URI::Generic | String original_base_url) -> URI::Generic
|
136
|
+
def build_request: (Net::HTTPRequest original_request, URI::Generic new_uri) -> Net::HTTPRequest
|
137
|
+
def send_new_request: (URI::Generic new_uri, Net::HTTPRequest new_request) -> Net::HTTPResponse
|
138
|
+
end
|
139
|
+
|
114
140
|
class ResponseHandler
|
115
|
-
|
116
|
-
|
141
|
+
DEFAULT_ARRAY_CLASS: Class
|
142
|
+
DEFAULT_OBJECT_CLASS: Class
|
143
|
+
ERROR_CLASSES: Hash[Integer, singleton(AuthenticationError) | singleton(BadRequestError) | singleton(ForbiddenError) | singleton(NotFoundError) | singleton(ServerError) | singleton(ServiceUnavailableError) | singleton(TooManyRequestsError)]
|
144
|
+
JSON_CONTENT_TYPE_REGEXP: Regexp
|
117
145
|
|
118
146
|
attr_accessor array_class: Class
|
119
147
|
attr_accessor object_class: Class
|
120
|
-
def initialize: (
|
121
|
-
def handle: (Net::HTTPResponse response) -> untyped
|
148
|
+
def initialize: (?array_class: Class, ?object_class: Class) -> void
|
149
|
+
def handle: (Net::HTTPResponse response) -> Hash[String, untyped]
|
122
150
|
|
123
151
|
private
|
124
152
|
def successful_json_response?: (Net::HTTPResponse response) -> bool
|
@@ -126,20 +154,21 @@ module X
|
|
126
154
|
|
127
155
|
class Client
|
128
156
|
extend Forwardable
|
129
|
-
|
130
|
-
@authenticator: Authenticator
|
157
|
+
@authenticator: BearerTokenAuthenticator | OauthAuthenticator
|
131
158
|
@connection: Connection
|
132
159
|
@request_builder: RequestBuilder
|
160
|
+
@redirect_handler: RedirectHandler
|
133
161
|
@response_handler: ResponseHandler
|
134
162
|
|
135
|
-
attr_reader
|
136
|
-
def initialize: (api_key: String
|
137
|
-
def get: (String endpoint) -> untyped
|
138
|
-
def post: (String endpoint, ?nil body) -> untyped
|
139
|
-
def put: (String endpoint, ?nil body) -> untyped
|
140
|
-
def delete: (String endpoint) -> untyped
|
163
|
+
attr_reader base_uri: URI::Generic
|
164
|
+
def initialize: (?bearer_token: String?, ?api_key: String?, ?api_key_secret: String?, ?access_token: String?, ?access_token_secret: String?, ?base_url: URI::Generic | String, ?content_type: String, ?user_agent: String, ?open_timeout: Float | Integer, ?read_timeout: Float | Integer, ?write_timeout: Float | Integer, ?debug_output: IO?, ?array_class: Class, ?object_class: Class, ?max_redirects: Integer) -> void
|
165
|
+
def get: (String endpoint) -> Hash[String, untyped]
|
166
|
+
def post: (String endpoint, ?nil body) -> Hash[String, untyped]
|
167
|
+
def put: (String endpoint, ?nil body) -> Hash[String, untyped]
|
168
|
+
def delete: (String endpoint) -> Hash[String, untyped]
|
141
169
|
|
142
170
|
private
|
143
|
-
def
|
171
|
+
def initialize_authenticator: (String? bearer_token, String? api_key, String? api_key_secret, String? access_token, String? access_token_secret) -> (BearerTokenAuthenticator | OauthAuthenticator)
|
172
|
+
def send_request: (Symbol http_method, String endpoint, ?nil body) -> Hash[String, untyped]
|
144
173
|
end
|
145
174
|
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: x
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.8.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Erik Berlin
|
8
8
|
autorequire:
|
9
9
|
bindir: exe
|
10
10
|
cert_chain: []
|
11
|
-
date: 2023-09-
|
11
|
+
date: 2023-09-14 00:00:00.000000000 Z
|
12
12
|
dependencies: []
|
13
13
|
description:
|
14
14
|
email:
|
@@ -25,21 +25,22 @@ files:
|
|
25
25
|
- Rakefile
|
26
26
|
- Steepfile
|
27
27
|
- lib/x.rb
|
28
|
-
- lib/x/
|
28
|
+
- lib/x/bearer_token_authenticator.rb
|
29
29
|
- lib/x/client.rb
|
30
|
-
- lib/x/client_defaults.rb
|
31
30
|
- lib/x/connection.rb
|
32
31
|
- lib/x/errors/authentication_error.rb
|
33
32
|
- lib/x/errors/bad_request_error.rb
|
34
33
|
- lib/x/errors/client_error.rb
|
35
34
|
- lib/x/errors/error.rb
|
36
|
-
- lib/x/errors/errors.rb
|
37
35
|
- lib/x/errors/forbidden_error.rb
|
38
36
|
- lib/x/errors/network_error.rb
|
39
37
|
- lib/x/errors/not_found_error.rb
|
40
38
|
- lib/x/errors/server_error.rb
|
41
39
|
- lib/x/errors/service_unavailable_error.rb
|
40
|
+
- lib/x/errors/too_many_redirects_error.rb
|
42
41
|
- lib/x/errors/too_many_requests_error.rb
|
42
|
+
- lib/x/oauth_authenticator.rb
|
43
|
+
- lib/x/redirect_handler.rb
|
43
44
|
- lib/x/request_builder.rb
|
44
45
|
- lib/x/response_handler.rb
|
45
46
|
- lib/x/version.rb
|
data/lib/x/authenticator.rb
DELETED
@@ -1,82 +0,0 @@
|
|
1
|
-
require "base64"
|
2
|
-
require "cgi"
|
3
|
-
require "json"
|
4
|
-
require "openssl"
|
5
|
-
require "securerandom"
|
6
|
-
require "uri"
|
7
|
-
|
8
|
-
module X
|
9
|
-
# Handles OAuth authentication
|
10
|
-
class Authenticator
|
11
|
-
attr_accessor :api_key, :api_key_secret, :access_token, :access_token_secret
|
12
|
-
|
13
|
-
OAUTH_VERSION = "1.0".freeze
|
14
|
-
OAUTH_SIGNATURE_METHOD = "HMAC-SHA1".freeze
|
15
|
-
|
16
|
-
def initialize(api_key, api_key_secret, access_token, access_token_secret)
|
17
|
-
@api_key = api_key
|
18
|
-
@api_key_secret = api_key_secret
|
19
|
-
@access_token = access_token
|
20
|
-
@access_token_secret = access_token_secret
|
21
|
-
end
|
22
|
-
|
23
|
-
def sign!(request)
|
24
|
-
method = request.method
|
25
|
-
uri, query_params = split_uri(request.uri)
|
26
|
-
request.add_field("Authorization", oauth_header(method, uri, query_params))
|
27
|
-
end
|
28
|
-
|
29
|
-
private
|
30
|
-
|
31
|
-
def split_uri(uri)
|
32
|
-
query_string = uri.query.to_s
|
33
|
-
uri_base = uri.to_s.chomp("?#{query_string}")
|
34
|
-
query_params = URI.decode_www_form(query_string).to_h
|
35
|
-
[uri_base, query_params]
|
36
|
-
end
|
37
|
-
|
38
|
-
def oauth_header(method, uri, query_params)
|
39
|
-
oauth_params = default_oauth_params
|
40
|
-
all_params = query_params.merge(oauth_params)
|
41
|
-
oauth_params["oauth_signature"] = generate_signature(method, uri, all_params)
|
42
|
-
formatted_oauth_header(oauth_params)
|
43
|
-
end
|
44
|
-
|
45
|
-
def default_oauth_params
|
46
|
-
{
|
47
|
-
"oauth_consumer_key" => @api_key,
|
48
|
-
"oauth_nonce" => SecureRandom.hex,
|
49
|
-
"oauth_signature_method" => OAUTH_SIGNATURE_METHOD,
|
50
|
-
"oauth_timestamp" => Time.now.utc.to_i.to_s,
|
51
|
-
"oauth_token" => @access_token,
|
52
|
-
"oauth_version" => OAUTH_VERSION
|
53
|
-
}
|
54
|
-
end
|
55
|
-
|
56
|
-
def generate_signature(method, uri, params)
|
57
|
-
Base64.encode64(OpenSSL::HMAC.digest(
|
58
|
-
OpenSSL::Digest.new("sha1"),
|
59
|
-
signing_key,
|
60
|
-
signature_base_string(method, uri, params)
|
61
|
-
)).chomp
|
62
|
-
end
|
63
|
-
|
64
|
-
def signature_base_string(method, uri, params)
|
65
|
-
encoded_params = encode_params(params)
|
66
|
-
"#{method}&#{CGI.escape(uri)}&#{CGI.escape(encoded_params)}"
|
67
|
-
end
|
68
|
-
|
69
|
-
def encode_params(params)
|
70
|
-
# TODO: Replace CGI.escape with CGI.escapeURIComponent when support for Ruby 3.1 is dropped
|
71
|
-
params.sort.map { |k, v| "#{k}=#{CGI.escape(v.to_s)}" }.join("&").gsub("+", "%20")
|
72
|
-
end
|
73
|
-
|
74
|
-
def signing_key
|
75
|
-
"#{CGI.escape(@api_key_secret)}&#{CGI.escape(@access_token_secret)}"
|
76
|
-
end
|
77
|
-
|
78
|
-
def formatted_oauth_header(params)
|
79
|
-
"OAuth #{params.sort.map { |k, v| "#{k}=\"#{CGI.escape(v.to_s)}\"" }.join(", ")}"
|
80
|
-
end
|
81
|
-
end
|
82
|
-
end
|
data/lib/x/client_defaults.rb
DELETED
@@ -1,14 +0,0 @@
|
|
1
|
-
require_relative "version"
|
2
|
-
|
3
|
-
module X
|
4
|
-
module ClientDefaults
|
5
|
-
DEFAULT_BASE_URL = "https://api.twitter.com/2/".freeze
|
6
|
-
DEFAULT_CONTENT_TYPE = "application/json; charset=utf-8".freeze
|
7
|
-
DEFAULT_ARRAY_CLASS = Array
|
8
|
-
DEFAULT_OBJECT_CLASS = Hash
|
9
|
-
DEFAULT_OPEN_TIMEOUT = 60 # seconds
|
10
|
-
DEFAULT_READ_TIMEOUT = 60 # seconds
|
11
|
-
DEFAULT_WRITE_TIMEOUT = 60 # seconds
|
12
|
-
DEFAULT_USER_AGENT = "X-Client/#{VERSION} Ruby/#{RUBY_VERSION}".freeze
|
13
|
-
end
|
14
|
-
end
|
data/lib/x/errors/errors.rb
DELETED
@@ -1,28 +0,0 @@
|
|
1
|
-
require "net/http"
|
2
|
-
require_relative "bad_request_error"
|
3
|
-
require_relative "authentication_error"
|
4
|
-
require_relative "forbidden_error"
|
5
|
-
require_relative "not_found_error"
|
6
|
-
require_relative "too_many_requests_error"
|
7
|
-
require_relative "server_error"
|
8
|
-
require_relative "service_unavailable_error"
|
9
|
-
|
10
|
-
module X
|
11
|
-
module Errors
|
12
|
-
ERROR_CLASSES = {
|
13
|
-
400 => BadRequestError,
|
14
|
-
401 => AuthenticationError,
|
15
|
-
403 => ForbiddenError,
|
16
|
-
404 => NotFoundError,
|
17
|
-
429 => TooManyRequestsError,
|
18
|
-
500 => ServerError,
|
19
|
-
503 => ServiceUnavailableError
|
20
|
-
}.freeze
|
21
|
-
|
22
|
-
NETWORK_ERRORS = [
|
23
|
-
Errno::ECONNREFUSED,
|
24
|
-
Net::OpenTimeout,
|
25
|
-
Net::ReadTimeout
|
26
|
-
].freeze
|
27
|
-
end
|
28
|
-
end
|