x 0.9.1 → 0.11.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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +43 -27
  3. data/README.md +6 -2
  4. data/lib/x/authenticator.rb +8 -0
  5. data/lib/x/bearer_token_authenticator.rb +5 -3
  6. data/lib/x/cgi.rb +15 -0
  7. data/lib/x/client.rb +40 -37
  8. data/lib/x/connection.rb +42 -65
  9. data/lib/x/errors/bad_gateway.rb +5 -0
  10. data/lib/x/errors/{not_found_error.rb → bad_request.rb} +1 -1
  11. data/lib/x/errors/connection_exception.rb +5 -0
  12. data/lib/x/errors/error.rb +1 -9
  13. data/lib/x/errors/{forbidden_error.rb → forbidden.rb} +1 -1
  14. data/lib/x/errors/gateway_timeout.rb +5 -0
  15. data/lib/x/errors/{bad_request_error.rb → gone.rb} +1 -1
  16. data/lib/x/errors/internal_server_error.rb +5 -0
  17. data/lib/x/errors/network_error.rb +1 -1
  18. data/lib/x/errors/not_acceptable.rb +5 -0
  19. data/lib/x/errors/not_found.rb +5 -0
  20. data/lib/x/errors/payload_too_large.rb +5 -0
  21. data/lib/x/errors/service_unavailable.rb +5 -0
  22. data/lib/x/errors/too_many_redirects.rb +5 -0
  23. data/lib/x/errors/too_many_requests.rb +29 -0
  24. data/lib/x/errors/unauthorized.rb +5 -0
  25. data/lib/x/errors/unprocessable_entity.rb +5 -0
  26. data/lib/x/media_uploader.rb +122 -0
  27. data/lib/x/oauth_authenticator.rb +10 -15
  28. data/lib/x/redirect_handler.rb +26 -24
  29. data/lib/x/request_builder.rb +22 -28
  30. data/lib/x/response_parser.rb +92 -0
  31. data/lib/x/version.rb +1 -1
  32. data/sig/x.rbs +160 -71
  33. metadata +22 -11
  34. data/lib/x/errors/authentication_error.rb +0 -5
  35. data/lib/x/errors/service_unavailable_error.rb +0 -5
  36. data/lib/x/errors/too_many_redirects_error.rb +0 -5
  37. data/lib/x/errors/too_many_requests_error.rb +0 -29
  38. data/lib/x/response_handler.rb +0 -54
@@ -0,0 +1,122 @@
1
+ require "securerandom"
2
+
3
+ module X
4
+ # Helper module for uploading images and videos
5
+ module MediaUploader
6
+ extend self
7
+
8
+ MAX_RETRIES = 3
9
+ BYTES_PER_MB = 1_048_576
10
+ MEDIA_CATEGORIES = %w[dm_gif dm_image dm_video subtitles tweet_gif tweet_image tweet_video].freeze
11
+ DM_GIF, DM_IMAGE, DM_VIDEO, SUBTITLES, TWEET_GIF, TWEET_IMAGE, TWEET_VIDEO = MEDIA_CATEGORIES
12
+ DEFAULT_MIME_TYPE = "application/octet-stream".freeze
13
+ MIME_TYPES = %w[image/gif image/jpeg video/mp4 image/png application/x-subrip image/webp].freeze
14
+ GIF_MIME_TYPE, JPEG_MIME_TYPE, MP4_MIME_TYPE, PNG_MIME_TYPE, SUBRIP_MIME_TYPE, WEBP_MIME_TYPE = MIME_TYPES
15
+ MIME_TYPE_MAP = {"gif" => GIF_MIME_TYPE, "jpg" => JPEG_MIME_TYPE, "jpeg" => JPEG_MIME_TYPE, "mp4" => MP4_MIME_TYPE,
16
+ "png" => PNG_MIME_TYPE, "srt" => SUBRIP_MIME_TYPE, "webp" => WEBP_MIME_TYPE}.freeze
17
+
18
+ def upload(client:, file_path:, media_category:, media_type: infer_media_type(file_path, media_category),
19
+ boundary: SecureRandom.hex)
20
+ validate!(file_path: file_path, media_category: media_category)
21
+ upload_client = client.dup.tap { |c| c.base_url = "https://upload.twitter.com/1.1/" }
22
+ upload_body = construct_upload_body(file_path, media_type, boundary)
23
+ headers = {"Content-Type" => "multipart/form-data, boundary=#{boundary}"}
24
+ upload_client.post("media/upload.json?media_category=#{media_category}", upload_body, headers: headers)
25
+ end
26
+
27
+ def chunked_upload(client:, file_path:, media_category:, media_type: infer_media_type(file_path,
28
+ media_category), boundary: SecureRandom.hex, chunk_size_mb: 8)
29
+ validate!(file_path: file_path, media_category: media_category)
30
+ upload_client = client.dup.tap { |c| c.base_url = "https://upload.twitter.com/1.1/" }
31
+ media = init(upload_client, file_path, media_type, media_category)
32
+ chunk_size = chunk_size_mb * BYTES_PER_MB
33
+ chunk_paths = split(file_path, chunk_size)
34
+ append(upload_client, chunk_paths, media, media_type, boundary)
35
+ upload_client.post("media/upload.json?command=FINALIZE&media_id=#{media["media_id"]}")
36
+ end
37
+
38
+ def await_processing(client:, media:)
39
+ upload_client = client.dup.tap { |c| c.base_url = "https://upload.twitter.com/1.1/" }
40
+ loop do
41
+ status = upload_client.get("media/upload.json?command=STATUS&media_id=#{media["media_id"]}")
42
+ return status if status["processing_info"]["state"] == "succeeded"
43
+
44
+ sleep status["processing_info"]["check_after_secs"].to_i
45
+ end
46
+ end
47
+
48
+ private
49
+
50
+ def validate!(file_path:, media_category:)
51
+ raise "File not found: #{file_path}" unless File.exist?(file_path)
52
+
53
+ return if MEDIA_CATEGORIES.include?(media_category.downcase)
54
+
55
+ raise ArgumentError, "Invalid media_category: #{media_category}. Valid values: #{MEDIA_CATEGORIES.join(", ")}"
56
+ end
57
+
58
+ def infer_media_type(file_path, media_category)
59
+ case media_category.downcase
60
+ when TWEET_GIF, DM_GIF then GIF_MIME_TYPE
61
+ when TWEET_VIDEO, DM_VIDEO then MP4_MIME_TYPE
62
+ when SUBTITLES then SUBRIP_MIME_TYPE
63
+ else MIME_TYPE_MAP[File.extname(file_path).delete(".").downcase] || DEFAULT_MIME_TYPE
64
+ end
65
+ end
66
+
67
+ def init(upload_client, file_path, media_type, media_category)
68
+ total_bytes = File.size(file_path)
69
+ query = "command=INIT&media_type=#{media_type}&media_category=#{media_category}&total_bytes=#{total_bytes}"
70
+ upload_client.post("media/upload.json?#{query}")
71
+ end
72
+
73
+ def split(file_path, chunk_size)
74
+ file_number = -1
75
+
76
+ [].tap do |chunk_paths|
77
+ File.open(file_path, "rb") do |f|
78
+ while (chunk = f.read(chunk_size))
79
+ chunk_paths << "#{Dir.mktmpdir}/x#{format("%03d", file_number += 1)}".tap do |path|
80
+ File.write(path, chunk)
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
86
+
87
+ def append(upload_client, chunk_paths, media, media_type, boundary = SecureRandom.hex)
88
+ threads = chunk_paths.map.with_index do |chunk_path, index|
89
+ Thread.new do
90
+ upload_body = construct_upload_body(chunk_path, media_type, boundary)
91
+ query = "command=APPEND&media_id=#{media["media_id"]}&segment_index=#{index}"
92
+ headers = {"Content-Type" => "multipart/form-data, boundary=#{boundary}"}
93
+ upload_chunk(upload_client, query, upload_body, chunk_path, headers)
94
+ end
95
+ end
96
+ threads.each(&:join)
97
+ end
98
+
99
+ def upload_chunk(upload_client, query, upload_body, chunk_path, headers = {})
100
+ upload_client.post("media/upload.json?#{query}", upload_body, headers: headers)
101
+ rescue NetworkError, ServerError
102
+ retries ||= 0
103
+ ((retries += 1) < MAX_RETRIES) ? retry : raise
104
+ ensure
105
+ cleanup_chunk(chunk_path)
106
+ end
107
+
108
+ def cleanup_chunk(chunk_path)
109
+ dirname = File.dirname(chunk_path)
110
+ File.delete(chunk_path)
111
+ Dir.delete(dirname) if Dir.empty?(dirname)
112
+ end
113
+
114
+ def construct_upload_body(file_path, media_type, boundary = SecureRandom.hex)
115
+ "--#{boundary}\r\n" \
116
+ "Content-Disposition: form-data; name=\"media\"; filename=\"#{File.basename(file_path)}\"\r\n" \
117
+ "Content-Type: #{media_type}\r\n\r\n" \
118
+ "#{File.read(file_path)}\r\n" \
119
+ "--#{boundary}--\r\n"
120
+ end
121
+ end
122
+ end
@@ -1,20 +1,21 @@
1
1
  require "base64"
2
- require "cgi"
3
2
  require "json"
4
3
  require "openssl"
5
4
  require "securerandom"
6
5
  require "uri"
6
+ require_relative "authenticator"
7
+ require_relative "cgi"
7
8
 
8
9
  module X
9
10
  # Handles OAuth authentication
10
- class OauthAuthenticator
11
+ class OAuthAuthenticator < Authenticator
11
12
  OAUTH_VERSION = "1.0".freeze
12
13
  OAUTH_SIGNATURE_METHOD = "HMAC-SHA1".freeze
13
14
  OAUTH_SIGNATURE_ALGORITHM = "sha1".freeze
14
15
 
15
16
  attr_accessor :api_key, :api_key_secret, :access_token, :access_token_secret
16
17
 
17
- def initialize(api_key, api_key_secret, access_token, access_token_secret)
18
+ def initialize(api_key:, api_key_secret:, access_token:, access_token_secret:) # rubocop:disable Lint/MissingSuper
18
19
  @api_key = api_key
19
20
  @api_key_secret = api_key_secret
20
21
  @access_token = access_token
@@ -23,7 +24,7 @@ module X
23
24
 
24
25
  def header(request)
25
26
  method, url, query_params = parse_request(request)
26
- build_oauth_header(method, url, query_params)
27
+ {"Authorization" => build_oauth_header(method, url, query_params)}
27
28
  end
28
29
 
29
30
  private
@@ -54,7 +55,7 @@ module X
54
55
  "oauth_consumer_key" => api_key,
55
56
  "oauth_nonce" => SecureRandom.hex,
56
57
  "oauth_signature_method" => OAUTH_SIGNATURE_METHOD,
57
- "oauth_timestamp" => Time.now.utc.to_i.to_s,
58
+ "oauth_timestamp" => Integer(Time.now).to_s,
58
59
  "oauth_token" => access_token,
59
60
  "oauth_version" => OAUTH_VERSION
60
61
  }
@@ -66,26 +67,20 @@ module X
66
67
  end
67
68
 
68
69
  def hmac_signature(base_string)
69
- digest = OpenSSL::Digest.new(OAUTH_SIGNATURE_ALGORITHM)
70
- hmac = OpenSSL::HMAC.digest(digest, signing_key, base_string)
70
+ hmac = OpenSSL::HMAC.digest(OAUTH_SIGNATURE_ALGORITHM, signing_key, base_string)
71
71
  Base64.strict_encode64(hmac)
72
72
  end
73
73
 
74
74
  def signature_base_string(method, url, params)
75
- "#{method}&#{escape(url)}&#{escape(URI.encode_www_form(params.sort))}"
75
+ "#{method}&#{CGI.escape(url)}&#{CGI.escape(CGI.escape_params(params.sort))}"
76
76
  end
77
77
 
78
78
  def signing_key
79
- "#{escape(api_key_secret)}&#{escape(access_token_secret)}"
79
+ "#{api_key_secret}&#{access_token_secret}"
80
80
  end
81
81
 
82
82
  def format_oauth_header(params)
83
- "OAuth #{params.sort.map { |k, v| "#{k}=\"#{escape(v.to_s)}\"" }.join(", ")}"
84
- end
85
-
86
- def escape(value)
87
- # TODO: Replace CGI.escape with CGI.escapeURIComponent when support for Ruby 3.1 is dropped
88
- CGI.escape(value.to_s).gsub("+", "%20")
83
+ "OAuth #{params.sort.map { |k, v| "#{k}=\"#{CGI.escape(v)}\"" }.join(", ")}"
89
84
  end
90
85
  end
91
86
  end
@@ -1,31 +1,36 @@
1
1
  require "net/http"
2
2
  require "uri"
3
+ require_relative "authenticator"
3
4
  require_relative "connection"
4
- require_relative "errors/too_many_redirects_error"
5
+ require_relative "errors/too_many_redirects"
6
+ require_relative "request_builder"
5
7
 
6
8
  module X
7
9
  # Handles HTTP redirects
8
10
  class RedirectHandler
9
11
  DEFAULT_MAX_REDIRECTS = 10
10
12
 
11
- attr_reader :authenticator, :connection, :request_builder, :max_redirects
13
+ attr_accessor :max_redirects
14
+ attr_reader :authenticator, :connection, :request_builder
12
15
 
13
- def initialize(authenticator, connection, request_builder, max_redirects: DEFAULT_MAX_REDIRECTS)
16
+ def initialize(authenticator: Authenticator.new, connection: Connection.new, request_builder: RequestBuilder.new,
17
+ max_redirects: DEFAULT_MAX_REDIRECTS)
14
18
  @authenticator = authenticator
15
19
  @connection = connection
16
20
  @request_builder = request_builder
17
21
  @max_redirects = max_redirects
18
22
  end
19
23
 
20
- def handle_redirects(response, original_request, original_base_url, redirect_count = 0)
24
+ def handle(response:, request:, base_url:, redirect_count: 0)
21
25
  if response.is_a?(Net::HTTPRedirection)
22
- raise TooManyRedirectsError.new("Too many redirects", response: response) if redirect_count >= max_redirects
26
+ raise TooManyRedirects, "Too many redirects" if redirect_count > max_redirects
23
27
 
24
- new_uri = build_new_uri(response, original_base_url)
25
- new_request = build_request(original_request, new_uri)
26
- new_response = send_new_request(new_uri, new_request)
28
+ new_uri = build_new_uri(response, base_url)
27
29
 
28
- handle_redirects(new_response, new_request, original_base_url, redirect_count + 1)
30
+ new_request = build_request(request, new_uri, Integer(response.code))
31
+ new_response = connection.perform(request: new_request)
32
+
33
+ handle(response: new_response, request: new_request, base_url: base_url, redirect_count: redirect_count + 1)
29
34
  else
30
35
  response
31
36
  end
@@ -33,24 +38,21 @@ module X
33
38
 
34
39
  private
35
40
 
36
- def build_new_uri(response, original_base_url)
37
- location = response["location"].to_s
38
- new_uri = URI.parse(location)
39
- new_uri = URI.join(original_base_url.to_s, location) if new_uri.relative?
40
- new_uri
41
+ def build_new_uri(response, base_url)
42
+ location = response.fetch("location")
43
+ # If location is relative, it will join with the original base URL, otherwise it will overwrite it
44
+ URI.join(base_url, location)
41
45
  end
42
46
 
43
- def build_request(original_request, new_uri)
44
- http_method = original_request.method.downcase.to_sym
45
- body = original_request.body if original_request.body
46
- request_builder.build(authenticator, http_method, new_uri, body: body)
47
- end
47
+ def build_request(request, new_uri, response_code)
48
+ http_method, body = case response_code
49
+ in 307 | 308
50
+ [request.method.downcase.to_sym, request.body]
51
+ else
52
+ [:get, nil]
53
+ end
48
54
 
49
- def send_new_request(new_uri, new_request)
50
- @connection = Connection.new(base_url: new_uri, open_timeout: connection.open_timeout,
51
- read_timeout: connection.read_timeout, write_timeout: connection.write_timeout,
52
- debug_output: connection.debug_output, proxy_url: connection.proxy_uri)
53
- connection.send_request(new_request)
55
+ request_builder.build(http_method: http_method, uri: new_uri, body: body, authenticator: authenticator)
54
56
  end
55
57
  end
56
58
  end
@@ -1,64 +1,58 @@
1
1
  require "net/http"
2
2
  require "uri"
3
+ require_relative "authenticator"
4
+ require_relative "cgi"
3
5
  require_relative "version"
4
6
 
5
7
  module X
6
8
  # Creates HTTP requests
7
9
  class RequestBuilder
10
+ DEFAULT_HEADERS = {
11
+ "Content-Type" => "application/json; charset=utf-8",
12
+ "User-Agent" => "X-Client/#{VERSION} #{RUBY_ENGINE}/#{RUBY_VERSION} (#{RUBY_PLATFORM})"
13
+ }.freeze
8
14
  HTTP_METHODS = {
9
15
  get: Net::HTTP::Get,
10
16
  post: Net::HTTP::Post,
11
17
  put: Net::HTTP::Put,
12
18
  delete: Net::HTTP::Delete
13
19
  }.freeze
14
- DEFAULT_CONTENT_TYPE = "application/json; charset=utf-8".freeze
15
- DEFAULT_USER_AGENT = "X-Client/#{VERSION} #{RUBY_ENGINE}/#{RUBY_VERSION} (#{RUBY_PLATFORM})".freeze
16
- AUTHORIZATION_HEADER = "Authorization".freeze
17
- CONTENT_TYPE_HEADER = "Content-Type".freeze
18
- USER_AGENT_HEADER = "User-Agent".freeze
19
-
20
- attr_accessor :content_type, :user_agent
21
-
22
- def initialize(content_type: DEFAULT_CONTENT_TYPE, user_agent: DEFAULT_USER_AGENT)
23
- @content_type = content_type
24
- @user_agent = user_agent
25
- end
26
20
 
27
- def build(authenticator, http_method, uri, body: nil)
28
- request = create_request(http_method, uri, body)
29
- add_authorization(request, authenticator)
30
- add_content_type(request)
31
- add_user_agent(request)
21
+ def build(http_method:, uri:, body: nil, headers: {}, authenticator: Authenticator.new)
22
+ request = create_request(http_method: http_method, uri: uri, body: body)
23
+ add_headers(request: request, headers: headers)
24
+ add_authentication(request: request, authenticator: authenticator)
32
25
  request
33
26
  end
34
27
 
35
28
  private
36
29
 
37
- def create_request(http_method, uri, body)
30
+ def create_request(http_method:, uri:, body:)
38
31
  http_method_class = HTTP_METHODS[http_method]
39
32
 
40
33
  raise ArgumentError, "Unsupported HTTP method: #{http_method}" unless http_method_class
41
34
 
42
35
  escaped_uri = escape_query_params(uri)
43
36
  request = http_method_class.new(escaped_uri)
44
- request.body = body if body && http_method != :get
37
+ request.body = body
45
38
  request
46
39
  end
47
40
 
48
- def add_authorization(request, authenticator)
49
- request.add_field(AUTHORIZATION_HEADER, authenticator.header(request))
50
- end
51
-
52
- def add_content_type(request)
53
- request.add_field(CONTENT_TYPE_HEADER, content_type) if content_type
41
+ def add_authentication(request:, authenticator:)
42
+ authenticator.header(request).each do |key, value|
43
+ request.add_field(key, value)
44
+ end
54
45
  end
55
46
 
56
- def add_user_agent(request)
57
- request.add_field(USER_AGENT_HEADER, user_agent) if user_agent
47
+ def add_headers(request:, headers:)
48
+ DEFAULT_HEADERS.merge(headers).each do |key, value|
49
+ request.delete(key)
50
+ request.add_field(key, value)
51
+ end
58
52
  end
59
53
 
60
54
  def escape_query_params(uri)
61
- URI(uri).tap { |u| u.query = URI.encode_www_form(URI.decode_www_form(uri.query)) if uri.query }
55
+ URI(uri).tap { |u| u.query = CGI.escape_params(URI.decode_www_form(u.query)) if u.query }
62
56
  end
63
57
  end
64
58
  end
@@ -0,0 +1,92 @@
1
+ require "json"
2
+ require "net/http"
3
+ require_relative "errors/bad_gateway"
4
+ require_relative "errors/bad_request"
5
+ require_relative "errors/connection_exception"
6
+ require_relative "errors/error"
7
+ require_relative "errors/forbidden"
8
+ require_relative "errors/gateway_timeout"
9
+ require_relative "errors/gone"
10
+ require_relative "errors/internal_server_error"
11
+ require_relative "errors/not_acceptable"
12
+ require_relative "errors/not_found"
13
+ require_relative "errors/payload_too_large"
14
+ require_relative "errors/service_unavailable"
15
+ require_relative "errors/too_many_requests"
16
+ require_relative "errors/unauthorized"
17
+ require_relative "errors/unprocessable_entity"
18
+
19
+ module X
20
+ # Process HTTP responses
21
+ class ResponseParser
22
+ ERROR_CLASSES = {
23
+ 400 => BadRequest,
24
+ 401 => Unauthorized,
25
+ 403 => Forbidden,
26
+ 404 => NotFound,
27
+ 406 => NotAcceptable,
28
+ 409 => ConnectionException,
29
+ 410 => Gone,
30
+ 413 => PayloadTooLarge,
31
+ 422 => UnprocessableEntity,
32
+ 429 => TooManyRequests,
33
+ 500 => InternalServerError,
34
+ 502 => BadGateway,
35
+ 503 => ServiceUnavailable,
36
+ 504 => GatewayTimeout
37
+ }.freeze
38
+ JSON_CONTENT_TYPE_REGEXP = %r{application/(problem\+|)json}
39
+
40
+ attr_accessor :array_class, :object_class
41
+
42
+ def initialize(array_class: nil, object_class: nil)
43
+ @array_class = array_class
44
+ @object_class = object_class
45
+ end
46
+
47
+ def parse(response:)
48
+ raise error(response) unless success?(response)
49
+
50
+ JSON.parse(response.body, array_class: array_class, object_class: object_class) if json?(response)
51
+ end
52
+
53
+ private
54
+
55
+ def success?(response)
56
+ response.is_a?(Net::HTTPSuccess)
57
+ end
58
+
59
+ def error(response)
60
+ error_class(response).new(error_message(response), response)
61
+ end
62
+
63
+ def error_class(response)
64
+ ERROR_CLASSES[Integer(response.code)] || Error
65
+ end
66
+
67
+ def error_message(response)
68
+ if json?(response)
69
+ message_from_json_response(response)
70
+ else
71
+ response.message
72
+ end
73
+ end
74
+
75
+ def message_from_json_response(response)
76
+ response_object = JSON.parse(response.body)
77
+ if response_object.key?("title") && response_object.key?("detail")
78
+ "#{response_object.fetch("title")}: #{response_object.fetch("detail")}"
79
+ elsif response_object.key?("error")
80
+ response_object.fetch("error")
81
+ elsif response_object["errors"].instance_of?(Array)
82
+ response_object.fetch("errors").map { |error| error.fetch("message") }.join(", ")
83
+ else
84
+ response.message
85
+ end
86
+ end
87
+
88
+ def json?(response)
89
+ response.body && JSON_CONTENT_TYPE_REGEXP.match?(response["content-type"])
90
+ end
91
+ end
92
+ end
data/lib/x/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  require "rubygems/version"
2
2
 
3
3
  module X
4
- VERSION = Gem::Version.create("0.9.1")
4
+ VERSION = Gem::Version.create("0.11.0")
5
5
  end