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.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +6 -0
  3. data/README.md +4 -4
  4. data/lib/x/account_uploader.rb +168 -0
  5. data/lib/x/authenticator.rb +12 -0
  6. data/lib/x/bearer_token_authenticator.rb +22 -1
  7. data/lib/x/client.rb +95 -57
  8. data/lib/x/client_credentials.rb +208 -0
  9. data/lib/x/connection.rb +88 -2
  10. data/lib/x/errors/bad_gateway.rb +1 -0
  11. data/lib/x/errors/bad_request.rb +1 -0
  12. data/lib/x/errors/client_error.rb +1 -0
  13. data/lib/x/errors/connection_exception.rb +1 -0
  14. data/lib/x/errors/error.rb +1 -0
  15. data/lib/x/errors/forbidden.rb +1 -0
  16. data/lib/x/errors/gateway_timeout.rb +1 -0
  17. data/lib/x/errors/gone.rb +1 -0
  18. data/lib/x/errors/http_error.rb +47 -4
  19. data/lib/x/errors/internal_server_error.rb +1 -0
  20. data/lib/x/errors/invalid_media_type.rb +6 -0
  21. data/lib/x/errors/network_error.rb +1 -0
  22. data/lib/x/errors/not_acceptable.rb +1 -0
  23. data/lib/x/errors/not_found.rb +1 -0
  24. data/lib/x/errors/payload_too_large.rb +1 -0
  25. data/lib/x/errors/server_error.rb +1 -0
  26. data/lib/x/errors/service_unavailable.rb +1 -0
  27. data/lib/x/errors/too_many_redirects.rb +1 -0
  28. data/lib/x/errors/too_many_requests.rb +32 -0
  29. data/lib/x/errors/unauthorized.rb +1 -0
  30. data/lib/x/errors/unprocessable_entity.rb +1 -0
  31. data/lib/x/media_upload_validator.rb +19 -0
  32. data/lib/x/media_uploader.rb +117 -5
  33. data/lib/x/oauth2_authenticator.rb +169 -0
  34. data/lib/x/oauth_authenticator.rb +99 -2
  35. data/lib/x/rate_limit.rb +57 -1
  36. data/lib/x/redirect_handler.rb +55 -1
  37. data/lib/x/request_builder.rb +36 -0
  38. data/lib/x/response_parser.rb +21 -0
  39. data/lib/x/version.rb +2 -1
  40. data/sig/x.rbs +78 -17
  41. metadata +6 -2
@@ -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
- DEFAULT_MIME_TYPE = "application/octet-stream".freeze
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
- private
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 MIME_TYPE_MAP.fetch(File.extname(file_path).delete(".").downcase, DEFAULT_MIME_TYPE)
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: #{DEFAULT_MIME_TYPE}\r\n\r\n" \
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
- attr_accessor :api_key, :api_key_secret, :access_token, :access_token_secret
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
- def initialize(api_key:, api_key_secret:, access_token:, access_token_secret:) # rubocop:disable Lint/MissingSuper
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
- attr_accessor :type, :response
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