Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Change media upload to use the API v2 endpoints #38

Merged
merged 9 commits into from
Jan 30, 2025
Merged

Conversation

GCorbel
Copy link
Contributor

@GCorbel GCorbel commented Jan 23, 2025

This fix #37

X announced the creation of the API v2 endpoints for media upload : https://devcommunity.x.com/t/announcing-media-upload-endpoints-in-the-x-api-v2/234175/3

It is very similar, so there are not a lot of changes. The biggest change is that it requires to use the authentication with OAuth 2.0 but once you have a user token, the client can be done with X::Client.new(bearer_token: user_token). This client works the same for all other endpoints.

Comment on lines 42 to 45
begin
JSON.parse(response.body, array_class:, object_class:)
rescue JSON::ParserError
end
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After the call of POST media/upload, the content-type is "application/octet-stream" despite the body is a valid json.

Here is an example of a response :

-> "HTTP/1.1 200 OK\r\n"
-> "date: Thu, 23 Jan 2025 17:19:10 GMT\r\n"
-> "perf: 7402827104\r\n"
-> "vary: Origin\r\n"
-> "pragma: no-cache\r\n"
-> "server: tsa_b\r\n"
-> "expires: Tue, 31 Mar 1981 05:00:00 GMT\r\n"
-> "set-cookie: guest_id_marketing=v1%3A173765274968771643; Max-Age=63072000; Expires=Sat, 23 Jan 2027 17:19:10 GMT; Path=/; Domain=.twitter.com; Secure; SameSite=None\r\n"
-> "set-cookie: guest_id_ads=v1%3A173765274968771643; Max-Age=63072000; Expires=Sat, 23 Jan 2027 17:19:10 GMT; Path=/; Domain=.twitter.com; Secure; SameSite=None\r\n"
-> "set-cookie: personalization_id=\"v1_6xr6bdpzJyLG+/nW6b3LqQ==\"; Max-Age=63072000; Expires=Sat, 23 Jan 2027 17:19:10 GMT; Path=/; Domain=.twitter.com; Secure; SameSite=None\r\n"
-> "set-cookie: lang=en; Path=/\r\n"
-> "set-cookie: guest_id=v1%3A173765274968771643; Max-Age=63072000; Expires=Sat, 23 Jan 2027 17:19:10 GMT; Path=/; Domain=.twitter.com; Secure; SameSite=None\r\n"
-> "api-version: 2.development\r\n"
-> "content-type: application/octet-stream\r\n"
-> "cache-control: no-cache, no-store, must-revalidate, pre-check=0, post-check=0\r\n"
-> "last-modified: Thu, 23 Jan 2025 17:19:10 GMT\r\n"
-> "x-transaction: 409457a465fdfcc5\r\n"
-> "content-length: 160\r\n"
-> "x-frame-options: SAMEORIGIN\r\n"
-> "x-transaction-id: 409457a465fdfcc5\r\n"
-> "x-xss-protection: 1; mode=block\r\n"
-> "x-rate-limit-limit: 1000\r\n"
-> "x-rate-limit-reset: 1737653013\r\n"
-> "x-content-type-options: nosniff\r\n"
-> "x-rate-limit-remaining: 992\r\n"
-> "x-twitter-response-tags: BouncerCompliant\r\n"
-> "strict-transport-security: max-age=631138519\r\n"
-> "x-response-time: 1142\r\n"
-> "x-connection-hash: 4d2a5453c0fcc53df1a7580c322112b153fb3731fd16b2367e9869e2472add55\r\n"
-> "connection: close\r\n"
-> "\r\n"
reading 160 bytes...
-> "{\"id\":\"1882478190238502912\",\"media_key\":\"3_1882478190238502912\",\"size\":753855,\"expires_after_secs\":86400,\"image\":{\"image_type\":\"image\\/jpeg\",\"w\":1980,\"h\":1114}}"

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rubocop complains about that because I remove an exception, but I have no better idea. Do you have one ?

x.gemspec Outdated
@@ -9,7 +9,7 @@ Gem::Specification.new do |spec|
spec.summary = "A Ruby interface to the X API."
spec.homepage = "https://sferik.github.io/x-ruby"
spec.license = "MIT"
spec.required_ruby_version = ">= 3.2"
# spec.required_ruby_version = ">= 3.2"
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's temporary but I need to comment this to be able to test ATM.

Is there a reason to force the version of Ruby ?

Copy link
Owner

@sferik sferik Jan 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Support for Ruby 3.1 is scheduled to end in two months, on March 31, 2025. After that point, running Ruby 3.1 in production will be insecure: any new vulnerabilities will be made public but won't be patched. Ruby 3.2 was released over two years ago, on December 25, 2022, and offers many features and performance improvements over Ruby 3.1. I'm interested to understand why you're waiting to upgrade.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We are still using Ruby 3.1 and want to upgrade. We are currently working on the upgrade.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The syntax changes between 3.1 and 3.2 are quite minor. I'm curious what's blocking your upgrade.

Copy link
Contributor Author

@GCorbel GCorbel Jan 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to upgrade our stack which is not all compatible with Ruby 3.2.

@GCorbel GCorbel marked this pull request as draft January 23, 2025 17:31
@GCorbel
Copy link
Contributor Author

GCorbel commented Jan 23, 2025

@sferik can you take a quick look? I will change specs and finish the implementation once you validated this draft.

@sferik
Copy link
Owner

sferik commented Jan 24, 2025

Thank you for this patch. It looks like you're on the right track! I will happily merge this once all the tests are passing.

end

def chunked_upload(client:, file_path:, media_category:, media_type: infer_media_type(file_path,
media_category), boundary: SecureRandom.hex, chunk_size_mb: 8)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

chunk_size = chunk_size_mb * BYTES_PER_MB
append(upload_client:, file_paths: split(file_path, chunk_size), media:, media_type:, boundary:)
upload_client.post("media/upload.json?command=FINALIZE&media_id=#{media["media_id"]}")
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sometimes, the API return a payload with data and sometimes it doesn't.

A call to "media/upload?media_category=tweet_image" returns :

{"id":"1884301761822838785","media_key":"3_1884301761822838785","size":20259,"expires_after_secs":86400,"image":{"image_type":"image\/jpeg","w":932,"h":176}}

But with a "command" attribute it gives :

{"data":{"id":"1884301283227561984","media_key":"7_1884301283227561984","size":9840497,"expires_after_secs":86399,"video":{"video_type":"video\/mp4"},"processing_info":"state":"succeeded","progress_percent":100}}}

A request with "command=APPEND" returns an empty body with "NoContent" in headers.

Comment on lines 42 to 45
begin
JSON.parse(response.body, array_class:, object_class:)
rescue JSON::ParserError
end
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rubocop complains about that because I remove an exception, but I have no better idea. Do you have one ?

@GCorbel GCorbel marked this pull request as ready for review January 28, 2025 18:40
@GCorbel GCorbel requested a review from sferik January 28, 2025 18:41
JSON.parse(response.body, array_class:, object_class:)
begin
JSON.parse(response.body, array_class:, object_class:)
rescue JSON::ParserError # rubocop:disable Lint/SuppressedException
Copy link
Owner

@sferik sferik Jan 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What are the cases you're expecting to rescue here? Do you expect the X API to return invalid JSON? Or just an empty string?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is related to the comment I did here : #38 (comment)

Previously, we relied on headers to parse JSON but now, there is a request made with "application/octet-stream" which contains a valid JSON. As before, nil should be returned when the body does not contain a JSON.

Copy link
Owner

@sferik sferik left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good overall! Please address my two comments/questions above.


def json?(response)
JSON_CONTENT_TYPE_REGEXP === response["content-type"]
end
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you're removing this method, you can remove the JSON_CONTENT_TYPE_REGEXP constant, since this is the only place it's used.

That said, the X::HTTPError class performs a similar check when parsing error JSON. We probably want to handle these cases in the same way.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will give a try tomorrow.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried something as :

diff --git a/lib/x/errors/http_error.rb b/lib/x/errors/http_error.rb
index 9f54aeb..ce0871d 100644
--- a/lib/x/errors/http_error.rb
+++ b/lib/x/errors/http_error.rb
@@ -3,8 +3,6 @@ require_relative "error"
 
 module X
   class HTTPError < Error
-    JSON_CONTENT_TYPE_REGEXP = %r{application/(problem\+|)json}
-
     attr_reader :response, :code
 
     def initialize(response:)
@@ -14,7 +12,7 @@ module X
     end
 
     def error_message(response)
-      if json?(response)
+      if X::ResponseParser.json?(response)
         message_from_json_response(response)
       else
         response.message
@@ -33,9 +31,5 @@ module X
         response.message
       end
     end
-
-    def json?(response)
-      JSON_CONTENT_TYPE_REGEXP === response["content-type"]
-    end
   end
 end
diff --git a/lib/x/response_parser.rb b/lib/x/response_parser.rb
index da5606d..fa55cb2 100644
--- a/lib/x/response_parser.rb
+++ b/lib/x/response_parser.rb
@@ -34,17 +34,20 @@ module X
       503 => ServiceUnavailable,
       504 => GatewayTimeout
     }.freeze
-    JSON_CONTENT_TYPE_REGEXP = %r{application/json}
+
+    def self.json?(response)
+      JSON.parse(response.body)
+      true
+    rescue JSON::ParserError, TypeError
+      false
+    end
 
     def parse(response:, array_class: nil, object_class: nil)
       raise error(response) unless response.is_a?(Net::HTTPSuccess)
 
-      return if response.instance_of?(Net::HTTPNoContent)
+      return if !self.class.json?(response) || response.instance_of?(Net::HTTPNoContent)
 
-      begin
-        JSON.parse(response.body, array_class:, object_class:)
-      rescue JSON::ParserError # rubocop:disable Lint/SuppressedException
-      end
+      JSON.parse(response.body, array_class:, object_class:)
     end
 
     private

But it breaks 22 tests so there are too many changes for this PR.

I refactored a little and removed JSON_CONTENT_TYPE_REGEXP so it's simple.

@GCorbel GCorbel requested a review from sferik January 29, 2025 15:35
def json?(response)
return false if response.instance_of?(Net::HTTPNoContent)

JSON.parse(response.body)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is better than before but it seems wasteful to parse the response body and throw away the result right before parsing JSON again in the parse method. I would move this check into the parse method, especially if it's a private method that you're not going to call from the X::HTTPError class.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree, I changed to have a similar version than 8d16719.

@sferik
Copy link
Owner

sferik commented Jan 30, 2025

Looks good!

@sferik sferik merged commit 001721f into sferik:main Jan 30, 2025
6 checks passed
@sferik
Copy link
Owner

sferik commented Jan 30, 2025

FYI, I just pushed these two cleanup commits:

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Use X API v2 to upload media
2 participants