x 0.17.0 → 0.19.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 +9 -0
- data/README.md +17 -4
- data/lib/x/account_uploader.rb +168 -0
- data/lib/x/authenticator.rb +12 -0
- data/lib/x/bearer_token_authenticator.rb +22 -1
- data/lib/x/client.rb +121 -52
- data/lib/x/client_credentials.rb +208 -0
- data/lib/x/connection.rb +107 -4
- data/lib/x/errors/bad_gateway.rb +1 -0
- data/lib/x/errors/bad_request.rb +1 -0
- data/lib/x/errors/client_error.rb +1 -0
- data/lib/x/errors/connection_exception.rb +1 -0
- data/lib/x/errors/error.rb +1 -0
- data/lib/x/errors/forbidden.rb +1 -0
- data/lib/x/errors/gateway_timeout.rb +1 -0
- data/lib/x/errors/gone.rb +1 -0
- data/lib/x/errors/http_error.rb +47 -4
- data/lib/x/errors/internal_server_error.rb +1 -0
- data/lib/x/errors/invalid_media_type.rb +6 -0
- data/lib/x/errors/network_error.rb +1 -0
- data/lib/x/errors/not_acceptable.rb +1 -0
- data/lib/x/errors/not_found.rb +1 -0
- data/lib/x/errors/payload_too_large.rb +1 -0
- data/lib/x/errors/server_error.rb +1 -0
- data/lib/x/errors/service_unavailable.rb +1 -0
- data/lib/x/errors/too_many_redirects.rb +1 -0
- data/lib/x/errors/too_many_requests.rb +32 -0
- data/lib/x/errors/unauthorized.rb +1 -0
- data/lib/x/errors/unprocessable_entity.rb +1 -0
- data/lib/x/media_upload_validator.rb +19 -0
- data/lib/x/media_uploader.rb +117 -5
- data/lib/x/oauth2_authenticator.rb +169 -0
- data/lib/x/oauth_authenticator.rb +99 -2
- data/lib/x/rate_limit.rb +57 -1
- data/lib/x/redirect_handler.rb +55 -1
- data/lib/x/request_builder.rb +36 -0
- data/lib/x/response_parser.rb +21 -0
- data/lib/x/stream_parser.rb +75 -0
- data/lib/x/version.rb +2 -1
- data/sig/x.rbs +94 -18
- metadata +7 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5aaa181ca277a20cc6ddb1ec8d4219535357611891da51642471f54c032a2b47
|
|
4
|
+
data.tar.gz: 43f3949ff58bb6e3248adad99324f25f6709ad507fc96bb75ff47eba832bddba
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 612f01c108c9c219363f2910fe393795881151b008c6dc452210ead69c02d1421141061575e8d92f5108fbdc18a3949bc6c3330c9c73444495ed37b4df587e03
|
|
7
|
+
data.tar.gz: 1a5759aa9862066f735632e0ff63defeb6827bd63657013b88dd33d97792ca177f95859784f08ac32f0f31249ee6a26bdfab582218aee1abd79fcac71281b067
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
## [0.19.0] - 2026-03-01
|
|
2
|
+
* Add streaming support for filtered stream and volume stream endpoints
|
|
3
|
+
|
|
4
|
+
## [0.18.0] - 2026-01-06
|
|
5
|
+
* Add OAuth 2.0 authentication with token refresh support (d4c03cb)
|
|
6
|
+
* Add AccountUploader for profile image and banner uploads (7833dd2)
|
|
7
|
+
* Raise InvalidMediaType error for unsupported file extensions (2b6eacc)
|
|
8
|
+
* Prioritize errors array over title/detail in error messages (75279b9)
|
|
9
|
+
|
|
1
10
|
## [0.17.0] - 2025-12-02
|
|
2
11
|
* Add MediaUploader.upload_binary method (9f2f108)
|
|
3
12
|
* Don't forward filename during media upload (492214d)
|
data/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
[](https://github.com/sferik/x-ruby/actions/workflows/mutant.yml)
|
|
3
3
|
[](https://github.com/sferik/x-ruby/actions/workflows/lint.yml)
|
|
4
4
|
[](https://github.com/sferik/x-ruby/actions/workflows/steep.yml)
|
|
5
|
-
[](https://qlty.sh/gh/sferik/projects/x-ruby)
|
|
6
6
|
[](https://rubygems.org/gems/x)
|
|
7
7
|
|
|
8
8
|
# A [Ruby](https://www.ruby-lang.org) interface to the [X API](https://developer.x.com)
|
|
@@ -71,11 +71,23 @@ ads_client = X::Client.new(base_url: "https://ads-api.twitter.com/12/", **x_cred
|
|
|
71
71
|
ads_client.get("accounts")
|
|
72
72
|
```
|
|
73
73
|
|
|
74
|
+
### Streaming
|
|
75
|
+
|
|
76
|
+
```ruby
|
|
77
|
+
# Set up rules for filtered stream
|
|
78
|
+
x_client.post("tweets/search/stream/rules", '{"add": [{"value": "ruby"}]}')
|
|
79
|
+
|
|
80
|
+
# Stream matching posts in real time
|
|
81
|
+
x_client.stream("tweets/search/stream") do |tweet|
|
|
82
|
+
puts tweet["data"]["text"]
|
|
83
|
+
end
|
|
84
|
+
```
|
|
85
|
+
|
|
74
86
|
See other common usage [examples](https://github.com/sferik/x-ruby/tree/main/examples).
|
|
75
87
|
|
|
76
88
|
## History and Philosophy
|
|
77
89
|
|
|
78
|
-
This library is a rewrite of the [Twitter Ruby library](https://github.com/sferik/twitter). Over 16 years of development, that library ballooned to over 3,000 lines of code (plus 7,500 lines of tests), not counting dependencies. This library is
|
|
90
|
+
This library is a rewrite of the [Twitter Ruby library](https://github.com/sferik/twitter). Over 16 years of development, that library ballooned to over 3,000 lines of code (plus 7,500 lines of tests), not counting dependencies. This library is less than 1,000 lines of code (plus 2,000 test lines) and has no runtime dependencies. That doesn’t mean new features won’t be added over time, but the benefits of more code must be weighed against the benefits of less:
|
|
79
91
|
|
|
80
92
|
* Less code is easier to maintain.
|
|
81
93
|
* Less code means fewer bugs.
|
|
@@ -91,10 +103,10 @@ This code is not littered with comments that are intended to generate documentat
|
|
|
91
103
|
|
|
92
104
|
## Features
|
|
93
105
|
|
|
94
|
-
If this entire library is implemented in
|
|
106
|
+
If this entire library is implemented in under 1,000 lines of code, why should you use it at all vs. writing your own library that suits your needs? If you feel inspired to do that, don’t let me discourage you, but this library has some advanced features that may not be apparent without diving into the code:
|
|
95
107
|
|
|
96
108
|
* OAuth 1.0 Revision A
|
|
97
|
-
* OAuth 2.0
|
|
109
|
+
* OAuth 2.0
|
|
98
110
|
* Thread safety
|
|
99
111
|
* HTTP redirect following
|
|
100
112
|
* HTTP proxy support
|
|
@@ -102,6 +114,7 @@ If this entire library is implemented in just 500 lines of code, why should you
|
|
|
102
114
|
* HTTP timeout configuration
|
|
103
115
|
* HTTP error handling
|
|
104
116
|
* Rate limit handling
|
|
117
|
+
* Streaming (filtered stream, volume stream)
|
|
105
118
|
* Parsing JSON into custom response objects (e.g. OpenStruct)
|
|
106
119
|
* Configurable base URLs for accessing different APIs/versions
|
|
107
120
|
* Parallel uploading of large media files in chunks
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
require "base64"
|
|
2
|
+
require "securerandom"
|
|
3
|
+
require_relative "errors/invalid_media_type"
|
|
4
|
+
|
|
5
|
+
module X
|
|
6
|
+
# Uploads profile images and banners to the X API v1.1
|
|
7
|
+
# @api public
|
|
8
|
+
module AccountUploader
|
|
9
|
+
extend self
|
|
10
|
+
|
|
11
|
+
# Base URL for X API v1.1 account endpoints
|
|
12
|
+
V1_BASE_URL = "https://api.x.com/1.1/".freeze
|
|
13
|
+
# Supported image extensions for profile uploads
|
|
14
|
+
SUPPORTED_EXTENSIONS = %w[gif jpg jpeg png].freeze
|
|
15
|
+
# Mapping of file extensions to MIME types
|
|
16
|
+
MIME_TYPE_MAP = {"gif" => "image/gif", "jpg" => "image/jpeg", "jpeg" => "image/jpeg", "png" => "image/png"}.freeze
|
|
17
|
+
|
|
18
|
+
# Update the authenticating user's profile image
|
|
19
|
+
#
|
|
20
|
+
# @api public
|
|
21
|
+
# @param client [Client] the X API client
|
|
22
|
+
# @param file_path [String] the path to the image file
|
|
23
|
+
# @param boundary [String] the multipart boundary
|
|
24
|
+
# @return [Hash, nil] the updated user object
|
|
25
|
+
# @raise [RuntimeError] if the file does not exist
|
|
26
|
+
# @raise [InvalidMediaType] if the file type is not supported
|
|
27
|
+
# @example Update profile image from a file
|
|
28
|
+
# AccountUploader.update_profile_image(client: client, file_path: "avatar.png")
|
|
29
|
+
def update_profile_image(client:, file_path:, boundary: SecureRandom.hex)
|
|
30
|
+
validate_file!(file_path)
|
|
31
|
+
upload_profile_image_binary(client:, content: File.binread(file_path), boundary:)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Update the authenticating user's profile image from binary content
|
|
35
|
+
#
|
|
36
|
+
# @api public
|
|
37
|
+
# @param client [Client] the X API client
|
|
38
|
+
# @param content [String] the binary image content
|
|
39
|
+
# @param boundary [String] the multipart boundary
|
|
40
|
+
# @return [Hash, nil] the updated user object
|
|
41
|
+
# @example Update profile image from binary content
|
|
42
|
+
# AccountUploader.upload_profile_image_binary(client: client, content: image_data)
|
|
43
|
+
def upload_profile_image_binary(client:, content:, boundary: SecureRandom.hex)
|
|
44
|
+
body = construct_multipart_body(field_name: "image", content:, boundary:)
|
|
45
|
+
headers = {"Content-Type" => "multipart/form-data; boundary=#{boundary}"}
|
|
46
|
+
v1_client(client).post("account/update_profile_image.json", body, headers:)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Update the authenticating user's profile banner
|
|
50
|
+
#
|
|
51
|
+
# @api public
|
|
52
|
+
# @param client [Client] the X API client
|
|
53
|
+
# @param file_path [String] the path to the image file
|
|
54
|
+
# @param width [Integer, nil] the width of the banner
|
|
55
|
+
# @param height [Integer, nil] the height of the banner
|
|
56
|
+
# @param offset_left [Integer, nil] the left offset of the banner
|
|
57
|
+
# @param offset_top [Integer, nil] the top offset of the banner
|
|
58
|
+
# @param boundary [String] the multipart boundary
|
|
59
|
+
# @return [Hash, nil] nil on success (204 No Content)
|
|
60
|
+
# @raise [RuntimeError] if the file does not exist
|
|
61
|
+
# @raise [InvalidMediaType] if the file type is not supported
|
|
62
|
+
# @example Update profile banner from a file
|
|
63
|
+
# AccountUploader.update_profile_banner(client: client, file_path: "banner.png")
|
|
64
|
+
# @example Update profile banner with dimensions
|
|
65
|
+
# AccountUploader.update_profile_banner(client: client, file_path: "banner.png", width: 1500, height: 500)
|
|
66
|
+
def update_profile_banner(client:, file_path:, width: nil, height: nil, offset_left: nil, offset_top: nil,
|
|
67
|
+
boundary: SecureRandom.hex)
|
|
68
|
+
validate_file!(file_path)
|
|
69
|
+
upload_profile_banner_binary(client:, content: File.binread(file_path), width:, height:, offset_left:,
|
|
70
|
+
offset_top:, boundary:)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Update the authenticating user's profile banner from binary content
|
|
74
|
+
#
|
|
75
|
+
# @api public
|
|
76
|
+
# @param client [Client] the X API client
|
|
77
|
+
# @param content [String] the binary image content
|
|
78
|
+
# @param width [Integer, nil] the width of the banner
|
|
79
|
+
# @param height [Integer, nil] the height of the banner
|
|
80
|
+
# @param offset_left [Integer, nil] the left offset of the banner
|
|
81
|
+
# @param offset_top [Integer, nil] the top offset of the banner
|
|
82
|
+
# @param boundary [String] the multipart boundary
|
|
83
|
+
# @return [Hash, nil] nil on success (204 No Content)
|
|
84
|
+
# @example Update profile banner from binary content
|
|
85
|
+
# AccountUploader.upload_profile_banner_binary(client: client, content: image_data)
|
|
86
|
+
def upload_profile_banner_binary(client:, content:, width: nil, height: nil, offset_left: nil, offset_top: nil,
|
|
87
|
+
boundary: SecureRandom.hex)
|
|
88
|
+
body = construct_banner_body(content:, width:, height:, offset_left:, offset_top:, boundary:)
|
|
89
|
+
headers = {"Content-Type" => "multipart/form-data; boundary=#{boundary}"}
|
|
90
|
+
v1_client(client).post("account/update_profile_banner.json", body, headers:)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
# Create a v1.1 API client from an existing client
|
|
96
|
+
# @api private
|
|
97
|
+
# @param client [Client] the original client
|
|
98
|
+
# @return [Client] a client configured for v1.1 API
|
|
99
|
+
def v1_client(client)
|
|
100
|
+
Client.new(
|
|
101
|
+
api_key: client.api_key,
|
|
102
|
+
api_key_secret: client.api_key_secret,
|
|
103
|
+
access_token: client.access_token,
|
|
104
|
+
access_token_secret: client.access_token_secret,
|
|
105
|
+
base_url: V1_BASE_URL
|
|
106
|
+
)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Validate that the file exists and has a supported extension
|
|
110
|
+
# @api private
|
|
111
|
+
# @param file_path [String] the file path
|
|
112
|
+
# @return [nil]
|
|
113
|
+
# @raise [RuntimeError] if the file does not exist
|
|
114
|
+
# @raise [InvalidMediaType] if the file type is not supported
|
|
115
|
+
def validate_file!(file_path)
|
|
116
|
+
raise "File not found: #{file_path}" unless File.exist?(file_path)
|
|
117
|
+
|
|
118
|
+
extension = File.extname(file_path).delete(".").downcase
|
|
119
|
+
return if SUPPORTED_EXTENSIONS.include?(extension)
|
|
120
|
+
|
|
121
|
+
raise InvalidMediaType, "Unsupported file type: #{extension}. Supported types: #{SUPPORTED_EXTENSIONS.join(", ")}"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Construct multipart body for profile image upload
|
|
125
|
+
# @api private
|
|
126
|
+
# @param field_name [String] the form field name
|
|
127
|
+
# @param content [String] the binary content
|
|
128
|
+
# @param boundary [String] the multipart boundary
|
|
129
|
+
# @return [String] the multipart body
|
|
130
|
+
def construct_multipart_body(field_name:, content:, boundary:)
|
|
131
|
+
"--#{boundary}\r\n" \
|
|
132
|
+
"Content-Disposition: form-data; name=\"#{field_name}\"\r\n" \
|
|
133
|
+
"Content-Type: application/octet-stream\r\n\r\n" \
|
|
134
|
+
"#{content}\r\n" \
|
|
135
|
+
"--#{boundary}--\r\n"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Construct multipart body for profile banner upload with optional dimensions
|
|
139
|
+
# @api private
|
|
140
|
+
# @param content [String] the binary content
|
|
141
|
+
# @param width [Integer, nil] the width
|
|
142
|
+
# @param height [Integer, nil] the height
|
|
143
|
+
# @param offset_left [Integer, nil] the left offset
|
|
144
|
+
# @param offset_top [Integer, nil] the top offset
|
|
145
|
+
# @param boundary [String] the multipart boundary
|
|
146
|
+
# @return [String] the multipart body
|
|
147
|
+
def construct_banner_body(content:, width:, height:, offset_left:, offset_top:, boundary:)
|
|
148
|
+
body = ""
|
|
149
|
+
body += multipart_field("width", width, boundary) if width
|
|
150
|
+
body += multipart_field("height", height, boundary) if height
|
|
151
|
+
body += multipart_field("offset_left", offset_left, boundary) if offset_left
|
|
152
|
+
body += multipart_field("offset_top", offset_top, boundary) if offset_top
|
|
153
|
+
body + construct_multipart_body(field_name: "banner", content:, boundary:)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Create a multipart form field
|
|
157
|
+
# @api private
|
|
158
|
+
# @param name [String] the field name
|
|
159
|
+
# @param value [Object] the field value
|
|
160
|
+
# @param boundary [String] the multipart boundary
|
|
161
|
+
# @return [String] the multipart field
|
|
162
|
+
def multipart_field(name, value, boundary)
|
|
163
|
+
"--#{boundary}\r\n" \
|
|
164
|
+
"Content-Disposition: form-data; name=\"#{name}\"\r\n\r\n" \
|
|
165
|
+
"#{value}\r\n"
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
data/lib/x/authenticator.rb
CHANGED
|
@@ -1,7 +1,19 @@
|
|
|
1
|
+
# A Ruby client for the X API
|
|
1
2
|
module X
|
|
3
|
+
# Base class for authentication
|
|
4
|
+
# @api public
|
|
2
5
|
class Authenticator
|
|
6
|
+
# The HTTP header name for authentication
|
|
3
7
|
AUTHENTICATION_HEADER = "Authorization".freeze
|
|
4
8
|
|
|
9
|
+
# Generate the authentication header for a request
|
|
10
|
+
#
|
|
11
|
+
# @api public
|
|
12
|
+
# @param _request [Net::HTTPRequest] the HTTP request
|
|
13
|
+
# @return [Hash{String => String}] the authentication header
|
|
14
|
+
# @example Generate an empty authentication header
|
|
15
|
+
# authenticator = X::Authenticator.new
|
|
16
|
+
# authenticator.header(request)
|
|
5
17
|
def header(_request)
|
|
6
18
|
{AUTHENTICATION_HEADER => ""}
|
|
7
19
|
end
|
|
@@ -1,13 +1,34 @@
|
|
|
1
1
|
require_relative "authenticator"
|
|
2
2
|
|
|
3
3
|
module X
|
|
4
|
+
# Authenticator for Bearer token authentication
|
|
5
|
+
# @api public
|
|
4
6
|
class BearerTokenAuthenticator < Authenticator
|
|
7
|
+
# The bearer token for authentication
|
|
8
|
+
# @api public
|
|
9
|
+
# @return [String] the bearer token
|
|
10
|
+
# @example Get the bearer token
|
|
11
|
+
# authenticator.bearer_token
|
|
5
12
|
attr_accessor :bearer_token
|
|
6
13
|
|
|
7
|
-
|
|
14
|
+
# Initialize a new BearerTokenAuthenticator
|
|
15
|
+
#
|
|
16
|
+
# @api public
|
|
17
|
+
# @param bearer_token [String] the bearer token for authentication
|
|
18
|
+
# @return [BearerTokenAuthenticator] a new instance
|
|
19
|
+
# @example Create a new bearer token authenticator
|
|
20
|
+
# authenticator = X::BearerTokenAuthenticator.new(bearer_token: "token")
|
|
21
|
+
def initialize(bearer_token:)
|
|
8
22
|
@bearer_token = bearer_token
|
|
9
23
|
end
|
|
10
24
|
|
|
25
|
+
# Generate the authentication header for a request
|
|
26
|
+
#
|
|
27
|
+
# @api public
|
|
28
|
+
# @param _request [Net::HTTPRequest] the HTTP request
|
|
29
|
+
# @return [Hash{String => String}] the authentication header with bearer token
|
|
30
|
+
# @example Generate a bearer authentication header
|
|
31
|
+
# authenticator.header(request)
|
|
11
32
|
def header(_request)
|
|
12
33
|
{AUTHENTICATION_HEADER => "Bearer #{bearer_token}"}
|
|
13
34
|
end
|
data/lib/x/client.rb
CHANGED
|
@@ -1,121 +1,190 @@
|
|
|
1
1
|
require "forwardable"
|
|
2
|
+
require_relative "authenticator"
|
|
2
3
|
require_relative "bearer_token_authenticator"
|
|
4
|
+
require_relative "client_credentials"
|
|
3
5
|
require_relative "connection"
|
|
4
6
|
require_relative "oauth_authenticator"
|
|
7
|
+
require_relative "oauth2_authenticator"
|
|
5
8
|
require_relative "redirect_handler"
|
|
6
9
|
require_relative "request_builder"
|
|
7
10
|
require_relative "response_parser"
|
|
11
|
+
require_relative "stream_parser"
|
|
8
12
|
|
|
9
13
|
module X
|
|
14
|
+
# A client for interacting with the X API
|
|
15
|
+
# @api public
|
|
10
16
|
class Client
|
|
11
17
|
extend Forwardable
|
|
18
|
+
include ClientCredentials
|
|
12
19
|
|
|
20
|
+
# Default base URL for the X API
|
|
13
21
|
DEFAULT_BASE_URL = "https://api.twitter.com/2/".freeze
|
|
22
|
+
# Default class for parsing JSON arrays
|
|
14
23
|
DEFAULT_ARRAY_CLASS = Array
|
|
24
|
+
# Default class for parsing JSON objects
|
|
15
25
|
DEFAULT_OBJECT_CLASS = Hash
|
|
16
26
|
|
|
17
|
-
|
|
18
|
-
|
|
27
|
+
# The base URL for API requests
|
|
28
|
+
# @api public
|
|
29
|
+
# @return [String] the base URL for API requests
|
|
30
|
+
# @example Get or set the base URL
|
|
31
|
+
# client.base_url = "https://api.twitter.com/1.1/"
|
|
32
|
+
attr_accessor :base_url
|
|
33
|
+
|
|
34
|
+
# The default class for parsing JSON arrays
|
|
35
|
+
# @api public
|
|
36
|
+
# @return [Class] the default class for parsing JSON arrays
|
|
37
|
+
# @example Get or set the default array class
|
|
38
|
+
# client.default_array_class = Set
|
|
39
|
+
attr_accessor :default_array_class
|
|
40
|
+
|
|
41
|
+
# The default class for parsing JSON objects
|
|
42
|
+
# @api public
|
|
43
|
+
# @return [Class] the default class for parsing JSON objects
|
|
44
|
+
# @example Get or set the default object class
|
|
45
|
+
# client.default_object_class = OpenStruct
|
|
46
|
+
attr_accessor :default_object_class
|
|
47
|
+
|
|
48
|
+
# The authenticator for API requests
|
|
49
|
+
# @api public
|
|
50
|
+
# @return [Authenticator] the authenticator instance
|
|
51
|
+
# @example Check if the OAuth 2.0 token has expired
|
|
52
|
+
# client.authenticator.token_expired?
|
|
53
|
+
attr_reader :authenticator
|
|
19
54
|
|
|
20
55
|
def_delegators :@connection, :open_timeout, :read_timeout, :write_timeout, :proxy_url, :debug_output
|
|
21
56
|
def_delegators :@connection, :open_timeout=, :read_timeout=, :write_timeout=, :proxy_url=, :debug_output=
|
|
22
57
|
def_delegators :@redirect_handler, :max_redirects
|
|
23
58
|
def_delegators :@redirect_handler, :max_redirects=
|
|
24
59
|
|
|
60
|
+
# Initialize a new X API client
|
|
61
|
+
#
|
|
62
|
+
# @api public
|
|
63
|
+
# @param api_key [String, nil] the API key for OAuth 1.0a authentication
|
|
64
|
+
# @param api_key_secret [String, nil] the API key secret for OAuth 1.0a authentication
|
|
65
|
+
# @param access_token [String, nil] the access token for OAuth authentication
|
|
66
|
+
# @param access_token_secret [String, nil] the access token secret for OAuth 1.0a authentication
|
|
67
|
+
# @param bearer_token [String, nil] the bearer token for authentication
|
|
68
|
+
# @param client_id [String, nil] the OAuth 2.0 client ID
|
|
69
|
+
# @param client_secret [String, nil] the OAuth 2.0 client secret
|
|
70
|
+
# @param refresh_token [String, nil] the OAuth 2.0 refresh token
|
|
71
|
+
# @param base_url [String] the base URL for API requests
|
|
72
|
+
# @param open_timeout [Integer] the timeout for opening connections in seconds
|
|
73
|
+
# @param read_timeout [Integer] the timeout for reading responses in seconds
|
|
74
|
+
# @param write_timeout [Integer] the timeout for writing requests in seconds
|
|
75
|
+
# @param debug_output [IO] the IO object for debug output
|
|
76
|
+
# @param proxy_url [String, nil] the proxy URL for requests
|
|
77
|
+
# @param default_array_class [Class] the default class for parsing JSON arrays
|
|
78
|
+
# @param default_object_class [Class] the default class for parsing JSON objects
|
|
79
|
+
# @param max_redirects [Integer] the maximum number of redirects to follow
|
|
80
|
+
# @return [Client] a new client instance
|
|
81
|
+
# @example Create a client with bearer token authentication
|
|
82
|
+
# client = X::Client.new(bearer_token: "your_bearer_token")
|
|
83
|
+
# @example Create a client with OAuth 1.0a authentication
|
|
84
|
+
# client = X::Client.new(api_key: "key", api_key_secret: "secret", access_token: "token", access_token_secret: "token_secret")
|
|
25
85
|
def initialize(api_key: nil, api_key_secret: nil, access_token: nil, access_token_secret: nil,
|
|
26
|
-
bearer_token: nil,
|
|
86
|
+
bearer_token: nil, client_id: nil, client_secret: nil, refresh_token: nil,
|
|
27
87
|
base_url: DEFAULT_BASE_URL,
|
|
28
88
|
open_timeout: Connection::DEFAULT_OPEN_TIMEOUT,
|
|
29
89
|
read_timeout: Connection::DEFAULT_READ_TIMEOUT,
|
|
30
90
|
write_timeout: Connection::DEFAULT_WRITE_TIMEOUT,
|
|
31
|
-
debug_output:
|
|
91
|
+
debug_output: nil,
|
|
32
92
|
proxy_url: nil,
|
|
33
93
|
default_array_class: DEFAULT_ARRAY_CLASS,
|
|
34
94
|
default_object_class: DEFAULT_OBJECT_CLASS,
|
|
35
95
|
max_redirects: RedirectHandler::DEFAULT_MAX_REDIRECTS)
|
|
36
|
-
|
|
96
|
+
initialize_credentials(api_key:, api_key_secret:, access_token:, access_token_secret:, bearer_token:,
|
|
97
|
+
client_id:, client_secret:, refresh_token:)
|
|
37
98
|
initialize_authenticator
|
|
38
99
|
@base_url = base_url
|
|
39
|
-
initialize_default_classes(default_array_class
|
|
100
|
+
initialize_default_classes(default_array_class:, default_object_class:)
|
|
40
101
|
@connection = Connection.new(open_timeout:, read_timeout:, write_timeout:, debug_output:, proxy_url:)
|
|
41
102
|
@request_builder = RequestBuilder.new
|
|
42
103
|
@redirect_handler = RedirectHandler.new(connection: @connection, request_builder: @request_builder, max_redirects:)
|
|
43
104
|
@response_parser = ResponseParser.new
|
|
105
|
+
@stream_parser = StreamParser.new
|
|
44
106
|
end
|
|
45
107
|
|
|
108
|
+
# Perform a GET request to the X API
|
|
109
|
+
#
|
|
110
|
+
# @api public
|
|
111
|
+
# @return [Hash, Array, nil] the parsed response body
|
|
112
|
+
# @example Get a user by username
|
|
113
|
+
# client.get("users/by/username/sferik")
|
|
46
114
|
def get(endpoint, headers: {}, array_class: default_array_class, object_class: default_object_class)
|
|
47
115
|
execute_request(:get, endpoint, headers:, array_class:, object_class:)
|
|
48
116
|
end
|
|
49
117
|
|
|
118
|
+
# Perform a POST request to the X API
|
|
119
|
+
#
|
|
120
|
+
# @api public
|
|
121
|
+
# @return [Hash, Array, nil] the parsed response body
|
|
122
|
+
# @example Create a tweet
|
|
123
|
+
# client.post("tweets", '{"text": "Hello, World!"}')
|
|
50
124
|
def post(endpoint, body = nil, headers: {}, array_class: default_array_class, object_class: default_object_class)
|
|
51
125
|
execute_request(:post, endpoint, body:, headers:, array_class:, object_class:)
|
|
52
126
|
end
|
|
53
127
|
|
|
128
|
+
# Perform a PUT request to the X API
|
|
129
|
+
#
|
|
130
|
+
# @api public
|
|
131
|
+
# @return [Hash, Array, nil] the parsed response body
|
|
132
|
+
# @example Update a resource
|
|
133
|
+
# client.put("some/endpoint", '{"key": "value"}')
|
|
54
134
|
def put(endpoint, body = nil, headers: {}, array_class: default_array_class, object_class: default_object_class)
|
|
55
135
|
execute_request(:put, endpoint, body:, headers:, array_class:, object_class:)
|
|
56
136
|
end
|
|
57
137
|
|
|
138
|
+
# Perform a DELETE request to the X API
|
|
139
|
+
#
|
|
140
|
+
# @api public
|
|
141
|
+
# @return [Hash, Array, nil] the parsed response body
|
|
142
|
+
# @example Delete a tweet
|
|
143
|
+
# client.delete("tweets/1234567890")
|
|
58
144
|
def delete(endpoint, headers: {}, array_class: default_array_class, object_class: default_object_class)
|
|
59
145
|
execute_request(:delete, endpoint, headers:, array_class:, object_class:)
|
|
60
146
|
end
|
|
61
147
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
end
|
|
81
|
-
|
|
82
|
-
def bearer_token=(bearer_token)
|
|
83
|
-
@bearer_token = bearer_token
|
|
84
|
-
initialize_authenticator
|
|
148
|
+
# Stream data from the X API
|
|
149
|
+
#
|
|
150
|
+
# @api public
|
|
151
|
+
# @param endpoint [String] the streaming API endpoint
|
|
152
|
+
# @param headers [Hash] additional headers for the request
|
|
153
|
+
# @param array_class [Class] the class for parsing JSON arrays
|
|
154
|
+
# @param object_class [Class] the class for parsing JSON objects
|
|
155
|
+
# @yield [Hash, Array] each parsed JSON object from the stream
|
|
156
|
+
# @return [void]
|
|
157
|
+
# @raise [HTTPError] if the response is not successful
|
|
158
|
+
# @example Stream filtered tweets
|
|
159
|
+
# client.stream("tweets/search/stream") { |tweet| puts tweet }
|
|
160
|
+
def stream(endpoint, headers: {}, array_class: default_array_class, object_class: default_object_class, &block)
|
|
161
|
+
uri = URI.join(base_url, endpoint)
|
|
162
|
+
request = @request_builder.build(http_method: :get, uri:, headers:, authenticator:)
|
|
163
|
+
@connection.perform_stream(request:) do |response|
|
|
164
|
+
@stream_parser.process(response:, response_parser: @response_parser, array_class:, object_class:, &block)
|
|
165
|
+
end
|
|
85
166
|
end
|
|
86
167
|
|
|
87
168
|
private
|
|
88
169
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
end
|
|
96
|
-
|
|
97
|
-
def initialize_default_classes(default_array_class, default_object_class)
|
|
170
|
+
# Initialize default JSON parsing classes
|
|
171
|
+
# @api private
|
|
172
|
+
# @param default_array_class [Class] the default class for parsing JSON arrays
|
|
173
|
+
# @param default_object_class [Class] the default class for parsing JSON objects
|
|
174
|
+
# @return [void]
|
|
175
|
+
def initialize_default_classes(default_array_class:, default_object_class:)
|
|
98
176
|
@default_array_class = default_array_class
|
|
99
177
|
@default_object_class = default_object_class
|
|
100
178
|
end
|
|
101
179
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
elsif bearer_token
|
|
106
|
-
BearerTokenAuthenticator.new(bearer_token:)
|
|
107
|
-
elsif @authenticator.nil?
|
|
108
|
-
Authenticator.new
|
|
109
|
-
else
|
|
110
|
-
@authenticator
|
|
111
|
-
end
|
|
112
|
-
end
|
|
113
|
-
|
|
180
|
+
# Execute an HTTP request to the X API
|
|
181
|
+
# @api private
|
|
182
|
+
# @return [Hash, Array, nil] the parsed response body
|
|
114
183
|
def execute_request(http_method, endpoint, body: nil, headers: {}, array_class: default_array_class, object_class: default_object_class)
|
|
115
184
|
uri = URI.join(base_url, endpoint)
|
|
116
|
-
request = @request_builder.build(http_method:, uri:, body:, headers:, authenticator:
|
|
185
|
+
request = @request_builder.build(http_method:, uri:, body:, headers:, authenticator:)
|
|
117
186
|
response = @connection.perform(request:)
|
|
118
|
-
response = @redirect_handler.handle(response:, request:, base_url:, authenticator:
|
|
187
|
+
response = @redirect_handler.handle(response:, request:, base_url:, authenticator:)
|
|
119
188
|
@response_parser.parse(response:, array_class:, object_class:)
|
|
120
189
|
end
|
|
121
190
|
end
|