x 0.7.1 → 0.8.0

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: 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