x 0.10.0 → 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 +41 -28
  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 -40
  8. data/lib/x/connection.rb +39 -76
  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 -6
  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/network_error.rb +1 -1
  17. data/lib/x/errors/not_acceptable.rb +5 -0
  18. data/lib/x/errors/not_found.rb +5 -0
  19. data/lib/x/errors/payload_too_large.rb +5 -0
  20. data/lib/x/errors/service_unavailable.rb +5 -0
  21. data/lib/x/errors/too_many_redirects.rb +5 -0
  22. data/lib/x/errors/too_many_requests.rb +29 -0
  23. data/lib/x/errors/unauthorized.rb +5 -0
  24. data/lib/x/errors/unprocessable_entity.rb +5 -0
  25. data/lib/x/{media_upload.rb → media_uploader.rb} +12 -15
  26. data/lib/x/oauth_authenticator.rb +10 -15
  27. data/lib/x/redirect_handler.rb +26 -22
  28. data/lib/x/request_builder.rb +22 -35
  29. data/lib/x/response_parser.rb +92 -0
  30. data/lib/x/version.rb +1 -1
  31. data/sig/x.rbs +101 -87
  32. metadata +21 -13
  33. data/lib/x/errors/authentication_error.rb +0 -5
  34. data/lib/x/errors/payload_too_large_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 -63
@@ -2,7 +2,7 @@ require "securerandom"
2
2
 
3
3
  module X
4
4
  # Helper module for uploading images and videos
5
- module MediaUpload
5
+ module MediaUploader
6
6
  extend self
7
7
 
8
8
  MAX_RETRIES = 3
@@ -15,19 +15,19 @@ module X
15
15
  MIME_TYPE_MAP = {"gif" => GIF_MIME_TYPE, "jpg" => JPEG_MIME_TYPE, "jpeg" => JPEG_MIME_TYPE, "mp4" => MP4_MIME_TYPE,
16
16
  "png" => PNG_MIME_TYPE, "srt" => SUBRIP_MIME_TYPE, "webp" => WEBP_MIME_TYPE}.freeze
17
17
 
18
- def media_upload(client:, file_path:, media_category:, media_type: infer_media_type(file_path, media_category),
18
+ def upload(client:, file_path:, media_category:, media_type: infer_media_type(file_path, media_category),
19
19
  boundary: SecureRandom.hex)
20
20
  validate!(file_path: file_path, media_category: media_category)
21
- upload_client = client.dup.tap { |c| c.base_uri = "https://upload.twitter.com/1.1/" }
21
+ upload_client = client.dup.tap { |c| c.base_url = "https://upload.twitter.com/1.1/" }
22
22
  upload_body = construct_upload_body(file_path, media_type, boundary)
23
- upload_client.content_type = "multipart/form-data, boundary=#{boundary}"
24
- upload_client.post("media/upload.json?media_category=#{media_category}", upload_body)
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
25
  end
26
26
 
27
- def chunked_media_upload(client:, file_path:, media_category:, media_type: infer_media_type(file_path,
27
+ def chunked_upload(client:, file_path:, media_category:, media_type: infer_media_type(file_path,
28
28
  media_category), boundary: SecureRandom.hex, chunk_size_mb: 8)
29
29
  validate!(file_path: file_path, media_category: media_category)
30
- upload_client = client.dup.tap { |c| c.base_uri = "https://upload.twitter.com/1.1/" }
30
+ upload_client = client.dup.tap { |c| c.base_url = "https://upload.twitter.com/1.1/" }
31
31
  media = init(upload_client, file_path, media_type, media_category)
32
32
  chunk_size = chunk_size_mb * BYTES_PER_MB
33
33
  chunk_paths = split(file_path, chunk_size)
@@ -36,7 +36,7 @@ module X
36
36
  end
37
37
 
38
38
  def await_processing(client:, media:)
39
- upload_client = client.dup.tap { |c| c.base_uri = "https://upload.twitter.com/1.1/" }
39
+ upload_client = client.dup.tap { |c| c.base_url = "https://upload.twitter.com/1.1/" }
40
40
  loop do
41
41
  status = upload_client.get("media/upload.json?command=STATUS&media_id=#{media["media_id"]}")
42
42
  return status if status["processing_info"]["state"] == "succeeded"
@@ -89,18 +89,15 @@ module X
89
89
  Thread.new do
90
90
  upload_body = construct_upload_body(chunk_path, media_type, boundary)
91
91
  query = "command=APPEND&media_id=#{media["media_id"]}&segment_index=#{index}"
92
- upload_chunk(upload_client, query, upload_body, chunk_path, boundary)
92
+ headers = {"Content-Type" => "multipart/form-data, boundary=#{boundary}"}
93
+ upload_chunk(upload_client, query, upload_body, chunk_path, headers)
93
94
  end
94
95
  end
95
96
  threads.each(&:join)
96
97
  end
97
98
 
98
- def upload_chunk(upload_client, query, upload_body, chunk_path, boundary)
99
- # Initialize a new client to avoid shared connection issues
100
- client = upload_client.dup
101
- client.connection = Connection.new(**upload_client.connection.configuration.merge(base_url: "https://upload.twitter.com/1.1/"))
102
- client.content_type = "multipart/form-data, boundary=#{boundary}"
103
- client.post("media/upload.json?#{query}", upload_body)
99
+ def upload_chunk(upload_client, query, upload_body, chunk_path, headers = {})
100
+ upload_client.post("media/upload.json?#{query}", upload_body, headers: headers)
104
101
  rescue NetworkError, ServerError
105
102
  retries ||= 0
106
103
  ((retries += 1) < MAX_RETRIES) ? retry : raise
@@ -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,22 +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(**connection.configuration.merge(base_url: new_uri))
51
- connection.send_request(new_request)
55
+ request_builder.build(http_method: http_method, uri: new_uri, body: body, authenticator: authenticator)
52
56
  end
53
57
  end
54
58
  end
@@ -1,71 +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
- def configuration
36
- {
37
- content_type: content_type,
38
- user_agent: user_agent
39
- }
40
- end
41
-
42
28
  private
43
29
 
44
- def create_request(http_method, uri, body)
30
+ def create_request(http_method:, uri:, body:)
45
31
  http_method_class = HTTP_METHODS[http_method]
46
32
 
47
33
  raise ArgumentError, "Unsupported HTTP method: #{http_method}" unless http_method_class
48
34
 
49
35
  escaped_uri = escape_query_params(uri)
50
36
  request = http_method_class.new(escaped_uri)
51
- request.body = body if body && http_method != :get
37
+ request.body = body
52
38
  request
53
39
  end
54
40
 
55
- def add_authorization(request, authenticator)
56
- request.add_field(AUTHORIZATION_HEADER, authenticator.header(request))
57
- end
58
-
59
- def add_content_type(request)
60
- 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
61
45
  end
62
46
 
63
- def add_user_agent(request)
64
- 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
65
52
  end
66
53
 
67
54
  def escape_query_params(uri)
68
- 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 }
69
56
  end
70
57
  end
71
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.10.0")
4
+ VERSION = Gem::Version.create("0.11.0")
5
5
  end