x 0.1.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: 480eb23ebeb098bcc5c2d4924279d2d4608f8cf2a8e5eb7897edf45cc0311bc4
4
- data.tar.gz: c97f4694a91b3d3ad9f60d55001d48080329254d28db75aff74d646264fe38f2
3
+ metadata.gz: 56ad55c544f1c7774395d906e0d9d07b6dc2a6d20e37d7df0b3e5b9f7b7b2bb6
4
+ data.tar.gz: 1747e256413ee2aeae02d8ed0e351b6d411217b00e634a42aeec5299764d6fac
5
5
  SHA512:
6
- metadata.gz: 86adf1889c2e820353c4455a08c443480682d4827addd074a7e1fe3e60937e70b638118d31802c8cc57d457865c917d2f1a83d739448659519f42c9cbede74ed
7
- data.tar.gz: 714dcea3deff757a2d15548ed1fdbb17749ca5b901e9463a5b3a223120b05974ab792d1c3230406dc07984358a6d9e7c4d781402aa025fa5e257d967ee5ecbcf
6
+ metadata.gz: c5b386cd7386457b148c555359e68c4f9cb72b598ff9eb0a75405f3af75599dca0d5fc1700b01653465b2e50e401b69e9f9e5e9dd126d6a56c46b6df96b5736c
7
+ data.tar.gz: 462957aeee740d46f4a83497e69bec852fa0a4d8a9e38aec6bf8ffcd8f432e862737fa34847995dce5a49c09a40d9a08607ace68a1314a32972292d881cbd1db
data/.rubocop.yml CHANGED
@@ -15,8 +15,8 @@ Style/StringLiteralsInInterpolation:
15
15
  Enabled: true
16
16
  EnforcedStyle: double_quotes
17
17
 
18
- Layout/LineLength:
19
- Max: 120
20
-
21
18
  Style/FrozenStringLiteralComment:
22
19
  Enabled: false
20
+
21
+ Metrics/ParameterLists:
22
+ CountKeywordArgs: false
data/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
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
+
8
+ ## [0.2.0] - 2023-08-02
9
+
10
+ - Allow configuration of base URL (4bc0531)
11
+ - Improve error handling (14dc0cd)
12
+
3
13
  ## [0.1.0] - 2023-08-02
4
14
 
5
15
  - Initial release
data/Gemfile CHANGED
@@ -9,4 +9,5 @@ gem "rubocop", ">= 1.21"
9
9
  gem "rubocop-minitest", ">= 0.31"
10
10
  gem "rubocop-performance", ">= 1.18"
11
11
  gem "rubocop-rake", ">= 0.6"
12
+ gem "simplecov", ">= 0.22"
12
13
  gem "webmock", ">= 3.18.1"
data/README.md CHANGED
@@ -15,19 +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
- api_key = "YOUR_X_API_KEY"
19
- api_key_secret = "YOUR_X_API_KEY_SECRET"
20
- access_token = "YOUR_X_ACCESS_TOKEN"
21
- access_token_secret = "YOUR_X_ACCESS_TOKEN_SECRET"
22
-
23
- x_client = X::Client.new(api_key: api_key, api_key_secret: api_key_secret, access_token: access_token, access_token_secret: access_token_secret)
24
-
25
- begin
26
- response = x_client.get("users/me")
27
- puts JSON.pretty_generate(response)
28
- rescue StandardError => e
29
- puts "Error: #{e.message}"
30
- 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")
31
51
  ```
32
52
 
33
53
  ## Development
data/lib/x/client.rb CHANGED
@@ -1,11 +1,80 @@
1
- require "oauth"
1
+ require "forwardable"
2
2
  require "json"
3
3
  require "net/http"
4
+ require "oauth"
5
+ require "uri"
6
+ require_relative "version"
4
7
 
5
8
  module X
6
- # Main client that handles HTTP authentication and requests
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
+
59
+ # HTTP client that handles authentication and requests
7
60
  class Client
8
- BASE_URL = "https://api.twitter.com/2/".freeze
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
+
75
+ DEFAULT_BASE_URL = "https://api.twitter.com/2/".freeze
76
+ DEFAULT_USER_AGENT = "X-Client/#{X::Version} Ruby/#{RUBY_VERSION}".freeze
77
+ DEFAULT_READ_TIMEOUT = 60 # seconds
9
78
  HTTP_METHODS = {
10
79
  get: Net::HTTP::Get,
11
80
  post: Net::HTTP::Post,
@@ -13,52 +82,65 @@ module X
13
82
  delete: Net::HTTP::Delete
14
83
  }.freeze
15
84
 
16
- def initialize(bearer_token: nil, api_key: nil, api_key_secret: nil, access_token: nil, access_token_secret: nil)
17
- @use_bearer_token = !bearer_token.nil?
85
+ def initialize(bearer_token: nil, api_key: nil, api_key_secret: nil, access_token: nil, access_token_secret: nil,
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
18
90
 
19
- if @use_bearer_token
20
- @bearer_token = bearer_token
21
- else
22
- unless api_key && api_key_secret && access_token && access_token_secret
23
- raise ArgumentError, "Missing OAuth credentials."
24
- end
91
+ validate_base_url!
25
92
 
26
- @consumer = OAuth::Consumer.new(api_key, api_key_secret, site: BASE_URL)
27
- @access_token = OAuth::Token.new(access_token, access_token_secret)
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
28
97
  end
29
98
  end
30
99
 
31
100
  def get(endpoint)
32
- response = send_request(:get, endpoint)
33
- handle_response(response)
101
+ send_request(:get, endpoint)
34
102
  end
35
103
 
36
104
  def post(endpoint, body = nil)
37
- response = send_request(:post, endpoint, body)
38
- handle_response(response)
105
+ send_request(:post, endpoint, body)
39
106
  end
40
107
 
41
108
  def put(endpoint, body = nil)
42
- response = send_request(:put, endpoint, body)
43
- handle_response(response)
109
+ send_request(:put, endpoint, body)
44
110
  end
45
111
 
46
112
  def delete(endpoint)
47
- response = send_request(:delete, endpoint)
48
- handle_response(response)
113
+ send_request(:delete, endpoint)
114
+ end
115
+
116
+ def base_url=(base_url)
117
+ @base_url = URI(base_url)
118
+ validate_base_url!
49
119
  end
50
120
 
51
121
  private
52
122
 
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"
126
+ end
127
+
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
131
+
53
132
  def send_request(http_method, endpoint, body = nil)
54
- url = URI.parse(BASE_URL + endpoint)
133
+ url = URI.join(@base_url, endpoint)
55
134
  http = Net::HTTP.new(url.host, url.port)
56
- http.use_ssl = true
135
+ http.use_ssl = url.scheme == "https"
136
+ http.read_timeout = @read_timeout
57
137
 
58
138
  request = create_request(http_method, url, body)
59
- add_authorization(request)
139
+ add_headers(request)
60
140
 
61
- http.request(request)
141
+ handle_response(http.request(request))
142
+ rescue Errno::ECONNREFUSED, Net::OpenTimeout, Net::ReadTimeout => e
143
+ raise X::NetworkError, "Network error: #{e.message}"
62
144
  end
63
145
 
64
146
  def create_request(http_method, url, body)
@@ -71,24 +153,62 @@ module X
71
153
  request
72
154
  end
73
155
 
156
+ def add_headers(request)
157
+ add_authorization(request)
158
+ add_content_type(request)
159
+ add_user_agent(request)
160
+ end
161
+
74
162
  def add_authorization(request)
75
- if @use_bearer_token
76
- request["Authorization"] = "Bearer #{@bearer_token}"
77
- else
163
+ if @bearer_token.nil?
78
164
  @consumer.sign!(request, @access_token)
165
+ else
166
+ request["Authorization"] = "Bearer #{@bearer_token}"
79
167
  end
80
168
  end
81
169
 
170
+ def add_content_type(request)
171
+ request["Content-Type"] = JSON_CONTENT_TYPE
172
+ end
173
+
174
+ def add_user_agent(request)
175
+ request["User-Agent"] = @user_agent if @user_agent
176
+ end
177
+
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
181
+
82
182
  def handle_response(response)
83
- case response
84
- when Net::HTTPSuccess
85
- JSON.parse(response.body)
86
- when Net::HTTPUnauthorized
87
- raise X::AuthenticationError, "Authentication failed. Please check your credentials."
88
- when Net::HTTPServerError
89
- raise X::ServerError, "An internal server error occurred."
90
- else
91
- raise X::Error, "Unexpected response: #{response.code} #{response.message}"
183
+ ResponseHandler.new(response).handle
184
+ end
185
+
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
196
+ }.freeze
197
+
198
+ def initialize(response)
199
+ @response = response
200
+ end
201
+
202
+ def handle
203
+ if @response.is_a?(Net::HTTPSuccess) && @response["content-type"] == JSON_CONTENT_TYPE
204
+ return JSON.parse(@response.body)
205
+ end
206
+
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?
210
+
211
+ raise error_class.new(@response), error_message
92
212
  end
93
213
  end
94
214
  end
data/lib/x/version.rb CHANGED
@@ -1,3 +1,39 @@
1
1
  module X
2
- VERSION = "0.1.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
1
  require_relative "x/client"
3
- require_relative "x/error"
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.1.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/error.rb
43
42
  - lib/x/version.rb
44
43
  - sig/x.rbs
45
44
  homepage: https://github.com/sferik/x-ruby
data/lib/x/error.rb DELETED
@@ -1,5 +0,0 @@
1
- module X
2
- class Error < StandardError; end
3
- class AuthenticationError < Error; end
4
- class ServerError < Error; end
5
- end