x 0.17.0 → 0.18.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 +6 -0
- data/README.md +4 -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 +95 -57
- data/lib/x/client_credentials.rb +208 -0
- data/lib/x/connection.rb +88 -2
- 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/version.rb +2 -1
- data/sig/x.rbs +78 -17
- metadata +6 -2
data/lib/x/media_uploader.rb
CHANGED
|
@@ -1,27 +1,58 @@
|
|
|
1
1
|
require "json"
|
|
2
2
|
require "securerandom"
|
|
3
3
|
require "tmpdir"
|
|
4
|
+
require_relative "errors/invalid_media_type"
|
|
4
5
|
require_relative "media_upload_validator"
|
|
5
6
|
|
|
6
7
|
module X
|
|
8
|
+
# Uploads media files to the X API
|
|
9
|
+
# @api public
|
|
7
10
|
module MediaUploader
|
|
8
11
|
extend self
|
|
9
12
|
|
|
13
|
+
# Maximum number of retry attempts for failed uploads
|
|
10
14
|
MAX_RETRIES = 3
|
|
15
|
+
# Number of bytes per megabyte
|
|
11
16
|
BYTES_PER_MB = 1_048_576
|
|
17
|
+
# Media category constants
|
|
12
18
|
DM_GIF, DM_IMAGE, DM_VIDEO, SUBTITLES, TWEET_GIF, TWEET_IMAGE, TWEET_VIDEO = MediaUploadValidator::MEDIA_CATEGORIES
|
|
13
|
-
|
|
19
|
+
# Supported MIME types
|
|
14
20
|
MIME_TYPES = %w[image/gif image/jpeg video/mp4 image/png application/x-subrip image/webp].freeze
|
|
21
|
+
# MIME type constants
|
|
15
22
|
GIF_MIME_TYPE, JPEG_MIME_TYPE, MP4_MIME_TYPE, PNG_MIME_TYPE, SUBRIP_MIME_TYPE, WEBP_MIME_TYPE = MIME_TYPES
|
|
23
|
+
# Mapping of file extensions to MIME types
|
|
16
24
|
MIME_TYPE_MAP = {"gif" => GIF_MIME_TYPE, "jpg" => JPEG_MIME_TYPE, "jpeg" => JPEG_MIME_TYPE, "mp4" => MP4_MIME_TYPE,
|
|
17
25
|
"png" => PNG_MIME_TYPE, "srt" => SUBRIP_MIME_TYPE, "webp" => WEBP_MIME_TYPE}.freeze
|
|
26
|
+
# Processing states that indicate completion
|
|
18
27
|
PROCESSING_INFO_STATES = %w[failed succeeded].freeze
|
|
19
28
|
|
|
29
|
+
# Upload a file to the X API
|
|
30
|
+
#
|
|
31
|
+
# @api public
|
|
32
|
+
# @param client [Client] the X API client
|
|
33
|
+
# @param file_path [String] the path to the file to upload
|
|
34
|
+
# @param media_category [String] the media category
|
|
35
|
+
# @param boundary [String] the multipart boundary
|
|
36
|
+
# @return [Hash, nil] the upload response data
|
|
37
|
+
# @raise [RuntimeError] if the file does not exist
|
|
38
|
+
# @example Upload an image
|
|
39
|
+
# MediaUploader.upload(client: client, file_path: "image.png", media_category: "tweet_image")
|
|
20
40
|
def upload(client:, file_path:, media_category:, boundary: SecureRandom.hex)
|
|
21
41
|
MediaUploadValidator.validate_file_path!(file_path:)
|
|
22
42
|
upload_binary(client:, content: File.binread(file_path), media_category:, boundary:)
|
|
23
43
|
end
|
|
24
44
|
|
|
45
|
+
# Upload binary content to the X API
|
|
46
|
+
#
|
|
47
|
+
# @api public
|
|
48
|
+
# @param client [Client] the X API client
|
|
49
|
+
# @param content [String] the binary content to upload
|
|
50
|
+
# @param media_category [String] the media category
|
|
51
|
+
# @param boundary [String] the multipart boundary
|
|
52
|
+
# @return [Hash, nil] the upload response data
|
|
53
|
+
# @raise [ArgumentError] if the media category is invalid
|
|
54
|
+
# @example Upload binary content
|
|
55
|
+
# MediaUploader.upload_binary(client: client, content: data, media_category: "tweet_image")
|
|
25
56
|
def upload_binary(client:, content:, media_category:, boundary: SecureRandom.hex)
|
|
26
57
|
MediaUploadValidator.validate_media_category!(media_category:)
|
|
27
58
|
upload_body = construct_upload_body(content:, media_category:, boundary:)
|
|
@@ -29,6 +60,20 @@ module X
|
|
|
29
60
|
client.post("media/upload", upload_body, headers:)&.fetch("data")
|
|
30
61
|
end
|
|
31
62
|
|
|
63
|
+
# Perform a chunked upload for large files
|
|
64
|
+
#
|
|
65
|
+
# @api public
|
|
66
|
+
# @param client [Client] the X API client
|
|
67
|
+
# @param file_path [String] the path to the file to upload
|
|
68
|
+
# @param media_category [String] the media category
|
|
69
|
+
# @param media_type [String] the MIME type of the media
|
|
70
|
+
# @param boundary [String] the multipart boundary
|
|
71
|
+
# @param chunk_size_mb [Integer] the size of each chunk in megabytes
|
|
72
|
+
# @return [Hash, nil] the upload response data
|
|
73
|
+
# @raise [RuntimeError] if the file does not exist
|
|
74
|
+
# @raise [ArgumentError] if the media category is invalid
|
|
75
|
+
# @example Upload a large video
|
|
76
|
+
# MediaUploader.chunked_upload(client: client, file_path: "video.mp4", media_category: "tweet_video")
|
|
32
77
|
def chunked_upload(client:, file_path:, media_category:, media_type: infer_media_type(file_path, media_category),
|
|
33
78
|
boundary: SecureRandom.hex, chunk_size_mb: 1)
|
|
34
79
|
MediaUploadValidator.validate_file_path!(file_path:)
|
|
@@ -39,6 +84,14 @@ module X
|
|
|
39
84
|
client.post("media/upload/#{media["id"]}/finalize")&.fetch("data")
|
|
40
85
|
end
|
|
41
86
|
|
|
87
|
+
# Wait for media processing to complete
|
|
88
|
+
#
|
|
89
|
+
# @api public
|
|
90
|
+
# @param client [Client] the X API client
|
|
91
|
+
# @param media [Hash] the media object with an id
|
|
92
|
+
# @return [Hash, nil] the processing status
|
|
93
|
+
# @example Wait for processing
|
|
94
|
+
# MediaUploader.await_processing(client: client, media: media)
|
|
42
95
|
def await_processing(client:, media:)
|
|
43
96
|
loop do
|
|
44
97
|
status = client.get("media/upload?command=STATUS&media_id=#{media["id"]}")&.fetch("data")
|
|
@@ -48,6 +101,15 @@ module X
|
|
|
48
101
|
end
|
|
49
102
|
end
|
|
50
103
|
|
|
104
|
+
# Wait for media processing and raise on failure
|
|
105
|
+
#
|
|
106
|
+
# @api public
|
|
107
|
+
# @param client [Client] the X API client
|
|
108
|
+
# @param media [Hash] the media object with an id
|
|
109
|
+
# @return [Hash, nil] the processing status
|
|
110
|
+
# @raise [RuntimeError] if media processing failed
|
|
111
|
+
# @example Wait for processing with error handling
|
|
112
|
+
# MediaUploader.await_processing!(client: client, media: media)
|
|
51
113
|
def await_processing!(client:, media:)
|
|
52
114
|
status = await_processing(client:, media:)
|
|
53
115
|
raise "Media processing failed" if status&.dig("processing_info", "state") == "failed"
|
|
@@ -55,17 +117,34 @@ module X
|
|
|
55
117
|
status
|
|
56
118
|
end
|
|
57
119
|
|
|
58
|
-
|
|
59
|
-
|
|
120
|
+
# Infer the media type from file path and category
|
|
121
|
+
#
|
|
122
|
+
# @api public
|
|
123
|
+
# @param file_path [String] the file path
|
|
124
|
+
# @param media_category [String] the media category
|
|
125
|
+
# @return [String] the inferred MIME type
|
|
126
|
+
# @raise [InvalidMediaType] if the MIME type cannot be determined
|
|
127
|
+
# @example MediaUploader.infer_media_type("image.png", "tweet_image") #=> "image/png"
|
|
60
128
|
def infer_media_type(file_path, media_category)
|
|
61
129
|
case media_category.downcase
|
|
62
130
|
when TWEET_GIF, DM_GIF then GIF_MIME_TYPE
|
|
63
131
|
when TWEET_VIDEO, DM_VIDEO then MP4_MIME_TYPE
|
|
64
132
|
when SUBTITLES then SUBRIP_MIME_TYPE
|
|
65
|
-
else
|
|
133
|
+
else
|
|
134
|
+
extension = File.extname(file_path).delete(".").downcase
|
|
135
|
+
MIME_TYPE_MAP.fetch(extension) do
|
|
136
|
+
raise InvalidMediaType, "unable to determine MIME type from file extension: #{file_path.inspect}"
|
|
137
|
+
end
|
|
66
138
|
end
|
|
67
139
|
end
|
|
68
140
|
|
|
141
|
+
private
|
|
142
|
+
|
|
143
|
+
# Split a file into chunks
|
|
144
|
+
# @api private
|
|
145
|
+
# @param file_path [String] the file path
|
|
146
|
+
# @param chunk_size [Integer] the chunk size in bytes
|
|
147
|
+
# @return [Array<String>] the paths to the chunk files
|
|
69
148
|
def split(file_path, chunk_size)
|
|
70
149
|
file_size = File.size(file_path)
|
|
71
150
|
segment_count = (file_size.to_f / chunk_size).ceil
|
|
@@ -76,12 +155,26 @@ module X
|
|
|
76
155
|
end
|
|
77
156
|
end
|
|
78
157
|
|
|
158
|
+
# Initialize a chunked upload
|
|
159
|
+
# @api private
|
|
160
|
+
# @param client [Client] the X API client
|
|
161
|
+
# @param file_path [String] the file path
|
|
162
|
+
# @param media_type [String] the MIME type
|
|
163
|
+
# @param media_category [String] the media category
|
|
164
|
+
# @return [Hash, nil] the initialization response
|
|
79
165
|
def init(client:, file_path:, media_type:, media_category:)
|
|
80
166
|
total_bytes = File.size(file_path)
|
|
81
167
|
data = {media_type:, media_category:, total_bytes:}.to_json
|
|
82
168
|
client.post("media/upload/initialize", data)&.fetch("data")
|
|
83
169
|
end
|
|
84
170
|
|
|
171
|
+
# Append chunks to a chunked upload
|
|
172
|
+
# @api private
|
|
173
|
+
# @param client [Client] the X API client
|
|
174
|
+
# @param file_paths [Array<String>] the chunk file paths
|
|
175
|
+
# @param media [Hash] the media object
|
|
176
|
+
# @param boundary [String] the multipart boundary
|
|
177
|
+
# @return [void]
|
|
85
178
|
def append(client:, file_paths:, media:, boundary: SecureRandom.hex)
|
|
86
179
|
threads = file_paths.map.with_index do |file_path, index|
|
|
87
180
|
Thread.new do
|
|
@@ -93,6 +186,14 @@ module X
|
|
|
93
186
|
threads.each(&:join)
|
|
94
187
|
end
|
|
95
188
|
|
|
189
|
+
# Upload a single chunk with retry logic
|
|
190
|
+
# @api private
|
|
191
|
+
# @param client [Client] the X API client
|
|
192
|
+
# @param media_id [String] the media ID
|
|
193
|
+
# @param upload_body [String] the upload body
|
|
194
|
+
# @param file_path [String] the chunk file path
|
|
195
|
+
# @param headers [Hash] the request headers
|
|
196
|
+
# @return [void]
|
|
96
197
|
def upload_chunk(client:, media_id:, upload_body:, file_path:, headers: {})
|
|
97
198
|
client.post("media/upload/#{media_id}/append", upload_body, headers:)
|
|
98
199
|
rescue NetworkError, ServerError
|
|
@@ -102,19 +203,30 @@ module X
|
|
|
102
203
|
cleanup_file(file_path)
|
|
103
204
|
end
|
|
104
205
|
|
|
206
|
+
# Clean up a temporary file
|
|
207
|
+
# @api private
|
|
208
|
+
# @param file_path [String] the file path
|
|
209
|
+
# @return [void]
|
|
105
210
|
def cleanup_file(file_path)
|
|
106
211
|
dirname = File.dirname(file_path)
|
|
107
212
|
File.delete(file_path)
|
|
108
213
|
Dir.delete(dirname) if Dir.empty?(dirname)
|
|
109
214
|
end
|
|
110
215
|
|
|
216
|
+
# Construct the multipart upload body
|
|
217
|
+
# @api private
|
|
218
|
+
# @param content [String] the content to upload
|
|
219
|
+
# @param media_category [String, nil] the media category
|
|
220
|
+
# @param segment_index [Integer, nil] the segment index
|
|
221
|
+
# @param boundary [String] the multipart boundary
|
|
222
|
+
# @return [String] the upload body
|
|
111
223
|
def construct_upload_body(content:, media_category: nil, segment_index: nil, boundary: SecureRandom.hex)
|
|
112
224
|
body = ""
|
|
113
225
|
body += "--#{boundary}\r\nContent-Disposition: form-data; name=\"segment_index\"\r\n\r\n#{segment_index}\r\n" if segment_index
|
|
114
226
|
body += "--#{boundary}\r\nContent-Disposition: form-data; name=\"media_category\"\r\n\r\n#{media_category}\r\n" if media_category
|
|
115
227
|
"#{body}--#{boundary}\r\n" \
|
|
116
228
|
"Content-Disposition: form-data; name=\"media\"\r\n" \
|
|
117
|
-
"Content-Type:
|
|
229
|
+
"Content-Type: application/octet-stream\r\n\r\n" \
|
|
118
230
|
"#{content}\r\n" \
|
|
119
231
|
"--#{boundary}--\r\n"
|
|
120
232
|
end
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
require "base64"
|
|
2
|
+
require "json"
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
require_relative "authenticator"
|
|
6
|
+
require_relative "connection"
|
|
7
|
+
|
|
8
|
+
module X
|
|
9
|
+
# Handles OAuth 2.0 authentication with token refresh capability
|
|
10
|
+
# @api public
|
|
11
|
+
class OAuth2Authenticator < Authenticator
|
|
12
|
+
# Path for the OAuth 2.0 token endpoint
|
|
13
|
+
TOKEN_PATH = "/2/oauth2/token".freeze
|
|
14
|
+
# Host for token refresh requests
|
|
15
|
+
TOKEN_HOST = "api.x.com".freeze
|
|
16
|
+
# Grant type for token refresh
|
|
17
|
+
REFRESH_GRANT_TYPE = "refresh_token".freeze
|
|
18
|
+
# Buffer time in seconds to account for clock skew and network latency
|
|
19
|
+
EXPIRATION_BUFFER = 30
|
|
20
|
+
|
|
21
|
+
# The OAuth 2.0 client ID
|
|
22
|
+
# @api public
|
|
23
|
+
# @return [String] the client ID
|
|
24
|
+
# @example Get the client ID
|
|
25
|
+
# authenticator.client_id
|
|
26
|
+
attr_accessor :client_id
|
|
27
|
+
# The OAuth 2.0 client secret
|
|
28
|
+
# @api public
|
|
29
|
+
# @return [String] the client secret
|
|
30
|
+
# @example Get the client secret
|
|
31
|
+
# authenticator.client_secret
|
|
32
|
+
attr_accessor :client_secret
|
|
33
|
+
# The OAuth 2.0 access token
|
|
34
|
+
# @api public
|
|
35
|
+
# @return [String] the access token
|
|
36
|
+
# @example Get the access token
|
|
37
|
+
# authenticator.access_token
|
|
38
|
+
attr_accessor :access_token
|
|
39
|
+
# The OAuth 2.0 refresh token
|
|
40
|
+
# @api public
|
|
41
|
+
# @return [String] the refresh token
|
|
42
|
+
# @example Get the refresh token
|
|
43
|
+
# authenticator.refresh_token
|
|
44
|
+
attr_accessor :refresh_token
|
|
45
|
+
# The expiration time of the access token
|
|
46
|
+
# @api public
|
|
47
|
+
# @return [Time, nil] the expiration time
|
|
48
|
+
# @example Get the expiration time
|
|
49
|
+
# authenticator.expires_at
|
|
50
|
+
attr_accessor :expires_at
|
|
51
|
+
|
|
52
|
+
# The connection for making token requests
|
|
53
|
+
# @api public
|
|
54
|
+
# @return [Connection] the connection instance
|
|
55
|
+
# @example Get the connection
|
|
56
|
+
# authenticator.connection
|
|
57
|
+
attr_accessor :connection
|
|
58
|
+
|
|
59
|
+
# Initialize a new OAuth 2.0 authenticator
|
|
60
|
+
#
|
|
61
|
+
# @api public
|
|
62
|
+
# @param client_id [String] the OAuth 2.0 client ID
|
|
63
|
+
# @param client_secret [String] the OAuth 2.0 client secret
|
|
64
|
+
# @param access_token [String] the OAuth 2.0 access token
|
|
65
|
+
# @param refresh_token [String] the OAuth 2.0 refresh token
|
|
66
|
+
# @param expires_at [Time, nil] the expiration time of the access token
|
|
67
|
+
# @param connection [Connection] the connection for making token requests
|
|
68
|
+
# @return [OAuth2Authenticator] a new authenticator instance
|
|
69
|
+
# @example Create an authenticator
|
|
70
|
+
# authenticator = X::OAuth2Authenticator.new(
|
|
71
|
+
# client_id: "id",
|
|
72
|
+
# client_secret: "secret",
|
|
73
|
+
# access_token: "token",
|
|
74
|
+
# refresh_token: "refresh"
|
|
75
|
+
# )
|
|
76
|
+
def initialize(client_id:, client_secret:, access_token:, refresh_token:, expires_at: nil,
|
|
77
|
+
connection: Connection.new)
|
|
78
|
+
@client_id = client_id
|
|
79
|
+
@client_secret = client_secret
|
|
80
|
+
@access_token = access_token
|
|
81
|
+
@refresh_token = refresh_token
|
|
82
|
+
@expires_at = expires_at
|
|
83
|
+
@connection = connection
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Generate the authentication header
|
|
87
|
+
#
|
|
88
|
+
# @api public
|
|
89
|
+
# @param _request [Net::HTTPRequest, nil] the HTTP request (unused)
|
|
90
|
+
# @return [Hash{String => String}] the authentication header
|
|
91
|
+
# @example Get the header
|
|
92
|
+
# authenticator.header(request)
|
|
93
|
+
def header(_request)
|
|
94
|
+
{AUTHENTICATION_HEADER => "Bearer #{access_token}"}
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Check if the access token has expired or will expire soon
|
|
98
|
+
#
|
|
99
|
+
# @api public
|
|
100
|
+
# @return [Boolean] true if the token has expired or will expire within the buffer period
|
|
101
|
+
# @example Check expiration
|
|
102
|
+
# authenticator.token_expired?
|
|
103
|
+
def token_expired?
|
|
104
|
+
return false if expires_at.nil?
|
|
105
|
+
|
|
106
|
+
Time.now >= expires_at - EXPIRATION_BUFFER
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Refresh the access token using the refresh token
|
|
110
|
+
#
|
|
111
|
+
# @api public
|
|
112
|
+
# @return [Hash{String => Object}] the token response
|
|
113
|
+
# @raise [Error] if token refresh fails
|
|
114
|
+
# @example Refresh the token
|
|
115
|
+
# authenticator.refresh_token!
|
|
116
|
+
def refresh_token!
|
|
117
|
+
response = send_token_request
|
|
118
|
+
handle_token_response(response)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
private
|
|
122
|
+
|
|
123
|
+
# Send the token refresh request
|
|
124
|
+
# @api private
|
|
125
|
+
# @return [Net::HTTPResponse] the HTTP response
|
|
126
|
+
def send_token_request
|
|
127
|
+
request = build_token_request
|
|
128
|
+
connection.perform(request: request)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Build the token refresh request
|
|
132
|
+
# @api private
|
|
133
|
+
# @return [Net::HTTP::Post] the POST request
|
|
134
|
+
def build_token_request
|
|
135
|
+
uri = URI::HTTPS.build(host: TOKEN_HOST, path: TOKEN_PATH)
|
|
136
|
+
request = Net::HTTP::Post.new(uri)
|
|
137
|
+
request["Content-Type"] = "application/x-www-form-urlencoded"
|
|
138
|
+
request["Authorization"] = "Basic #{Base64.strict_encode64("#{client_id}:#{client_secret}")}"
|
|
139
|
+
request.body = URI.encode_www_form(grant_type: REFRESH_GRANT_TYPE, refresh_token: refresh_token)
|
|
140
|
+
request
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Handle the token response
|
|
144
|
+
# @api private
|
|
145
|
+
# @param response [Net::HTTPResponse] the HTTP response
|
|
146
|
+
# @return [Hash{String => Object}] the parsed response body
|
|
147
|
+
# @raise [Error] if the response indicates an error
|
|
148
|
+
def handle_token_response(response)
|
|
149
|
+
body = JSON.parse(response.body)
|
|
150
|
+
rescue JSON::ParserError
|
|
151
|
+
raise Error, "Token refresh failed"
|
|
152
|
+
else
|
|
153
|
+
raise Error, body["error_description"] || body["error"] || "Token refresh failed" unless response.is_a?(Net::HTTPSuccess)
|
|
154
|
+
|
|
155
|
+
update_tokens(body)
|
|
156
|
+
body
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Update tokens from the response
|
|
160
|
+
# @api private
|
|
161
|
+
# @param token_response [Hash{String => Object}] the token response
|
|
162
|
+
# @return [void]
|
|
163
|
+
def update_tokens(token_response)
|
|
164
|
+
@access_token = token_response.fetch("access_token")
|
|
165
|
+
@refresh_token = token_response.fetch("refresh_token") if token_response.key?("refresh_token")
|
|
166
|
+
@expires_at = Time.now + token_response.fetch("expires_in") if token_response.key?("expires_in")
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
@@ -7,20 +7,73 @@ require "uri"
|
|
|
7
7
|
require_relative "authenticator"
|
|
8
8
|
|
|
9
9
|
module X
|
|
10
|
+
# Authenticator for OAuth 1.0a authentication
|
|
11
|
+
# @api public
|
|
10
12
|
class OAuthAuthenticator < Authenticator
|
|
13
|
+
# OAuth version
|
|
11
14
|
OAUTH_VERSION = "1.0".freeze
|
|
15
|
+
# OAuth signature method
|
|
12
16
|
OAUTH_SIGNATURE_METHOD = "HMAC-SHA1".freeze
|
|
17
|
+
# OAuth signature algorithm
|
|
13
18
|
OAUTH_SIGNATURE_ALGORITHM = "sha1".freeze
|
|
14
19
|
|
|
15
|
-
|
|
20
|
+
# The API key (consumer key)
|
|
21
|
+
# @api public
|
|
22
|
+
# @return [String] the API key (consumer key)
|
|
23
|
+
# @example Get or set the API key
|
|
24
|
+
# authenticator.api_key = "key"
|
|
25
|
+
attr_accessor :api_key
|
|
16
26
|
|
|
17
|
-
|
|
27
|
+
# The API key secret (consumer secret)
|
|
28
|
+
# @api public
|
|
29
|
+
# @return [String] the API key secret (consumer secret)
|
|
30
|
+
# @example Get or set the API key secret
|
|
31
|
+
# authenticator.api_key_secret = "secret"
|
|
32
|
+
attr_accessor :api_key_secret
|
|
33
|
+
|
|
34
|
+
# The access token
|
|
35
|
+
# @api public
|
|
36
|
+
# @return [String] the access token
|
|
37
|
+
# @example Get or set the access token
|
|
38
|
+
# authenticator.access_token = "token"
|
|
39
|
+
attr_accessor :access_token
|
|
40
|
+
|
|
41
|
+
# The access token secret
|
|
42
|
+
# @api public
|
|
43
|
+
# @return [String] the access token secret
|
|
44
|
+
# @example Get or set the access token secret
|
|
45
|
+
# authenticator.access_token_secret = "token_secret"
|
|
46
|
+
attr_accessor :access_token_secret
|
|
47
|
+
|
|
48
|
+
# Initialize a new OAuthAuthenticator
|
|
49
|
+
#
|
|
50
|
+
# @api public
|
|
51
|
+
# @param api_key [String] the API key (consumer key)
|
|
52
|
+
# @param api_key_secret [String] the API key secret (consumer secret)
|
|
53
|
+
# @param access_token [String] the access token
|
|
54
|
+
# @param access_token_secret [String] the access token secret
|
|
55
|
+
# @return [OAuthAuthenticator] a new instance
|
|
56
|
+
# @example Create an OAuth authenticator
|
|
57
|
+
# authenticator = X::OAuthAuthenticator.new(
|
|
58
|
+
# api_key: "key",
|
|
59
|
+
# api_key_secret: "secret",
|
|
60
|
+
# access_token: "token",
|
|
61
|
+
# access_token_secret: "token_secret"
|
|
62
|
+
# )
|
|
63
|
+
def initialize(api_key:, api_key_secret:, access_token:, access_token_secret:)
|
|
18
64
|
@api_key = api_key
|
|
19
65
|
@api_key_secret = api_key_secret
|
|
20
66
|
@access_token = access_token
|
|
21
67
|
@access_token_secret = access_token_secret
|
|
22
68
|
end
|
|
23
69
|
|
|
70
|
+
# Generate the OAuth authentication header for a request
|
|
71
|
+
#
|
|
72
|
+
# @api public
|
|
73
|
+
# @param request [Net::HTTPRequest] the HTTP request
|
|
74
|
+
# @return [Hash{String => String}] the authentication header with OAuth signature
|
|
75
|
+
# @example Generate an OAuth authentication header
|
|
76
|
+
# authenticator.header(request)
|
|
24
77
|
def header(request)
|
|
25
78
|
method, url, query_params = parse_request(request)
|
|
26
79
|
{AUTHENTICATION_HEADER => build_oauth_header(method, url, query_params)}
|
|
@@ -28,20 +81,38 @@ module X
|
|
|
28
81
|
|
|
29
82
|
private
|
|
30
83
|
|
|
84
|
+
# Parse the request to extract method, URL, and query parameters
|
|
85
|
+
# @api private
|
|
86
|
+
# @param request [Net::HTTPRequest] the HTTP request
|
|
87
|
+
# @return [Array<String, String, Hash>] the method, URL, and query parameters
|
|
31
88
|
def parse_request(request)
|
|
32
89
|
uri = request.uri
|
|
33
90
|
query_params = parse_query_params(uri.query.to_s)
|
|
34
91
|
[request.method, uri_without_query(uri), query_params]
|
|
35
92
|
end
|
|
36
93
|
|
|
94
|
+
# Parse query parameters from a query string
|
|
95
|
+
# @api private
|
|
96
|
+
# @param query_string [String] the query string
|
|
97
|
+
# @return [Hash] the parsed query parameters
|
|
37
98
|
def parse_query_params(query_string)
|
|
38
99
|
URI.decode_www_form(query_string).to_h
|
|
39
100
|
end
|
|
40
101
|
|
|
102
|
+
# Get the URI without query parameters
|
|
103
|
+
# @api private
|
|
104
|
+
# @param uri [URI] the URI
|
|
105
|
+
# @return [String] the URI without query parameters
|
|
41
106
|
def uri_without_query(uri)
|
|
42
107
|
"#{uri.scheme}://#{uri.host}#{uri.path}"
|
|
43
108
|
end
|
|
44
109
|
|
|
110
|
+
# Build the OAuth header value
|
|
111
|
+
# @api private
|
|
112
|
+
# @param method [String] the HTTP method
|
|
113
|
+
# @param url [String] the request URL
|
|
114
|
+
# @param query_params [Hash] the query parameters
|
|
115
|
+
# @return [String] the OAuth header value
|
|
45
116
|
def build_oauth_header(method, url, query_params)
|
|
46
117
|
oauth_params = default_oauth_params
|
|
47
118
|
all_params = query_params.merge(oauth_params)
|
|
@@ -49,6 +120,9 @@ module X
|
|
|
49
120
|
format_oauth_header(oauth_params)
|
|
50
121
|
end
|
|
51
122
|
|
|
123
|
+
# Get the default OAuth parameters
|
|
124
|
+
# @api private
|
|
125
|
+
# @return [Hash] the default OAuth parameters
|
|
52
126
|
def default_oauth_params
|
|
53
127
|
{
|
|
54
128
|
"oauth_consumer_key" => api_key,
|
|
@@ -60,24 +134,47 @@ module X
|
|
|
60
134
|
}
|
|
61
135
|
end
|
|
62
136
|
|
|
137
|
+
# Generate the OAuth signature
|
|
138
|
+
# @api private
|
|
139
|
+
# @param method [String] the HTTP method
|
|
140
|
+
# @param url [String] the request URL
|
|
141
|
+
# @param params [Hash] the combined parameters
|
|
142
|
+
# @return [String] the OAuth signature
|
|
63
143
|
def generate_signature(method, url, params)
|
|
64
144
|
base_string = signature_base_string(method, url, params)
|
|
65
145
|
hmac_signature(base_string)
|
|
66
146
|
end
|
|
67
147
|
|
|
148
|
+
# Generate the HMAC signature
|
|
149
|
+
# @api private
|
|
150
|
+
# @param base_string [String] the signature base string
|
|
151
|
+
# @return [String] the Base64-encoded HMAC signature
|
|
68
152
|
def hmac_signature(base_string)
|
|
69
153
|
hmac = OpenSSL::HMAC.digest(OAUTH_SIGNATURE_ALGORITHM, signing_key, base_string)
|
|
70
154
|
Base64.strict_encode64(hmac)
|
|
71
155
|
end
|
|
72
156
|
|
|
157
|
+
# Build the signature base string
|
|
158
|
+
# @api private
|
|
159
|
+
# @param method [String] the HTTP method
|
|
160
|
+
# @param url [String] the request URL
|
|
161
|
+
# @param params [Hash] the combined parameters
|
|
162
|
+
# @return [String] the signature base string
|
|
73
163
|
def signature_base_string(method, url, params)
|
|
74
164
|
"#{method}&#{CGI.escapeURIComponent(url)}&#{CGI.escapeURIComponent(URI.encode_www_form(params.sort).gsub("+", "%20"))}"
|
|
75
165
|
end
|
|
76
166
|
|
|
167
|
+
# Get the signing key
|
|
168
|
+
# @api private
|
|
169
|
+
# @return [String] the signing key
|
|
77
170
|
def signing_key
|
|
78
171
|
"#{api_key_secret}&#{access_token_secret}"
|
|
79
172
|
end
|
|
80
173
|
|
|
174
|
+
# Format the OAuth header value
|
|
175
|
+
# @api private
|
|
176
|
+
# @param params [Hash] the OAuth parameters
|
|
177
|
+
# @return [String] the formatted OAuth header value
|
|
81
178
|
def format_oauth_header(params)
|
|
82
179
|
"OAuth #{params.sort.map { |k, v| "#{k}=\"#{CGI.escapeURIComponent(v)}\"" }.join(", ")}"
|
|
83
180
|
end
|
data/lib/x/rate_limit.rb
CHANGED
|
@@ -1,33 +1,89 @@
|
|
|
1
1
|
module X
|
|
2
|
+
# Represents rate limit information from an API response
|
|
3
|
+
# @api public
|
|
2
4
|
class RateLimit
|
|
5
|
+
# Rate limit type identifier
|
|
3
6
|
RATE_LIMIT_TYPE = "rate-limit".freeze
|
|
7
|
+
# App limit type identifier
|
|
4
8
|
APP_LIMIT_TYPE = "app-limit-24hour".freeze
|
|
9
|
+
# User limit type identifier
|
|
5
10
|
USER_LIMIT_TYPE = "user-limit-24hour".freeze
|
|
11
|
+
# All supported rate limit types
|
|
6
12
|
TYPES = [RATE_LIMIT_TYPE, APP_LIMIT_TYPE, USER_LIMIT_TYPE].freeze
|
|
7
13
|
|
|
8
|
-
|
|
14
|
+
# The type of rate limit
|
|
15
|
+
# @api public
|
|
16
|
+
# @return [String] the type of rate limit
|
|
17
|
+
# @example Get or set the rate limit type
|
|
18
|
+
# rate_limit.type = "rate-limit"
|
|
19
|
+
attr_accessor :type
|
|
9
20
|
|
|
21
|
+
# The HTTP response containing rate limit headers
|
|
22
|
+
# @api public
|
|
23
|
+
# @return [Net::HTTPResponse] the HTTP response containing rate limit headers
|
|
24
|
+
# @example Get or set the response
|
|
25
|
+
# rate_limit.response = http_response
|
|
26
|
+
attr_accessor :response
|
|
27
|
+
|
|
28
|
+
# Initialize a new RateLimit
|
|
29
|
+
#
|
|
30
|
+
# @api public
|
|
31
|
+
# @param type [String] the type of rate limit
|
|
32
|
+
# @param response [Net::HTTPResponse] the HTTP response containing rate limit headers
|
|
33
|
+
# @return [RateLimit] a new instance
|
|
34
|
+
# @example Create a rate limit instance
|
|
35
|
+
# rate_limit = X::RateLimit.new(type: "rate-limit", response: response)
|
|
10
36
|
def initialize(type:, response:)
|
|
11
37
|
@type = type
|
|
12
38
|
@response = response
|
|
13
39
|
end
|
|
14
40
|
|
|
41
|
+
# Get the rate limit maximum
|
|
42
|
+
#
|
|
43
|
+
# @api public
|
|
44
|
+
# @return [Integer] the maximum number of requests allowed
|
|
45
|
+
# @example Get the rate limit
|
|
46
|
+
# rate_limit.limit
|
|
15
47
|
def limit
|
|
16
48
|
Integer(response.fetch("x-#{type}-limit"))
|
|
17
49
|
end
|
|
18
50
|
|
|
51
|
+
# Get the remaining requests
|
|
52
|
+
#
|
|
53
|
+
# @api public
|
|
54
|
+
# @return [Integer] the number of requests remaining
|
|
55
|
+
# @example Get the remaining requests
|
|
56
|
+
# rate_limit.remaining
|
|
19
57
|
def remaining
|
|
20
58
|
Integer(response.fetch("x-#{type}-remaining"))
|
|
21
59
|
end
|
|
22
60
|
|
|
61
|
+
# Get the time when the rate limit resets
|
|
62
|
+
#
|
|
63
|
+
# @api public
|
|
64
|
+
# @return [Time] the time when the rate limit resets
|
|
65
|
+
# @example Get the reset time
|
|
66
|
+
# rate_limit.reset_at
|
|
23
67
|
def reset_at
|
|
24
68
|
Time.at(Integer(response.fetch("x-#{type}-reset")))
|
|
25
69
|
end
|
|
26
70
|
|
|
71
|
+
# Get the seconds until the rate limit resets
|
|
72
|
+
#
|
|
73
|
+
# @api public
|
|
74
|
+
# @return [Integer] the seconds until the rate limit resets
|
|
75
|
+
# @example Get the reset time in seconds
|
|
76
|
+
# rate_limit.reset_in
|
|
27
77
|
def reset_in
|
|
28
78
|
[(reset_at - Time.now).ceil, 0].max
|
|
29
79
|
end
|
|
30
80
|
|
|
81
|
+
# @!method retry_after
|
|
82
|
+
# Alias for reset_in, returns the seconds until the rate limit resets
|
|
83
|
+
# @api public
|
|
84
|
+
# @return [Integer] the seconds until the rate limit resets
|
|
85
|
+
# @example Get the retry after time
|
|
86
|
+
# rate_limit.retry_after
|
|
31
87
|
alias_method :retry_after, :reset_in
|
|
32
88
|
end
|
|
33
89
|
end
|