x 0.7.1 → 0.8.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2dafb18f25e26e173b7496a0c304578e9466bcfe72b964a4af5139200d584f63
4
- data.tar.gz: 87d50d5fdda739560a269435ac8572ed3ebc05b5e7245e6234c3d88c5d3e9483
3
+ metadata.gz: 81a5b12497937f06eb22779e6c78ef9dc18d5e9c5b049c96b4da461b058c6998
4
+ data.tar.gz: 96f4e02f93df2e39589095f12d5a1e790f17159f932cc1ab72330e301815bda4
5
5
  SHA512:
6
- metadata.gz: 9d5bc377eafb70ef85d1e112c13bb693723c29df345ecf63c52526263fc051a49fabdf256f85d7f20c5751b85d96ee5e9cc5e39c960c369772b4e41ec875dcaa
7
- data.tar.gz: c7f3ff170223b8448d10d44e9903b64bf4c0f3ab885569455fd8c7c7a5d16f76ff34f329722e531d4126575fba8bb785bbb292bff2d7447cf468fa6b47356f94
6
+ metadata.gz: f8540864206f44e6863653ec538d076d4f1c696dab4fff2b8eeb33b587603301d2d62faedb163c59cc9366439b28a29acff4068876b4013a05bf88090c61042f
7
+ data.tar.gz: ef425363ab320f922e9f05f28f4d21a8090b763ef0d2468dfcd0ff7b9d0c456efa7f0a21b034fc750e5d927aafd5c4ae1b9407696d242512a44c7114e4efda4a
data/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.8.1] - 2023-09-20
4
+
5
+ - Fix bug where setting Connection#base_uri= doesn't update the HTTP client (d5a89db)
6
+
7
+ ## [0.8.0] - 2023-09-14
8
+
9
+ - Add (back) bearer token authentication (62e141d)
10
+ - Follow redirects (90a8c55)
11
+ - Parse error responses with Content-Type: application/problem+json (0b697d9)
12
+
3
13
  ## [0.7.1] - 2023-09-02
4
14
 
5
15
  - 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
- #### A Ruby interface to the X API.
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",
@@ -70,6 +74,20 @@ The tests for the previous version of this library executed in about 2 seconds.
70
74
 
71
75
  This code is not littered with comments that are intended to generate documentation. Rather, this code is intended to be simple enough to serve as its own documentation. If you want to understand how something works, don’t read the documentation—it might be wrong—read the code. The code is always right.
72
76
 
77
+ ## Sponsorship
78
+
79
+ The X gem is free to use, but with X API pricing tiers, it actually costs money to develop and maintain. By contributing to the project, you help us:
80
+
81
+ 1. Maintain the library: Keeping it up-to-date and secure.
82
+ 2. Add new features: Enhancements that make your life easier.
83
+ 3. Provide support: Faster responses to issues and feature requests.
84
+
85
+ ⭐️ Bonus: Sponsors will get priority support and influence over the project roadmap. We will also list your name or your company's logo on our GitHub page.
86
+
87
+ Building and maintaining an open-source project like this takes a considerable amount of time and effort. Your sponsorship can help sustain this project. Even a small monthly donation makes a huge difference!
88
+
89
+ [Click here to sponsor this project.](https://github.com/sponsors/sferik)
90
+
73
91
  ## Development
74
92
 
75
93
  1. Checkout and repo:
data/Rakefile CHANGED
@@ -4,7 +4,7 @@ require "rake/testtask"
4
4
  Rake::TestTask.new(:test) do |t|
5
5
  t.libs << "test"
6
6
  t.libs << "lib"
7
- t.test_files = FileList["test/**/test_*.rb"]
7
+ t.test_files = FileList["test/**/*_test.rb"]
8
8
  end
9
9
 
10
10
  require "standard/rake"
@@ -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 "authenticator"
3
- require_relative "client_defaults"
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, :base_url, :open_timeout, :read_timeout, :write_timeout, :debug_output
17
- def_delegators :@connection, :base_url=, :open_timeout=, :read_timeout=, :write_timeout=, :debug_output=
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(api_key:, api_key_secret:, access_token:, access_token_secret:,
24
- base_url: DEFAULT_BASE_URL, content_type: DEFAULT_CONTENT_TYPE, user_agent: DEFAULT_USER_AGENT,
25
- open_timeout: DEFAULT_OPEN_TIMEOUT, read_timeout: DEFAULT_READ_TIMEOUT, write_timeout: DEFAULT_WRITE_TIMEOUT,
26
- debug_output: nil, array_class: DEFAULT_ARRAY_CLASS, object_class: DEFAULT_OBJECT_CLASS)
27
- @authenticator = Authenticator.new(api_key, api_key_secret, access_token, access_token_secret)
28
- @connection = Connection.new(base_url, open_timeout, read_timeout, write_timeout, debug_output: debug_output)
29
- @request_builder = RequestBuilder.new(content_type, user_agent)
30
- @response_handler = ResponseHandler.new(array_class, object_class)
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
- request = @request_builder.build(@authenticator, http_method, base_url, endpoint, body: body)
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
- @response_handler.handle(response)
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,29 +1,40 @@
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
- attr_reader :base_url
11
+ DEFAULT_BASE_URL = "https://api.twitter.com/2/".freeze
12
+ DEFAULT_HOST = "https://api.twitter.com".freeze
13
+ DEFAULT_PORT = 443
14
+ DEFAULT_OPEN_TIMEOUT = 60 # seconds
15
+ DEFAULT_READ_TIMEOUT = 60 # seconds
16
+ DEFAULT_WRITE_TIMEOUT = 60 # seconds
17
+ NETWORK_ERRORS = [
18
+ Errno::ECONNREFUSED,
19
+ Net::OpenTimeout,
20
+ Net::ReadTimeout
21
+ ].freeze
22
+
23
+ attr_reader :base_uri, :http_client
14
24
 
15
25
  def_delegators :@http_client, :open_timeout, :read_timeout, :write_timeout
16
26
  def_delegators :@http_client, :open_timeout=, :read_timeout=, :write_timeout=
17
27
  def_delegator :@http_client, :set_debug_output, :debug_output=
18
28
 
19
- def initialize(url, open_timeout, read_timeout, write_timeout, debug_output: nil)
20
- self.base_url = url
21
- @http_client = Net::HTTP.new(base_url.host, base_url.port) if base_url.host
22
- @http_client.use_ssl = base_url.scheme == "https"
23
- @http_client.open_timeout = open_timeout
24
- @http_client.read_timeout = read_timeout
25
- @http_client.write_timeout = write_timeout
26
- @http_client.set_debug_output(debug_output) if debug_output
29
+ def initialize(base_url: DEFAULT_BASE_URL, open_timeout: DEFAULT_OPEN_TIMEOUT,
30
+ read_timeout: DEFAULT_READ_TIMEOUT, write_timeout: DEFAULT_WRITE_TIMEOUT, debug_output: nil)
31
+ self.base_uri = base_url
32
+ apply_http_client_settings(
33
+ open_timeout: open_timeout,
34
+ read_timeout: read_timeout,
35
+ write_timeout: write_timeout,
36
+ debug_output: debug_output
37
+ )
27
38
  end
28
39
 
29
40
  def send_request(request)
@@ -32,15 +43,53 @@ module X
32
43
  raise NetworkError, "Network error: #{e.message}"
33
44
  end
34
45
 
35
- def base_url=(new_base_url)
36
- uri = URI(new_base_url)
37
- raise ArgumentError, "Invalid base URL" unless uri.is_a?(URI::HTTPS) || uri.is_a?(URI::HTTP)
46
+ def base_uri=(base_url)
47
+ base_uri = URI(base_url)
48
+ raise ArgumentError, "Invalid base URL" unless base_uri.is_a?(URI::HTTPS) || base_uri.is_a?(URI::HTTP)
38
49
 
39
- @base_url = uri
50
+ @base_uri = base_uri
51
+ update_http_client_settings
40
52
  end
41
53
 
42
54
  def debug_output
43
55
  @http_client.instance_variable_get(:@debug_output)
44
56
  end
57
+
58
+ private
59
+
60
+ def apply_http_client_settings(open_timeout:, read_timeout:, write_timeout:, debug_output:)
61
+ @http_client.open_timeout = open_timeout
62
+ @http_client.read_timeout = read_timeout
63
+ @http_client.write_timeout = write_timeout
64
+ @http_client.set_debug_output(debug_output) if debug_output
65
+ end
66
+
67
+ def current_http_client_settings
68
+ {
69
+ open_timeout: @http_client.open_timeout,
70
+ read_timeout: @http_client.read_timeout,
71
+ write_timeout: @http_client.write_timeout,
72
+ debug_output: debug_output
73
+ }
74
+ end
75
+
76
+ def update_http_client_settings
77
+ conditionally_apply_http_client_settings do
78
+ host = @base_uri.host || DEFAULT_HOST
79
+ port = @base_uri.port || DEFAULT_PORT
80
+ @http_client = Net::HTTP.new(host, port)
81
+ @http_client.use_ssl = @base_uri.scheme == "https"
82
+ end
83
+ end
84
+
85
+ def conditionally_apply_http_client_settings
86
+ if @http_client
87
+ settings = current_http_client_settings
88
+ yield
89
+ apply_http_client_settings(**settings)
90
+ else
91
+ yield
92
+ end
93
+ end
45
94
  end
46
95
  end
@@ -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
- include ClientDefaults
7
+ JSON_CONTENT_TYPE_REGEXP = %r{application/(problem\+|)json}
8
+
9
9
  attr_reader :object
10
10
 
11
- def initialize(msg, response:, array_class: DEFAULT_ARRAY_CLASS, object_class: DEFAULT_OBJECT_CLASS)
12
- if json_response?(response)
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
@@ -0,0 +1,5 @@
1
+ require_relative "client_error"
2
+
3
+ module X
4
+ class TooManyRedirectsError < ClientError; end
5
+ 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
- include ClientDefaults
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
@@ -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, base_url, endpoint, body: nil)
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.sign!(request)
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("Content-Type", content_type) if content_type
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("User-Agent", user_agent) if user_agent
56
+ request.add_field(USER_AGENT_HEADER, user_agent) if user_agent
52
57
  end
53
58
  end
54
59
  end
@@ -1,16 +1,32 @@
1
1
  require "json"
2
2
  require "net/http"
3
- require_relative "errors/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
- include ClientDefaults
9
- include Errors
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, array_class: array_class, object_class: object_class)
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"] == DEFAULT_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
@@ -1,5 +1,5 @@
1
1
  require "rubygems/version"
2
2
 
3
3
  module X
4
- VERSION = Gem::Version.create("0.7.1")
4
+ VERSION = Gem::Version.create("0.8.1")
5
5
  end
data/sig/x.rbs CHANGED
@@ -1,44 +1,44 @@
1
1
  module X
2
2
  VERSION: Gem::Version
3
3
 
4
- class Authenticator
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 sign!: (Net::HTTPRequest request) -> void
20
+ def header: (Net::HTTPRequest request) -> String
14
21
 
15
22
  private
16
- def split_uri: (URI::Generic uri) -> [String, Hash[String, String]]
17
- def oauth_header: (String method, String uri, Hash[String, String] query_params) -> String
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 uri, Hash[String, String] params) -> String
20
- def signature_base_string: (String method, String uri, Hash[String, String] params) -> String
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 formatted_oauth_header: (Hash[String, String] params) -> String
24
- end
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
- include ClientDefaults
38
+ JSON_CONTENT_TYPE_REGEXP: Regexp
39
39
 
40
- attr_reader object: untyped
41
- def initialize: (String msg, response: Net::HTTPResponse, ?array_class: Class, ?object_class: Class) -> void
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, ?array_class: Class, ?object_class: Class) -> void
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,82 @@ 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
85
+ DEFAULT_BASE_URL: String
86
+ DEFAULT_HOST: String
87
+ DEFAULT_PORT: Integer
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))]
88
92
  extend Forwardable
89
- include Errors
90
93
  @http_client: Net::HTTP
91
94
 
92
- attr_reader base_url: URI::Generic
93
- def initialize: (URI::Generic | String url, Float | Integer open_timeout, Float | Integer read_timeout, Float | Integer write_timeout, ?debug_output: IO?) -> void
95
+ attr_reader base_uri: URI::Generic
96
+ attr_reader open_timeout : Float | Integer
97
+ attr_reader read_timeout : Float | Integer
98
+ attr_reader write_timeout : Float | Integer
99
+ def initialize: (?base_url: URI::Generic | String, ?open_timeout: Float | Integer, ?read_timeout: Float | Integer, ?write_timeout: Float | Integer, ?debug_output: IO?) -> void
94
100
  def send_request: (Net::HTTPRequest request) -> Net::HTTPResponse
95
- def base_url=: (URI::Generic | String new_base_url) -> URI::Generic
101
+ def base_uri=: (URI::Generic | String base_url) -> void
96
102
  def debug_output: -> IO?
103
+
104
+ private
105
+ def apply_http_client_settings: (open_timeout: Float | Integer, read_timeout: Float | Integer, write_timeout: Float | Integer, debug_output: IO?) -> untyped
106
+ def current_http_client_settings: -> {open_timeout: Float | Integer, read_timeout: Float | Integer, write_timeout: Float | Integer, debug_output: IO?}
107
+ def update_http_client_settings: -> untyped
108
+ def conditionally_apply_http_client_settings: { -> untyped } -> untyped
97
109
  end
98
110
 
99
111
  class RequestBuilder
100
112
  HTTP_METHODS: Hash[::Symbol, (singleton(::Net::HTTP::Get) | singleton(::Net::HTTP::Post) | singleton(::Net::HTTP::Put) | singleton(::Net::HTTP::Delete))]
113
+ DEFAULT_CONTENT_TYPE: String
114
+ DEFAULT_USER_AGENT: String
115
+ AUTHORIZATION_HEADER: String
116
+ CONTENT_TYPE_HEADER: String
117
+ USER_AGENT_HEADER: String
101
118
 
102
119
  attr_accessor content_type: String
103
120
  attr_accessor user_agent: String
104
- def initialize: (String content_type, String user_agent) -> void
105
- def build: (Authenticator authenticator, :delete | :get | :post | :put http_method, URI::Generic base_url, String endpoint, ?body: nil) -> (Net::HTTPRequest)
121
+ def initialize: (?content_type: String, ?user_agent: String) -> void
122
+ def build: (BearerTokenAuthenticator | OauthAuthenticator authenticator, Symbol http_method, URI::Generic url, ?body: String?) -> (Net::HTTPRequest)
106
123
 
107
124
  private
108
- def create_request: (:delete | :get | :post | :put http_method, URI::Generic url, nil body) -> (Net::HTTPRequest)
109
- def add_authorization: (Net::HTTPRequest request, Authenticator authenticator) -> void
125
+ def create_request: (Symbol http_method, URI::Generic url, String? body) -> (Net::HTTPRequest)
126
+ def add_authorization: (Net::HTTPRequest request, BearerTokenAuthenticator | OauthAuthenticator authenticator) -> void
110
127
  def add_content_type: (Net::HTTPRequest request) -> void
111
128
  def add_user_agent: (Net::HTTPRequest request) -> void
112
129
  end
113
130
 
131
+ class RedirectHandler
132
+ DEFAULT_MAX_REDIRECTS: Integer
133
+
134
+ attr_reader authenticator: BearerTokenAuthenticator | OauthAuthenticator
135
+ attr_reader connection: Connection
136
+ attr_reader request_builder: RequestBuilder
137
+ attr_reader max_redirects: Integer
138
+ def initialize: (BearerTokenAuthenticator | OauthAuthenticator authenticator, Connection connection, RequestBuilder request_builder, ?max_redirects: Integer) -> void
139
+ def handle_redirects: (Net::HTTPResponse response, Net::HTTPRequest original_request, URI::Generic | String original_base_url, ?Integer redirect_count) -> Net::HTTPResponse
140
+
141
+ private
142
+ def build_new_uri: (Net::HTTPResponse response, URI::Generic | String original_base_url) -> URI::Generic
143
+ def build_request: (Net::HTTPRequest original_request, URI::Generic new_uri) -> Net::HTTPRequest
144
+ def send_new_request: (URI::Generic new_uri, Net::HTTPRequest new_request) -> Net::HTTPResponse
145
+ end
146
+
114
147
  class ResponseHandler
115
- include Errors
116
- include ClientDefaults
148
+ DEFAULT_ARRAY_CLASS: Class
149
+ DEFAULT_OBJECT_CLASS: Class
150
+ ERROR_CLASSES: Hash[Integer, singleton(AuthenticationError) | singleton(BadRequestError) | singleton(ForbiddenError) | singleton(NotFoundError) | singleton(ServerError) | singleton(ServiceUnavailableError) | singleton(TooManyRequestsError)]
151
+ JSON_CONTENT_TYPE_REGEXP: Regexp
117
152
 
118
153
  attr_accessor array_class: Class
119
154
  attr_accessor object_class: Class
120
- def initialize: (Class array_class, Class object_class) -> void
121
- def handle: (Net::HTTPResponse response) -> untyped
155
+ def initialize: (?array_class: Class, ?object_class: Class) -> void
156
+ def handle: (Net::HTTPResponse response) -> Hash[String, untyped]
122
157
 
123
158
  private
124
159
  def successful_json_response?: (Net::HTTPResponse response) -> bool
@@ -126,20 +161,21 @@ module X
126
161
 
127
162
  class Client
128
163
  extend Forwardable
129
- include ClientDefaults
130
- @authenticator: Authenticator
164
+ @authenticator: BearerTokenAuthenticator | OauthAuthenticator
131
165
  @connection: Connection
132
166
  @request_builder: RequestBuilder
167
+ @redirect_handler: RedirectHandler
133
168
  @response_handler: ResponseHandler
134
169
 
135
- attr_reader base_url: URI::Generic
136
- def initialize: (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) -> void
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
170
+ attr_reader base_uri: URI::Generic
171
+ 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
172
+ def get: (String endpoint) -> Hash[String, untyped]
173
+ def post: (String endpoint, ?nil body) -> Hash[String, untyped]
174
+ def put: (String endpoint, ?nil body) -> Hash[String, untyped]
175
+ def delete: (String endpoint) -> Hash[String, untyped]
141
176
 
142
177
  private
143
- def send_request: (:delete | :get | :post | :put http_method, String endpoint, ?nil body) -> untyped
178
+ def initialize_authenticator: (String? bearer_token, String? api_key, String? api_key_secret, String? access_token, String? access_token_secret) -> (BearerTokenAuthenticator | OauthAuthenticator)
179
+ def send_request: (Symbol http_method, String endpoint, ?nil body) -> Hash[String, untyped]
144
180
  end
145
181
  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.7.1
4
+ version: 0.8.1
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-02 00:00:00.000000000 Z
11
+ date: 2023-09-20 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/authenticator.rb
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
@@ -68,7 +69,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
68
69
  - !ruby/object:Gem::Version
69
70
  version: '0'
70
71
  requirements: []
71
- rubygems_version: 3.4.18
72
+ rubygems_version: 3.4.19
72
73
  signing_key:
73
74
  specification_version: 4
74
75
  summary: A Ruby interface to the X API.
@@ -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
@@ -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
@@ -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