x 0.2.0 → 0.3.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: c40d2b65cf1479d65e6f434d6d9c98cb32ad7aa62b739fd1207b491b7e276e4d
4
- data.tar.gz: 6dee4de59342deb6bbc8a5897f7a96e18ae6b7a856083ae84e1ee26580ce7482
3
+ metadata.gz: 56ad55c544f1c7774395d906e0d9d07b6dc2a6d20e37d7df0b3e5b9f7b7b2bb6
4
+ data.tar.gz: 1747e256413ee2aeae02d8ed0e351b6d411217b00e634a42aeec5299764d6fac
5
5
  SHA512:
6
- metadata.gz: d64f01c2f011e53621214597fd1a9ac80e777ce22db2eb5d48c6b2f760743efa2fa3aa6848e6549b3fc25290c80456873262ddf92dbffaff2d134040cb87555f
7
- data.tar.gz: 8fc7d2aca33c67534ac775cc97f6afce95ef035716c5799e3571fb7b3a0218fd5b4646b41d8c66f61e3bba1d58f22b6c56c67ca273199b99dd7c55829827215a
6
+ metadata.gz: c5b386cd7386457b148c555359e68c4f9cb72b598ff9eb0a75405f3af75599dca0d5fc1700b01653465b2e50e401b69e9f9e5e9dd126d6a56c46b6df96b5736c
7
+ data.tar.gz: 462957aeee740d46f4a83497e69bec852fa0a4d8a9e38aec6bf8ffcd8f432e862737fa34847995dce5a49c09a40d9a08607ace68a1314a32972292d881cbd1db
data/CHANGELOG.md CHANGED
@@ -1,5 +1,10 @@
1
1
  ## [Unreleased]
2
2
 
3
+ - Add accessors to X::Client (e61fa73)
4
+ - Add configurable read timeout (41502b9)
5
+ - Handle network-related errors (9ed1fb4)
6
+ - Include response body in errors (a203e6a)
7
+
3
8
  ## [0.2.0] - 2023-08-02
4
9
 
5
10
  - Allow configuration of base URL (4bc0531)
data/README.md CHANGED
@@ -15,22 +15,39 @@ If bundler is not being used to manage dependencies, install the gem by executin
15
15
  ## Usage
16
16
 
17
17
  ```ruby
18
- x_api_key = "YOUR_X_API_KEY"
19
- x_api_key_secret = "YOUR_X_API_KEY_SECRET"
20
- x_access_token = "YOUR_X_ACCESS_TOKEN"
21
- x_access_token_secret = "YOUR_X_ACCESS_TOKEN_SECRET"
22
-
23
- x_client = X::Client.new(api_key: x_api_key,
24
- api_key_secret: x_api_key_secret,
25
- access_token: x_access_token,
26
- access_token_secret: x_access_token_secret)
27
-
28
- begin
29
- response = x_client.get("users/me")
30
- puts JSON.pretty_generate(response)
31
- rescue X::Error => e
32
- puts "Error: #{e.message}"
33
- end
18
+ oauth_credentials = {
19
+ api_key: "INSERT YOUR X API KEY HERE",
20
+ api_key_secret: "INSERT YOUR X API KEY SECRET HERE",
21
+ access_token: "INSERT YOUR X API ACCESS TOKEN HERE",
22
+ access_token_secret: "INSERT YOUR X API ACCESS TOKEN SECRET HERE",
23
+ }
24
+
25
+ # Initialize X API client with OAuth credentials
26
+ x_client = X::Client.new(**oauth_credentials)
27
+
28
+ # Request yourself
29
+ x_client.get("users/me")
30
+ # {"data"=>{"id"=>"7505382", "name"=>"Erik Berlin", "username"=>"sferik"}}
31
+
32
+ # Post a tweet
33
+ tweet = x_client.post("tweets", '{"text":"Hello, World! (from @gem)"}')
34
+ # {"data"=>{"edit_history_tweet_ids"=>["1234567890123456789"], "id"=>"1234567890123456789", "text"=>"Hello, World! (from @gem)"}}
35
+
36
+ # Delete a tweet
37
+ x_client.delete("tweets/#{tweet["data"]["id"]}")
38
+ # {"data"=>{"deleted"=>true}}
39
+
40
+ # Initialize an API v1.1 client
41
+ v1_client = X::Client.new(base_url: "https://api.twitter.com/1.1/", **oauth_credentials)
42
+
43
+ # Request your account settings
44
+ v1_client.get("account/settings.json")
45
+
46
+ # Initialize an X Ads API client
47
+ ads_client = X::Client.new(base_url: "https://ads-api.twitter.com/12/", **oauth_credentials)
48
+
49
+ # Request your ad accounts
50
+ ads_client.get("accounts")
34
51
  ```
35
52
 
36
53
  ## Development
data/lib/x/client.rb CHANGED
@@ -1,142 +1,198 @@
1
+ require "forwardable"
1
2
  require "json"
2
3
  require "net/http"
3
4
  require "oauth"
5
+ require "uri"
6
+ require_relative "version"
4
7
 
5
8
  module X
9
+ JSON_CONTENT_TYPE = "application/json; charset=utf-8".freeze
10
+
11
+ # Base error class
12
+ class Error < ::StandardError
13
+ attr_reader :object
14
+
15
+ def initialize(response = nil)
16
+ if response.is_a?(Net::HTTPResponse) && response.body && response["content-type"] == JSON_CONTENT_TYPE
17
+ @object = JSON.parse(response.body)
18
+ end
19
+ super
20
+ end
21
+ end
22
+
23
+ class NetworkError < Error; end
24
+ class ClientError < Error; end
25
+ class AuthenticationError < ClientError; end
26
+ class BadRequestError < ClientError; end
27
+ class ForbiddenError < ClientError; end
28
+ class NotFoundError < ClientError; end
29
+
30
+ # Rate limit error
31
+ class TooManyRequestsError < ClientError
32
+ def initialize(response = nil)
33
+ @response = response
34
+ super
35
+ end
36
+
37
+ def limit
38
+ @response && @response["x-rate-limit-limit"]&.to_i
39
+ end
40
+
41
+ def remaining
42
+ @response && @response["x-rate-limit-remaining"]&.to_i
43
+ end
44
+
45
+ def reset_at
46
+ reset = @response && @response["x-rate-limit-reset"]&.to_i
47
+ Time.at(reset.to_i).utc if reset
48
+ end
49
+
50
+ def reset_in
51
+ [(reset_at - Time.now).ceil, 0].max if reset_at
52
+ end
53
+ alias retry_after reset_in
54
+ end
55
+
56
+ class ServerError < Error; end
57
+ class ServiceUnavailableError < ServerError; end
58
+
6
59
  # HTTP client that handles authentication and requests
7
60
  class Client
61
+ extend Forwardable
62
+
63
+ attr_accessor :bearer_token, :user_agent, :read_timeout
64
+ attr_reader :base_url
65
+
66
+ def_delegator :@access_token, :secret, :access_token_secret
67
+ def_delegator :@access_token, :secret=, :access_token_secret=
68
+ def_delegator :@access_token, :token, :access_token
69
+ def_delegator :@access_token, :token=, :access_token=
70
+ def_delegator :@consumer, :key, :api_key
71
+ def_delegator :@consumer, :key=, :api_key=
72
+ def_delegator :@consumer, :secret, :api_key_secret
73
+ def_delegator :@consumer, :secret=, :api_key_secret=
74
+
8
75
  DEFAULT_BASE_URL = "https://api.twitter.com/2/".freeze
9
- DEFAULT_USER_AGENT = "X-Client/#{VERSION} Ruby/#{RUBY_VERSION}".freeze
76
+ DEFAULT_USER_AGENT = "X-Client/#{X::Version} Ruby/#{RUBY_VERSION}".freeze
77
+ DEFAULT_READ_TIMEOUT = 60 # seconds
78
+ HTTP_METHODS = {
79
+ get: Net::HTTP::Get,
80
+ post: Net::HTTP::Post,
81
+ put: Net::HTTP::Put,
82
+ delete: Net::HTTP::Delete
83
+ }.freeze
10
84
 
11
85
  def initialize(bearer_token: nil, api_key: nil, api_key_secret: nil, access_token: nil, access_token_secret: nil,
12
- base_url: DEFAULT_BASE_URL, user_agent: DEFAULT_USER_AGENT)
13
- @http_request = HttpRequest.new(bearer_token: bearer_token,
14
- api_key: api_key,
15
- api_key_secret: api_key_secret,
16
- access_token: access_token,
17
- access_token_secret: access_token_secret,
18
- base_url: base_url,
19
- user_agent: user_agent)
86
+ base_url: DEFAULT_BASE_URL, user_agent: DEFAULT_USER_AGENT, read_timeout: DEFAULT_READ_TIMEOUT)
87
+ @base_url = URI(base_url)
88
+ @user_agent = user_agent
89
+ @read_timeout = read_timeout
90
+
91
+ validate_base_url!
92
+
93
+ if bearer_token.nil?
94
+ initialize_oauth(api_key, api_key_secret, access_token, access_token_secret)
95
+ else
96
+ @bearer_token = bearer_token
97
+ end
20
98
  end
21
99
 
22
100
  def get(endpoint)
23
- handle_response { @http_request.get(endpoint) }
101
+ send_request(:get, endpoint)
24
102
  end
25
103
 
26
104
  def post(endpoint, body = nil)
27
- handle_response { @http_request.post(endpoint, body) }
105
+ send_request(:post, endpoint, body)
28
106
  end
29
107
 
30
108
  def put(endpoint, body = nil)
31
- handle_response { @http_request.put(endpoint, body) }
109
+ send_request(:put, endpoint, body)
32
110
  end
33
111
 
34
112
  def delete(endpoint)
35
- handle_response { @http_request.delete(endpoint) }
113
+ send_request(:delete, endpoint)
36
114
  end
37
115
 
38
- private
39
-
40
- def handle_response
41
- response = yield
42
- ErrorHandler.new(response).handle
116
+ def base_url=(base_url)
117
+ @base_url = URI(base_url)
118
+ validate_base_url!
43
119
  end
44
120
 
45
- # HTTP client requester
46
- class HttpRequest
47
- HTTP_METHODS = {
48
- get: Net::HTTP::Get,
49
- post: Net::HTTP::Post,
50
- put: Net::HTTP::Put,
51
- delete: Net::HTTP::Delete
52
- }.freeze
53
-
54
- def initialize(bearer_token: nil, api_key: nil, api_key_secret: nil, access_token: nil, access_token_secret: nil,
55
- base_url: nil, user_agent: nil)
56
- @base_url = base_url
57
- @use_bearer_token = !bearer_token.nil?
58
- @user_agent = user_agent || Client::DEFAULT_USER_AGENT
121
+ private
59
122
 
60
- if @use_bearer_token
61
- @bearer_token = bearer_token
62
- else
63
- initialize_oauth(api_key, api_key_secret, access_token, access_token_secret)
64
- end
123
+ def initialize_oauth(api_key, api_key_secret, access_token, access_token_secret)
124
+ unless api_key && api_key_secret && access_token && access_token_secret
125
+ raise ArgumentError, "Missing OAuth credentials"
65
126
  end
66
127
 
67
- def get(endpoint)
68
- send_request(:get, endpoint)
69
- end
128
+ @consumer = OAuth::Consumer.new(api_key, api_key_secret, site: @base_url)
129
+ @access_token = OAuth::Token.new(access_token, access_token_secret)
130
+ end
70
131
 
71
- def post(endpoint, body = nil)
72
- send_request(:post, endpoint, body)
73
- end
132
+ def send_request(http_method, endpoint, body = nil)
133
+ url = URI.join(@base_url, endpoint)
134
+ http = Net::HTTP.new(url.host, url.port)
135
+ http.use_ssl = url.scheme == "https"
136
+ http.read_timeout = @read_timeout
74
137
 
75
- def put(endpoint, body = nil)
76
- send_request(:put, endpoint, body)
77
- end
138
+ request = create_request(http_method, url, body)
139
+ add_headers(request)
78
140
 
79
- def delete(endpoint)
80
- send_request(:delete, endpoint)
81
- end
141
+ handle_response(http.request(request))
142
+ rescue Errno::ECONNREFUSED, Net::OpenTimeout, Net::ReadTimeout => e
143
+ raise X::NetworkError, "Network error: #{e.message}"
144
+ end
82
145
 
83
- private
146
+ def create_request(http_method, url, body)
147
+ http_method_class = HTTP_METHODS[http_method]
84
148
 
85
- def initialize_oauth(api_key, api_key_secret, access_token, access_token_secret)
86
- unless api_key && api_key_secret && access_token && access_token_secret
87
- raise ArgumentError, "Missing OAuth credentials."
88
- end
89
-
90
- @consumer = OAuth::Consumer.new(api_key, api_key_secret, site: @base_url)
91
- @access_token = OAuth::Token.new(access_token, access_token_secret)
92
- end
149
+ raise ArgumentError, "Unsupported HTTP method: #{http_method}" unless http_method_class
93
150
 
94
- def send_request(http_method, endpoint, body = nil)
95
- url = URI.parse(@base_url + endpoint)
96
- http = Net::HTTP.new(url.host, url.port)
97
- http.use_ssl = true
151
+ request = http_method_class.new(url)
152
+ request.body = body if body && http_method != :get
153
+ request
154
+ end
98
155
 
99
- request = create_request(http_method, url, body)
100
- add_authorization(request)
101
- add_user_agent(request)
156
+ def add_headers(request)
157
+ add_authorization(request)
158
+ add_content_type(request)
159
+ add_user_agent(request)
160
+ end
102
161
 
103
- http.request(request)
162
+ def add_authorization(request)
163
+ if @bearer_token.nil?
164
+ @consumer.sign!(request, @access_token)
165
+ else
166
+ request["Authorization"] = "Bearer #{@bearer_token}"
104
167
  end
168
+ end
105
169
 
106
- def create_request(http_method, url, body)
107
- http_method_class = HTTP_METHODS[http_method]
108
-
109
- raise ArgumentError, "Unsupported HTTP method: #{http_method}" unless http_method_class
170
+ def add_content_type(request)
171
+ request["Content-Type"] = JSON_CONTENT_TYPE
172
+ end
110
173
 
111
- request = http_method_class.new(url)
112
- request.body = body if body && http_method != :get
113
- request
114
- end
174
+ def add_user_agent(request)
175
+ request["User-Agent"] = @user_agent if @user_agent
176
+ end
115
177
 
116
- def add_authorization(request)
117
- if @use_bearer_token
118
- request["Authorization"] = "Bearer #{@bearer_token}"
119
- else
120
- @consumer.sign!(request, @access_token)
121
- end
122
- end
178
+ def validate_base_url!
179
+ raise ArgumentError, "Invalid base URL" unless @base_url.is_a?(URI::HTTPS) || @base_url.is_a?(URI::HTTP)
180
+ end
123
181
 
124
- def add_user_agent(request)
125
- request["User-Agent"] = @user_agent if @user_agent
126
- end
182
+ def handle_response(response)
183
+ ResponseHandler.new(response).handle
127
184
  end
128
185
 
129
- # HTTP client error handler
130
- class ErrorHandler
131
- HTTP_STATUS_HANDLERS = {
132
- Net::HTTPOK => :handle_success_response,
133
- Net::HTTPBadRequest => :handle_bad_request_response,
134
- Net::HTTPForbidden => :handle_forbidden_response,
135
- Net::HTTPUnauthorized => :handle_unauthorized_response,
136
- Net::HTTPNotFound => :handle_not_found_response,
137
- Net::HTTPTooManyRequests => :handle_too_many_requests_response,
138
- Net::HTTPInternalServerError => :handle_server_error_response,
139
- Net::HTTPServiceUnavailable => :handle_service_unavailable_response
186
+ # HTTP client response handler
187
+ class ResponseHandler
188
+ ERROR_CLASSES = {
189
+ Net::HTTPBadRequest => X::BadRequestError,
190
+ Net::HTTPUnauthorized => X::AuthenticationError,
191
+ Net::HTTPForbidden => X::ForbiddenError,
192
+ Net::HTTPNotFound => X::NotFoundError,
193
+ Net::HTTPTooManyRequests => X::TooManyRequestsError,
194
+ Net::HTTPInternalServerError => X::ServerError,
195
+ Net::HTTPServiceUnavailable => X::ServiceUnavailableError
140
196
  }.freeze
141
197
 
142
198
  def initialize(response)
@@ -144,50 +200,15 @@ module X
144
200
  end
145
201
 
146
202
  def handle
147
- handler_method = HTTP_STATUS_HANDLERS[@response.class]
148
- if handler_method
149
- send(handler_method)
150
- else
151
- handle_unexpected_response
203
+ if @response.is_a?(Net::HTTPSuccess) && @response["content-type"] == JSON_CONTENT_TYPE
204
+ return JSON.parse(@response.body)
152
205
  end
153
- end
154
206
 
155
- private
156
-
157
- def handle_success_response
158
- JSON.parse(@response.body)
159
- end
160
-
161
- def handle_bad_request_response
162
- raise X::BadRequestError, "Bad request: #{@response.code} #{@response.message}"
163
- end
164
-
165
- def handle_forbidden_response
166
- raise X::ForbiddenError, "Forbidden: #{@response.code} #{@response.message}"
167
- end
168
-
169
- def handle_unauthorized_response
170
- raise X::AuthenticationError, "Authentication failed. Please check your credentials."
171
- end
172
-
173
- def handle_not_found_response
174
- raise X::NotFoundError, "Not found: #{@response.code} #{@response.message}"
175
- end
176
-
177
- def handle_too_many_requests_response
178
- raise X::TooManyRequestsError, "Too many requests: #{@response.code} #{@response.message}"
179
- end
180
-
181
- def handle_server_error_response
182
- raise X::ServerError, "Internal server error: #{@response.code} #{@response.message}"
183
- end
184
-
185
- def handle_service_unavailable_response
186
- raise X::ServiceUnavailableError, "Service unavailable: #{@response.code} #{@response.message}"
187
- end
207
+ error_class = ERROR_CLASSES[@response.class] || X::Error
208
+ error_message = "#{@response.code} #{@response.message}"
209
+ raise error_class, error_message if @response.body.nil? || @response.body.empty?
188
210
 
189
- def handle_unexpected_response
190
- raise X::Error, "Unexpected response: #{@response.code}"
211
+ raise error_class.new(@response), error_message
191
212
  end
192
213
  end
193
214
  end
data/lib/x/version.rb CHANGED
@@ -1,3 +1,39 @@
1
1
  module X
2
- VERSION = "0.2.0".freeze
2
+ # The version of this library
3
+ module Version
4
+ module_function
5
+
6
+ def major
7
+ 0
8
+ end
9
+
10
+ def minor
11
+ 3
12
+ end
13
+
14
+ def patch
15
+ 0
16
+ end
17
+
18
+ def pre
19
+ nil
20
+ end
21
+
22
+ def to_h
23
+ {
24
+ major: major,
25
+ minor: minor,
26
+ patch: patch,
27
+ pre: pre
28
+ }
29
+ end
30
+
31
+ def to_a
32
+ [major, minor, patch, pre].compact
33
+ end
34
+
35
+ def to_s
36
+ to_a.join(".")
37
+ end
38
+ end
3
39
  end
data/lib/x.rb CHANGED
@@ -1,3 +1 @@
1
- require_relative "x/version"
2
- require_relative "x/errors"
3
1
  require_relative "x/client"
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.2.0
4
+ version: 0.3.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-08-02 00:00:00.000000000 Z
11
+ date: 2023-08-04 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: oauth
@@ -39,7 +39,6 @@ files:
39
39
  - Rakefile
40
40
  - lib/x.rb
41
41
  - lib/x/client.rb
42
- - lib/x/errors.rb
43
42
  - lib/x/version.rb
44
43
  - sig/x.rbs
45
44
  homepage: https://github.com/sferik/x-ruby
data/lib/x/errors.rb DELETED
@@ -1,11 +0,0 @@
1
- module X
2
- class Error < ::StandardError; end
3
- class ClientError < Error; end
4
- class AuthenticationError < ClientError; end
5
- class BadRequestError < ClientError; end
6
- class ForbiddenError < ClientError; end
7
- class NotFoundError < ClientError; end
8
- class TooManyRequestsError < ClientError; end
9
- class ServerError < Error; end
10
- class ServiceUnavailableError < ServerError; end
11
- end