x 0.3.0 → 0.5.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.rubocop.yml +35 -3
- data/CHANGELOG.md +13 -0
- data/Gemfile +2 -0
- data/README.md +21 -5
- data/Rakefile +2 -1
- data/lib/x/authenticator.rb +43 -0
- data/lib/x/client.rb +28 -183
- data/lib/x/client_defaults.rb +14 -0
- data/lib/x/connection.rb +40 -0
- data/lib/x/errors/authentication_error.rb +5 -0
- data/lib/x/errors/bad_request_error.rb +5 -0
- data/lib/x/errors/client_error.rb +5 -0
- data/lib/x/errors/error.rb +24 -0
- data/lib/x/errors/errors.rb +28 -0
- data/lib/x/errors/forbidden_error.rb +5 -0
- data/lib/x/errors/network_error.rb +5 -0
- data/lib/x/errors/not_found_error.rb +5 -0
- data/lib/x/errors/server_error.rb +5 -0
- data/lib/x/errors/service_unavailable_error.rb +5 -0
- data/lib/x/errors/too_many_requests_error.rb +32 -0
- data/lib/x/request_builder.rb +58 -0
- data/lib/x/response_handler.rb +40 -0
- data/lib/x/version.rb +3 -37
- metadata +20 -4
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: ff49ecaad847aef0d9da4bdfc4f89cc3bc54da5edbadab40be265e9ec2ec1cf6
|
4
|
+
data.tar.gz: 1ddef9c1479654f53ed7a889cb33b3d1e4d972c005c4f631726bb4270721c2b0
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 406e9aaa6f6166f9d267718ac22e94324b243c85475b30cb7569db957683014644542a3b44a6a3d337f0658a1204e8656b1b0c4ed2355b12ffc195c34b0734e4
|
7
|
+
data.tar.gz: b0754554e6272ef9b9ede3f650c3c407b71ae6ed8ede70d6da314ca408874898e83c2d6c14b31335817b0fb036422adde8ccefe3c13c274794c2e08229196812
|
data/.rubocop.yml
CHANGED
@@ -1,4 +1,6 @@
|
|
1
1
|
require:
|
2
|
+
- standard
|
3
|
+
- standard-performance
|
2
4
|
- rubocop-minitest
|
3
5
|
- rubocop-performance
|
4
6
|
- rubocop-rake
|
@@ -7,6 +9,39 @@ AllCops:
|
|
7
9
|
NewCops: enable
|
8
10
|
TargetRubyVersion: 3.0
|
9
11
|
|
12
|
+
Layout/ArgumentAlignment:
|
13
|
+
Enabled: true
|
14
|
+
EnforcedStyle: with_fixed_indentation
|
15
|
+
|
16
|
+
Layout/ArrayAlignment:
|
17
|
+
Enabled: true
|
18
|
+
EnforcedStyle: with_fixed_indentation
|
19
|
+
|
20
|
+
Layout/EndAlignment:
|
21
|
+
Enabled: true
|
22
|
+
EnforcedStyleAlignWith: variable
|
23
|
+
|
24
|
+
Layout/HashAlignment:
|
25
|
+
Enabled: true
|
26
|
+
EnforcedHashRocketStyle: key
|
27
|
+
EnforcedColonStyle: key
|
28
|
+
EnforcedLastArgumentHashStyle: always_inspect
|
29
|
+
|
30
|
+
Layout/ParameterAlignment:
|
31
|
+
Enabled: true
|
32
|
+
EnforcedStyle: with_fixed_indentation
|
33
|
+
IndentationWidth: ~
|
34
|
+
|
35
|
+
Layout/SpaceInsideHashLiteralBraces:
|
36
|
+
Enabled: false
|
37
|
+
|
38
|
+
Metrics/ParameterLists:
|
39
|
+
CountKeywordArgs: false
|
40
|
+
|
41
|
+
Style/Alias:
|
42
|
+
Enabled: true
|
43
|
+
EnforcedStyle: prefer_alias_method
|
44
|
+
|
10
45
|
Style/StringLiterals:
|
11
46
|
Enabled: true
|
12
47
|
EnforcedStyle: double_quotes
|
@@ -17,6 +52,3 @@ Style/StringLiteralsInInterpolation:
|
|
17
52
|
|
18
53
|
Style/FrozenStringLiteralComment:
|
19
54
|
Enabled: false
|
20
|
-
|
21
|
-
Metrics/ParameterLists:
|
22
|
-
CountKeywordArgs: false
|
data/CHANGELOG.md
CHANGED
@@ -1,5 +1,18 @@
|
|
1
1
|
## [Unreleased]
|
2
2
|
|
3
|
+
## [0.5.0] - 2023-08-10
|
4
|
+
|
5
|
+
- Add configurable write timeout (2a31f84)
|
6
|
+
- Use built-in Gem::Version class (066e0b6)
|
7
|
+
|
8
|
+
## [0.4.0] - 2023-08-06
|
9
|
+
|
10
|
+
- Refactor Client into Authenticator, RequestBuilder, Connection, ResponseHandler (6bee1e9)
|
11
|
+
- Add configurable open timeout (1000f9d)
|
12
|
+
- Allow configuration of content type (f33a732)
|
13
|
+
|
14
|
+
## [0.3.0] - 2023-08-04
|
15
|
+
|
3
16
|
- Add accessors to X::Client (e61fa73)
|
4
17
|
- Add configurable read timeout (41502b9)
|
5
18
|
- Handle network-related errors (9ed1fb4)
|
data/Gemfile
CHANGED
@@ -3,6 +3,7 @@ source "https://rubygems.org"
|
|
3
3
|
# Specify your gem's dependencies in x.gemspec
|
4
4
|
gemspec
|
5
5
|
|
6
|
+
gem "hashie", ">= 5"
|
6
7
|
gem "minitest", ">= 5.19"
|
7
8
|
gem "rake", ">= 13.0.6"
|
8
9
|
gem "rubocop", ">= 1.21"
|
@@ -10,4 +11,5 @@ gem "rubocop-minitest", ">= 0.31"
|
|
10
11
|
gem "rubocop-performance", ">= 1.18"
|
11
12
|
gem "rubocop-rake", ">= 0.6"
|
12
13
|
gem "simplecov", ">= 0.22"
|
14
|
+
gem "standard", ">= 1.30.1"
|
13
15
|
gem "webmock", ">= 3.18.1"
|
data/README.md
CHANGED
@@ -1,6 +1,6 @@
|
|
1
1
|
# X
|
2
2
|
|
3
|
-
A Ruby interface to the X
|
3
|
+
A Ruby interface to the X API.
|
4
4
|
|
5
5
|
## Installation
|
6
6
|
|
@@ -15,7 +15,7 @@ If bundler is not being used to manage dependencies, install the gem by executin
|
|
15
15
|
## Usage
|
16
16
|
|
17
17
|
```ruby
|
18
|
-
|
18
|
+
x_oauth_credentials = {
|
19
19
|
api_key: "INSERT YOUR X API KEY HERE",
|
20
20
|
api_key_secret: "INSERT YOUR X API KEY SECRET HERE",
|
21
21
|
access_token: "INSERT YOUR X API ACCESS TOKEN HERE",
|
@@ -23,7 +23,7 @@ oauth_credentials = {
|
|
23
23
|
}
|
24
24
|
|
25
25
|
# Initialize X API client with OAuth credentials
|
26
|
-
x_client = X::Client.new(**
|
26
|
+
x_client = X::Client.new(**x_oauth_credentials)
|
27
27
|
|
28
28
|
# Request yourself
|
29
29
|
x_client.get("users/me")
|
@@ -38,18 +38,34 @@ x_client.delete("tweets/#{tweet["data"]["id"]}")
|
|
38
38
|
# {"data"=>{"deleted"=>true}}
|
39
39
|
|
40
40
|
# Initialize an API v1.1 client
|
41
|
-
v1_client = X::Client.new(base_url: "https://api.twitter.com/1.1/", **
|
41
|
+
v1_client = X::Client.new(base_url: "https://api.twitter.com/1.1/", **x_oauth_credentials)
|
42
42
|
|
43
43
|
# Request your account settings
|
44
44
|
v1_client.get("account/settings.json")
|
45
45
|
|
46
46
|
# Initialize an X Ads API client
|
47
|
-
ads_client = X::Client.new(base_url: "https://ads-api.twitter.com/12/", **
|
47
|
+
ads_client = X::Client.new(base_url: "https://ads-api.twitter.com/12/", **x_oauth_credentials)
|
48
48
|
|
49
49
|
# Request your ad accounts
|
50
50
|
ads_client.get("accounts")
|
51
51
|
```
|
52
52
|
|
53
|
+
## History and Philosophy
|
54
|
+
|
55
|
+
This library is a rewrite of the [Twitter Ruby library](https://github.com/sferik/twitter). Over 16 years, that library ballooned to over 3,000 lines of code (plus 7,500 lines of tests). At the time of writing, this library is about 300 lines of code (plus 200 test lines) and I’d like to keep it that way. That doesn’t mean new features won’t be added over time, but the benefits of potential new features must be weighed against the benefits of simplicity:
|
56
|
+
|
57
|
+
* Less code is easier to maintain.
|
58
|
+
* Less code means fewer bugs.
|
59
|
+
* Less code runs faster.
|
60
|
+
|
61
|
+
In the immortal words of [Ezra Zygmuntowicz](https://github.com/ezmobius) and his [Merb](https://github.com/merb) project (may they both rest in peace): “No code is faster than no code.” The fastest code is the code that is never executed because it doesn’t exist. That principle should apply not just to this library itself but to third-party dependencies. At present, this library has one dependency ([oauth](https://rubygems.org/gems/oauth)) and I’d like to keep it that way. If anything, it should have fewer.
|
62
|
+
|
63
|
+
The tests for the previous version of this library ran in about 2 seconds. That sounds pretty fast until you see that tests for this library run in 2 hundredths of a second. This means you can automatically run the tests any time you write a file and receive immediate feedback. For such of workflows, 2 seconds feels painfully slow. At the same time, we aim to maintain 100% C0 code coverage.
|
64
|
+
|
65
|
+
This code is not littered with comments that are intended to generate documentation. Rather, this code is intended to be simple enough to serve as its own documentation. If you want to understand how something works, don’t read the documentation—it might be wrong—just read the code. The code is always right.
|
66
|
+
|
67
|
+
This project conforms to [Standard Ruby](https://github.com/standardrb/standard). Patches that don’t maintain that standard will not be accepted.
|
68
|
+
|
53
69
|
## Development
|
54
70
|
|
55
71
|
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
data/Rakefile
CHANGED
@@ -0,0 +1,43 @@
|
|
1
|
+
require "oauth"
|
2
|
+
require "forwardable"
|
3
|
+
|
4
|
+
module X
|
5
|
+
# Handles OAuth and bearer token authentication
|
6
|
+
class Authenticator
|
7
|
+
extend Forwardable
|
8
|
+
|
9
|
+
attr_accessor :bearer_token
|
10
|
+
|
11
|
+
def_delegator :@access_token, :secret, :access_token_secret
|
12
|
+
def_delegator :@access_token, :secret=, :access_token_secret=
|
13
|
+
def_delegator :@access_token, :token, :access_token
|
14
|
+
def_delegator :@access_token, :token=, :access_token=
|
15
|
+
def_delegator :@consumer, :key, :api_key
|
16
|
+
def_delegator :@consumer, :key=, :api_key=
|
17
|
+
def_delegator :@consumer, :secret, :api_key_secret
|
18
|
+
def_delegator :@consumer, :secret=, :api_key_secret=
|
19
|
+
|
20
|
+
def initialize(bearer_token:, api_key:, api_key_secret:, access_token:, access_token_secret:)
|
21
|
+
if bearer_token
|
22
|
+
@bearer_token = bearer_token
|
23
|
+
else
|
24
|
+
initialize_oauth(api_key, api_key_secret, access_token, access_token_secret)
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
def sign!(request)
|
29
|
+
@consumer.sign!(request, @access_token)
|
30
|
+
end
|
31
|
+
|
32
|
+
private
|
33
|
+
|
34
|
+
def initialize_oauth(api_key, api_key_secret, access_token, access_token_secret)
|
35
|
+
unless api_key && api_key_secret && access_token && access_token_secret
|
36
|
+
raise ArgumentError, "Missing OAuth credentials"
|
37
|
+
end
|
38
|
+
|
39
|
+
@consumer = OAuth::Consumer.new(api_key, api_key_secret, site: ClientDefaults::DEFAULT_BASE_URL)
|
40
|
+
@access_token = OAuth::Token.new(access_token, access_token_secret)
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
data/lib/x/client.rb
CHANGED
@@ -1,100 +1,35 @@
|
|
1
1
|
require "forwardable"
|
2
|
-
|
3
|
-
|
4
|
-
|
5
|
-
|
6
|
-
require_relative "
|
2
|
+
require_relative "authenticator"
|
3
|
+
require_relative "client_defaults"
|
4
|
+
require_relative "connection"
|
5
|
+
require_relative "request_builder"
|
6
|
+
require_relative "response_handler"
|
7
7
|
|
8
8
|
module X
|
9
|
-
|
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
|
9
|
+
# Main public interface
|
60
10
|
class Client
|
61
11
|
extend Forwardable
|
12
|
+
include ClientDefaults
|
62
13
|
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
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
|
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
|
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_url, :open_timeout, :read_timeout, :write_timeout
|
17
|
+
def_delegators :@connection, :base_url=, :open_timeout=, :read_timeout=, :write_timeout=
|
18
|
+
def_delegators :@request_builder, :content_type, :user_agent
|
19
|
+
def_delegators :@request_builder, :content_type=, :user_agent=
|
20
|
+
def_delegators :@response_handler, :array_class, :object_class
|
21
|
+
def_delegators :@response_handler, :array_class=, :object_class=
|
84
22
|
|
85
23
|
def initialize(bearer_token: nil, api_key: nil, api_key_secret: nil, access_token: nil, access_token_secret: nil,
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
@
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
else
|
96
|
-
@bearer_token = bearer_token
|
97
|
-
end
|
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
|
+
array_class: DEFAULT_ARRAY_CLASS, object_class: DEFAULT_OBJECT_CLASS)
|
27
|
+
@authenticator = Authenticator.new(bearer_token: bearer_token, api_key: api_key, api_key_secret: api_key_secret,
|
28
|
+
access_token: access_token, access_token_secret: access_token_secret)
|
29
|
+
@connection = Connection.new(base_url: base_url, open_timeout: open_timeout, read_timeout: read_timeout,
|
30
|
+
write_timeout: write_timeout)
|
31
|
+
@request_builder = RequestBuilder.new(content_type: content_type, user_agent: user_agent)
|
32
|
+
@response_handler = ResponseHandler.new(array_class: array_class, object_class: object_class)
|
98
33
|
end
|
99
34
|
|
100
35
|
def get(endpoint)
|
@@ -113,103 +48,13 @@ module X
|
|
113
48
|
send_request(:delete, endpoint)
|
114
49
|
end
|
115
50
|
|
116
|
-
def base_url=(base_url)
|
117
|
-
@base_url = URI(base_url)
|
118
|
-
validate_base_url!
|
119
|
-
end
|
120
|
-
|
121
51
|
private
|
122
52
|
|
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
|
-
|
132
53
|
def send_request(http_method, endpoint, body = nil)
|
133
|
-
|
134
|
-
|
135
|
-
|
136
|
-
|
137
|
-
|
138
|
-
request = create_request(http_method, url, body)
|
139
|
-
add_headers(request)
|
140
|
-
|
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
|
145
|
-
|
146
|
-
def create_request(http_method, url, body)
|
147
|
-
http_method_class = HTTP_METHODS[http_method]
|
148
|
-
|
149
|
-
raise ArgumentError, "Unsupported HTTP method: #{http_method}" unless http_method_class
|
150
|
-
|
151
|
-
request = http_method_class.new(url)
|
152
|
-
request.body = body if body && http_method != :get
|
153
|
-
request
|
154
|
-
end
|
155
|
-
|
156
|
-
def add_headers(request)
|
157
|
-
add_authorization(request)
|
158
|
-
add_content_type(request)
|
159
|
-
add_user_agent(request)
|
160
|
-
end
|
161
|
-
|
162
|
-
def add_authorization(request)
|
163
|
-
if @bearer_token.nil?
|
164
|
-
@consumer.sign!(request, @access_token)
|
165
|
-
else
|
166
|
-
request["Authorization"] = "Bearer #{@bearer_token}"
|
167
|
-
end
|
168
|
-
end
|
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
|
-
|
182
|
-
def handle_response(response)
|
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
|
212
|
-
end
|
54
|
+
request = @request_builder.build(authenticator: @authenticator, http_method: http_method, base_url: base_url,
|
55
|
+
endpoint: endpoint, body: body)
|
56
|
+
response = @connection.send_request(request: request)
|
57
|
+
@response_handler.handle(response: response)
|
213
58
|
end
|
214
59
|
end
|
215
60
|
end
|
@@ -0,0 +1,14 @@
|
|
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
|
data/lib/x/connection.rb
ADDED
@@ -0,0 +1,40 @@
|
|
1
|
+
require "forwardable"
|
2
|
+
require "net/http"
|
3
|
+
require "uri"
|
4
|
+
require_relative "errors/errors"
|
5
|
+
require_relative "errors/network_error"
|
6
|
+
|
7
|
+
module X
|
8
|
+
# Sends HTTP requests
|
9
|
+
class Connection
|
10
|
+
extend Forwardable
|
11
|
+
include Errors
|
12
|
+
|
13
|
+
attr_reader :base_url
|
14
|
+
|
15
|
+
def_delegators :@http_client, :open_timeout, :read_timeout, :write_timeout
|
16
|
+
def_delegators :@http_client, :open_timeout=, :read_timeout=, :write_timeout=
|
17
|
+
|
18
|
+
def initialize(base_url:, open_timeout:, read_timeout:, write_timeout:)
|
19
|
+
self.base_url = URI(base_url)
|
20
|
+
@http_client = Net::HTTP.new(@base_url.host, @base_url.port)
|
21
|
+
@http_client.use_ssl = @base_url.scheme == "https"
|
22
|
+
@http_client.open_timeout = open_timeout
|
23
|
+
@http_client.read_timeout = read_timeout
|
24
|
+
@http_client.write_timeout = write_timeout
|
25
|
+
end
|
26
|
+
|
27
|
+
def send_request(request:)
|
28
|
+
@http_client.request(request)
|
29
|
+
rescue *NETWORK_ERRORS => e
|
30
|
+
raise NetworkError, "Network error: #{e.message}"
|
31
|
+
end
|
32
|
+
|
33
|
+
def base_url=(new_base_url)
|
34
|
+
uri = URI(new_base_url)
|
35
|
+
raise ArgumentError, "Invalid base URL" unless uri.is_a?(URI::HTTPS) || uri.is_a?(URI::HTTP)
|
36
|
+
|
37
|
+
@base_url = uri
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
require "json"
|
2
|
+
require "net/http"
|
3
|
+
require_relative "../client_defaults"
|
4
|
+
|
5
|
+
module X
|
6
|
+
# Base error class
|
7
|
+
class Error < ::StandardError
|
8
|
+
include ClientDefaults
|
9
|
+
attr_reader :object
|
10
|
+
|
11
|
+
def initialize(msg = nil, response: nil, 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
|
15
|
+
super(msg)
|
16
|
+
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
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,28 @@
|
|
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
|
@@ -0,0 +1,32 @@
|
|
1
|
+
require_relative "client_error"
|
2
|
+
require_relative "../client_defaults"
|
3
|
+
|
4
|
+
module X
|
5
|
+
# Rate limit error
|
6
|
+
class TooManyRequestsError < ClientError
|
7
|
+
include ClientDefaults
|
8
|
+
|
9
|
+
def initialize(msg = nil, response: nil, array_class: DEFAULT_ARRAY_CLASS, object_class: DEFAULT_OBJECT_CLASS)
|
10
|
+
@response = response
|
11
|
+
super
|
12
|
+
end
|
13
|
+
|
14
|
+
def limit
|
15
|
+
@response&.fetch("x-rate-limit-limit", 0).to_i
|
16
|
+
end
|
17
|
+
|
18
|
+
def remaining
|
19
|
+
@response&.fetch("x-rate-limit-remaining", 0).to_i
|
20
|
+
end
|
21
|
+
|
22
|
+
def reset_at
|
23
|
+
Time.at(@response&.fetch("x-rate-limit-reset", 0).to_i).utc if @response
|
24
|
+
end
|
25
|
+
|
26
|
+
def reset_in
|
27
|
+
[(reset_at - Time.now).ceil, 0].max if reset_at
|
28
|
+
end
|
29
|
+
|
30
|
+
alias_method :retry_after, :reset_in
|
31
|
+
end
|
32
|
+
end
|
@@ -0,0 +1,58 @@
|
|
1
|
+
require "net/http"
|
2
|
+
require "uri"
|
3
|
+
|
4
|
+
module X
|
5
|
+
# Creates HTTP requests
|
6
|
+
class RequestBuilder
|
7
|
+
HTTP_METHODS = {
|
8
|
+
get: Net::HTTP::Get,
|
9
|
+
post: Net::HTTP::Post,
|
10
|
+
put: Net::HTTP::Put,
|
11
|
+
delete: Net::HTTP::Delete
|
12
|
+
}.freeze
|
13
|
+
|
14
|
+
attr_accessor :content_type, :user_agent
|
15
|
+
|
16
|
+
def initialize(content_type:, user_agent:)
|
17
|
+
@content_type = content_type
|
18
|
+
@user_agent = user_agent
|
19
|
+
end
|
20
|
+
|
21
|
+
def build(authenticator:, http_method:, base_url:, endpoint:, body: nil)
|
22
|
+
url = URI.join(base_url, endpoint)
|
23
|
+
request = create_request(http_method, url, body)
|
24
|
+
add_authorization(authenticator, request)
|
25
|
+
add_content_type(request)
|
26
|
+
add_user_agent(request)
|
27
|
+
request
|
28
|
+
end
|
29
|
+
|
30
|
+
private
|
31
|
+
|
32
|
+
def create_request(http_method, url, body)
|
33
|
+
http_method_class = HTTP_METHODS[http_method]
|
34
|
+
|
35
|
+
raise ArgumentError, "Unsupported HTTP method: #{http_method}" unless http_method_class
|
36
|
+
|
37
|
+
request = http_method_class.new(url)
|
38
|
+
request.body = body if body && http_method != :get
|
39
|
+
request
|
40
|
+
end
|
41
|
+
|
42
|
+
def add_authorization(authenticator, request)
|
43
|
+
if authenticator.bearer_token
|
44
|
+
request["Authorization"] = "Bearer #{@bearer_token}"
|
45
|
+
else
|
46
|
+
authenticator.sign!(request)
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
def add_content_type(request)
|
51
|
+
request["Content-Type"] = content_type if content_type
|
52
|
+
end
|
53
|
+
|
54
|
+
def add_user_agent(request)
|
55
|
+
request["User-Agent"] = user_agent if user_agent
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
require "json"
|
2
|
+
require "net/http"
|
3
|
+
require_relative "errors/errors"
|
4
|
+
|
5
|
+
module X
|
6
|
+
# Process HTTP responses
|
7
|
+
class ResponseHandler
|
8
|
+
include ClientDefaults
|
9
|
+
include Errors
|
10
|
+
|
11
|
+
attr_accessor :array_class, :object_class
|
12
|
+
|
13
|
+
def initialize(array_class:, object_class:)
|
14
|
+
@array_class = array_class
|
15
|
+
@object_class = object_class
|
16
|
+
end
|
17
|
+
|
18
|
+
def handle(response:)
|
19
|
+
if successful_json_response?(response)
|
20
|
+
return JSON.parse(response.body, array_class: array_class, object_class: object_class)
|
21
|
+
end
|
22
|
+
|
23
|
+
error_class = ERROR_CLASSES[response.code.to_i] || Error
|
24
|
+
error_message = "#{response.code} #{response.message}"
|
25
|
+
raise error_class, error_message if empty_response_body?(response)
|
26
|
+
|
27
|
+
raise error_class.new(error_message, response: response, array_class: array_class, object_class: object_class)
|
28
|
+
end
|
29
|
+
|
30
|
+
private
|
31
|
+
|
32
|
+
def successful_json_response?(response)
|
33
|
+
response.is_a?(Net::HTTPSuccess) && response.body && response["content-type"] == DEFAULT_CONTENT_TYPE
|
34
|
+
end
|
35
|
+
|
36
|
+
def empty_response_body?(response)
|
37
|
+
response.body.nil? || response.body.empty?
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
data/lib/x/version.rb
CHANGED
@@ -1,39 +1,5 @@
|
|
1
|
-
|
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
|
1
|
+
require "rubygems/version"
|
17
2
|
|
18
|
-
|
19
|
-
|
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
|
+
module X
|
4
|
+
VERSION = Gem::Version.create("0.5.0")
|
39
5
|
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.
|
4
|
+
version: 0.5.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-10 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: oauth
|
@@ -38,7 +38,23 @@ files:
|
|
38
38
|
- README.md
|
39
39
|
- Rakefile
|
40
40
|
- lib/x.rb
|
41
|
+
- lib/x/authenticator.rb
|
41
42
|
- lib/x/client.rb
|
43
|
+
- lib/x/client_defaults.rb
|
44
|
+
- lib/x/connection.rb
|
45
|
+
- lib/x/errors/authentication_error.rb
|
46
|
+
- lib/x/errors/bad_request_error.rb
|
47
|
+
- lib/x/errors/client_error.rb
|
48
|
+
- lib/x/errors/error.rb
|
49
|
+
- lib/x/errors/errors.rb
|
50
|
+
- lib/x/errors/forbidden_error.rb
|
51
|
+
- lib/x/errors/network_error.rb
|
52
|
+
- lib/x/errors/not_found_error.rb
|
53
|
+
- lib/x/errors/server_error.rb
|
54
|
+
- lib/x/errors/service_unavailable_error.rb
|
55
|
+
- lib/x/errors/too_many_requests_error.rb
|
56
|
+
- lib/x/request_builder.rb
|
57
|
+
- lib/x/response_handler.rb
|
42
58
|
- lib/x/version.rb
|
43
59
|
- sig/x.rbs
|
44
60
|
homepage: https://github.com/sferik/x-ruby
|
@@ -65,8 +81,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
65
81
|
- !ruby/object:Gem::Version
|
66
82
|
version: '0'
|
67
83
|
requirements: []
|
68
|
-
rubygems_version: 3.4.
|
84
|
+
rubygems_version: 3.4.18
|
69
85
|
signing_key:
|
70
86
|
specification_version: 4
|
71
|
-
summary: A Ruby interface to the X
|
87
|
+
summary: A Ruby interface to the X API.
|
72
88
|
test_files: []
|