x 0.10.0 → 0.11.0

Sign up to get free protection for your applications and to get access to all the features.
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