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 +4 -4
- data/CHANGELOG.md +5 -0
- data/README.md +33 -16
- data/lib/x/client.rb +157 -136
- data/lib/x/version.rb +37 -1
- data/lib/x.rb +0 -2
- metadata +2 -3
- data/lib/x/errors.rb +0 -11
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 56ad55c544f1c7774395d906e0d9d07b6dc2a6d20e37d7df0b3e5b9f7b7b2bb6
|
4
|
+
data.tar.gz: 1747e256413ee2aeae02d8ed0e351b6d411217b00e634a42aeec5299764d6fac
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
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/#{
|
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
|
-
@
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
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
|
-
|
101
|
+
send_request(:get, endpoint)
|
24
102
|
end
|
25
103
|
|
26
104
|
def post(endpoint, body = nil)
|
27
|
-
|
105
|
+
send_request(:post, endpoint, body)
|
28
106
|
end
|
29
107
|
|
30
108
|
def put(endpoint, body = nil)
|
31
|
-
|
109
|
+
send_request(:put, endpoint, body)
|
32
110
|
end
|
33
111
|
|
34
112
|
def delete(endpoint)
|
35
|
-
|
113
|
+
send_request(:delete, endpoint)
|
36
114
|
end
|
37
115
|
|
38
|
-
|
39
|
-
|
40
|
-
|
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
|
-
|
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
|
-
|
61
|
-
|
62
|
-
|
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
|
-
|
68
|
-
|
69
|
-
|
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
|
-
|
72
|
-
|
73
|
-
|
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
|
-
|
76
|
-
|
77
|
-
end
|
138
|
+
request = create_request(http_method, url, body)
|
139
|
+
add_headers(request)
|
78
140
|
|
79
|
-
|
80
|
-
|
81
|
-
|
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
|
-
|
146
|
+
def create_request(http_method, url, body)
|
147
|
+
http_method_class = HTTP_METHODS[http_method]
|
84
148
|
|
85
|
-
|
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
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
151
|
+
request = http_method_class.new(url)
|
152
|
+
request.body = body if body && http_method != :get
|
153
|
+
request
|
154
|
+
end
|
98
155
|
|
99
|
-
|
100
|
-
|
101
|
-
|
156
|
+
def add_headers(request)
|
157
|
+
add_authorization(request)
|
158
|
+
add_content_type(request)
|
159
|
+
add_user_agent(request)
|
160
|
+
end
|
102
161
|
|
103
|
-
|
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
|
-
|
107
|
-
|
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
|
-
|
112
|
-
|
113
|
-
|
114
|
-
end
|
174
|
+
def add_user_agent(request)
|
175
|
+
request["User-Agent"] = @user_agent if @user_agent
|
176
|
+
end
|
115
177
|
|
116
|
-
|
117
|
-
|
118
|
-
|
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
|
-
|
125
|
-
|
126
|
-
end
|
182
|
+
def handle_response(response)
|
183
|
+
ResponseHandler.new(response).handle
|
127
184
|
end
|
128
185
|
|
129
|
-
# HTTP client
|
130
|
-
class
|
131
|
-
|
132
|
-
Net::
|
133
|
-
Net::
|
134
|
-
Net::HTTPForbidden =>
|
135
|
-
Net::
|
136
|
-
Net::
|
137
|
-
Net::
|
138
|
-
Net::
|
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
|
-
|
148
|
-
|
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
|
-
|
156
|
-
|
157
|
-
|
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
|
-
|
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
|
-
|
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
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.
|
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-
|
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
|