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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2dafb18f25e26e173b7496a0c304578e9466bcfe72b964a4af5139200d584f63
4
- data.tar.gz: 87d50d5fdda739560a269435ac8572ed3ebc05b5e7245e6234c3d88c5d3e9483
3
+ metadata.gz: 0b97ab44334b647867e9fdd400531d6e925a10533fb51be399c669c28d441c67
4
+ data.tar.gz: 03e6b5b78f62cdef778dea21128e8ea1b56955a1077c5ea040b6640694e7577a
5
5
  SHA512:
6
- metadata.gz: 9d5bc377eafb70ef85d1e112c13bb693723c29df345ecf63c52526263fc051a49fabdf256f85d7f20c5751b85d96ee5e9cc5e39c960c369772b4e41ec875dcaa
7
- data.tar.gz: c7f3ff170223b8448d10d44e9903b64bf4c0f3ab885569455fd8c7c7a5d16f76ff34f329722e531d4126575fba8bb785bbb292bff2d7447cf468fa6b47356f94
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
- #### 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",
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,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
- attr_reader :base_url
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(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"
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 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)
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
- @base_url = uri
48
+ @base_uri = base_uri
40
49
  end
41
50
 
42
51
  def debug_output
@@ -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.0")
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,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
- include Errors
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 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
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 base_url=: (URI::Generic | String new_base_url) -> URI::Generic
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: (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)
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: (:delete | :get | :post | :put http_method, URI::Generic url, nil body) -> (Net::HTTPRequest)
109
- def add_authorization: (Net::HTTPRequest request, Authenticator authenticator) -> void
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
- include Errors
116
- include ClientDefaults
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: (Class array_class, Class object_class) -> void
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
- include ClientDefaults
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 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
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 send_request: (:delete | :get | :post | :put http_method, String endpoint, ?nil body) -> untyped
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.7.1
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-02 00:00:00.000000000 Z
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/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
@@ -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