├── test ├── sample_files │ ├── sample.jpg │ ├── sample.srt │ ├── sample.webp │ ├── sample.gif │ ├── sample.mp4 │ └── sample.png ├── x │ ├── authenticator_test.rb │ ├── bearer_token_authenticator_test.rb │ ├── version_test.rb │ ├── rate_limit_test.rb │ ├── error_test.rb │ ├── request_builder_test.rb │ ├── too_many_requests_test.rb │ ├── oauth_authenticator_test.rb │ ├── client_initailization_test.rb │ ├── client_request_test.rb │ ├── connection_test.rb │ ├── response_parser_test.rb │ ├── redirect_handler_test.rb │ └── media_uploader_test.rb └── test_helper.rb ├── .github ├── FUNDING.yml └── workflows │ ├── lint.yml │ ├── steep.yml │ ├── mutant.yml │ ├── test.yml │ └── jekyll-gh-pages.yml ├── lib ├── x.rb └── x │ ├── errors │ ├── error.rb │ ├── gone.rb │ ├── network_error.rb │ ├── forbidden.rb │ ├── not_found.rb │ ├── bad_gateway.rb │ ├── bad_request.rb │ ├── client_error.rb │ ├── server_error.rb │ ├── too_many_redirects.rb │ ├── not_acceptable.rb │ ├── unauthorized.rb │ ├── gateway_timeout.rb │ ├── payload_too_large.rb │ ├── service_unavailable.rb │ ├── connection_exception.rb │ ├── internal_server_error.rb │ ├── unprocessable_entity.rb │ ├── too_many_requests.rb │ └── http_error.rb │ ├── version.rb │ ├── authenticator.rb │ ├── bearer_token_authenticator.rb │ ├── rate_limit.rb │ ├── request_builder.rb │ ├── response_parser.rb │ ├── redirect_handler.rb │ ├── connection.rb │ ├── oauth_authenticator.rb │ ├── client.rb │ └── media_uploader.rb ├── bin ├── setup └── console ├── sponsor_logos ├── spacer.png ├── ifttt.svg ├── sentry.svg └── better_stack.svg ├── .gitignore ├── .mutant.yml ├── Steepfile ├── Gemfile ├── examples ├── post_media_upload.rb ├── chunked_media_upload.rb └── pagination.rb ├── Rakefile ├── LICENSE.txt ├── x.gemspec ├── .rubocop.yml ├── CHANGELOG.md ├── README.md └── sig └── x.rbs /test/sample_files/sample.jpg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/sample_files/sample.srt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/sample_files/sample.webp: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [sferik] 2 | -------------------------------------------------------------------------------- /lib/x.rb: -------------------------------------------------------------------------------- 1 | require_relative "x/client" 2 | -------------------------------------------------------------------------------- /lib/x/errors/error.rb: -------------------------------------------------------------------------------- 1 | module X 2 | class Error < StandardError; end 3 | end 4 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | -------------------------------------------------------------------------------- /sponsor_logos/spacer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super-dev03/ruby-test/HEAD/sponsor_logos/spacer.png -------------------------------------------------------------------------------- /test/sample_files/sample.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super-dev03/ruby-test/HEAD/test/sample_files/sample.gif -------------------------------------------------------------------------------- /test/sample_files/sample.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super-dev03/ruby-test/HEAD/test/sample_files/sample.mp4 -------------------------------------------------------------------------------- /test/sample_files/sample.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super-dev03/ruby-test/HEAD/test/sample_files/sample.png -------------------------------------------------------------------------------- /lib/x/errors/gone.rb: -------------------------------------------------------------------------------- 1 | require_relative "client_error" 2 | 3 | module X 4 | class Gone < ClientError; end 5 | end 6 | -------------------------------------------------------------------------------- /lib/x/errors/network_error.rb: -------------------------------------------------------------------------------- 1 | require_relative "error" 2 | 3 | module X 4 | class NetworkError < Error; end 5 | end 6 | -------------------------------------------------------------------------------- /lib/x/version.rb: -------------------------------------------------------------------------------- 1 | require "rubygems/version" 2 | 3 | module X 4 | VERSION = Gem::Version.create("0.14.1") 5 | end 6 | -------------------------------------------------------------------------------- /lib/x/errors/forbidden.rb: -------------------------------------------------------------------------------- 1 | require_relative "client_error" 2 | 3 | module X 4 | class Forbidden < ClientError; end 5 | end 6 | -------------------------------------------------------------------------------- /lib/x/errors/not_found.rb: -------------------------------------------------------------------------------- 1 | require_relative "client_error" 2 | 3 | module X 4 | class NotFound < ClientError; end 5 | end 6 | -------------------------------------------------------------------------------- /lib/x/errors/bad_gateway.rb: -------------------------------------------------------------------------------- 1 | require_relative "server_error" 2 | 3 | module X 4 | class BadGateway < ServerError; end 5 | end 6 | -------------------------------------------------------------------------------- /lib/x/errors/bad_request.rb: -------------------------------------------------------------------------------- 1 | require_relative "client_error" 2 | 3 | module X 4 | class BadRequest < ClientError; end 5 | end 6 | -------------------------------------------------------------------------------- /lib/x/errors/client_error.rb: -------------------------------------------------------------------------------- 1 | require_relative "http_error" 2 | 3 | module X 4 | class ClientError < HTTPError; end 5 | end 6 | -------------------------------------------------------------------------------- /lib/x/errors/server_error.rb: -------------------------------------------------------------------------------- 1 | require_relative "http_error" 2 | 3 | module X 4 | class ServerError < HTTPError; end 5 | end 6 | -------------------------------------------------------------------------------- /lib/x/errors/too_many_redirects.rb: -------------------------------------------------------------------------------- 1 | require_relative "error" 2 | 3 | module X 4 | class TooManyRedirects < Error; end 5 | end 6 | -------------------------------------------------------------------------------- /lib/x/errors/not_acceptable.rb: -------------------------------------------------------------------------------- 1 | require_relative "client_error" 2 | 3 | module X 4 | class NotAcceptable < ClientError; end 5 | end 6 | -------------------------------------------------------------------------------- /lib/x/errors/unauthorized.rb: -------------------------------------------------------------------------------- 1 | require_relative "client_error" 2 | 3 | module X 4 | class Unauthorized < ClientError; end 5 | end 6 | -------------------------------------------------------------------------------- /lib/x/errors/gateway_timeout.rb: -------------------------------------------------------------------------------- 1 | require_relative "server_error" 2 | 3 | module X 4 | class GatewayTimeout < ServerError; end 5 | end 6 | -------------------------------------------------------------------------------- /lib/x/errors/payload_too_large.rb: -------------------------------------------------------------------------------- 1 | require_relative "client_error" 2 | 3 | module X 4 | class PayloadTooLarge < ClientError; end 5 | end 6 | -------------------------------------------------------------------------------- /lib/x/errors/service_unavailable.rb: -------------------------------------------------------------------------------- 1 | require_relative "server_error" 2 | 3 | module X 4 | class ServiceUnavailable < ServerError; end 5 | end 6 | -------------------------------------------------------------------------------- /lib/x/errors/connection_exception.rb: -------------------------------------------------------------------------------- 1 | require_relative "client_error" 2 | 3 | module X 4 | class ConnectionException < ClientError; end 5 | end 6 | -------------------------------------------------------------------------------- /lib/x/errors/internal_server_error.rb: -------------------------------------------------------------------------------- 1 | require_relative "server_error" 2 | 3 | module X 4 | class InternalServerError < ServerError; end 5 | end 6 | -------------------------------------------------------------------------------- /lib/x/errors/unprocessable_entity.rb: -------------------------------------------------------------------------------- 1 | require_relative "client_error" 2 | 3 | module X 4 | class UnprocessableEntity < ClientError; end 5 | end 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | Gemfile.lock 10 | 11 | # ASDF-VM / rbenv 12 | .tool-versions 13 | -------------------------------------------------------------------------------- /lib/x/authenticator.rb: -------------------------------------------------------------------------------- 1 | module X 2 | class Authenticator 3 | AUTHENTICATION_HEADER = "Authorization".freeze 4 | 5 | def header(_request) 6 | {AUTHENTICATION_HEADER => ""} 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /.mutant.yml: -------------------------------------------------------------------------------- 1 | --- 2 | coverage_criteria: 3 | process_abort: true 4 | fail_fast: true 5 | includes: 6 | - lib 7 | integration: 8 | name: minitest 9 | matcher: 10 | subjects: 11 | - X* 12 | mutation: 13 | operators: full 14 | timeout: 10.0 15 | requires: 16 | - x 17 | usage: opensource 18 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "x" 5 | 6 | # You can add fixtures and/or initialization code here to make experimenting 7 | # with your gem easier. You can also use a different console, if you like. 8 | 9 | require "irb" 10 | IRB.start(__FILE__) 11 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: linter 2 | on: [push, pull_request] 3 | jobs: 4 | build: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v4 8 | - uses: ruby/setup-ruby@v1 9 | with: 10 | ruby-version: "3.1" 11 | bundler-cache: true 12 | - run: bundle exec rake lint 13 | -------------------------------------------------------------------------------- /.github/workflows/steep.yml: -------------------------------------------------------------------------------- 1 | name: type checker 2 | on: [push, pull_request] 3 | jobs: 4 | build: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v4 8 | - uses: ruby/setup-ruby@v1 9 | with: 10 | ruby-version: "3.1" 11 | bundler-cache: true 12 | - run: bundle exec rake steep 13 | -------------------------------------------------------------------------------- /.github/workflows/mutant.yml: -------------------------------------------------------------------------------- 1 | name: mutation tests 2 | on: [push, pull_request] 3 | jobs: 4 | build: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v4 8 | - uses: ruby/setup-ruby@v1 9 | with: 10 | ruby-version: "3.1" 11 | bundler-cache: true 12 | - run: bundle exec rake mutant 13 | -------------------------------------------------------------------------------- /Steepfile: -------------------------------------------------------------------------------- 1 | target :lib do 2 | signature "sig" 3 | check "lib" 4 | library "base64" 5 | library "cgi" 6 | library "forwardable" 7 | library "json" 8 | library "net-http" 9 | library "openssl" 10 | library "securerandom" 11 | library "tmpdir" 12 | library "uri" 13 | configure_code_diagnostics(Steep::Diagnostic::Ruby.default) # strict or all_error 14 | end 15 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: tests 2 | on: [push, pull_request] 3 | jobs: 4 | build: 5 | strategy: 6 | matrix: 7 | ruby: ["3.1", "3.2", "3.3"] 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v4 11 | - uses: ruby/setup-ruby@v1 12 | with: 13 | ruby-version: ${{ matrix.ruby }} 14 | bundler-cache: true 15 | - run: bundle exec rake test 16 | -------------------------------------------------------------------------------- /test/x/authenticator_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "../test_helper" 2 | 3 | module X 4 | class AuthenticatorTest < Minitest::Test 5 | cover Authenticator 6 | 7 | def setup 8 | @authenticator = Authenticator.new 9 | end 10 | 11 | def test_header 12 | assert_kind_of Hash, @authenticator.header(nil) 13 | assert_empty @authenticator.header(nil)["Authorization"] 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/x/bearer_token_authenticator.rb: -------------------------------------------------------------------------------- 1 | require_relative "authenticator" 2 | 3 | module X 4 | class BearerTokenAuthenticator < Authenticator 5 | attr_accessor :bearer_token 6 | 7 | def initialize(bearer_token:) # rubocop:disable Lint/MissingSuper 8 | @bearer_token = bearer_token 9 | end 10 | 11 | def header(_request) 12 | {AUTHENTICATION_HEADER => "Bearer #{bearer_token}"} 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /sponsor_logos/ifttt.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | # Specify your gem's dependencies in x.gemspec 4 | gemspec 5 | 6 | gem "base64", ">= 0.2" 7 | gem "fiddle", ">= 1.1.2" 8 | gem "minitest", ">= 5.19" 9 | gem "mutant", ">= 0.12" 10 | gem "mutant-minitest", ">= 0.11.24" 11 | gem "ostruct", ">= 0.6" 12 | gem "rake", ">= 13.0.6" 13 | gem "rbs", ">= 3.2.1" 14 | gem "rubocop", ">= 1.21" 15 | gem "rubocop-minitest", ">= 0.31" 16 | gem "rubocop-performance", ">= 1.18" 17 | gem "rubocop-rake", ">= 0.6" 18 | gem "simplecov", ">= 0.22" 19 | gem "standard", ">= 1.35.1" 20 | gem "steep", ">= 1.5.3" 21 | gem "webmock", ">= 3.18.1" 22 | -------------------------------------------------------------------------------- /test/x/bearer_token_authenticator_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "../test_helper" 2 | 3 | module X 4 | class BearerTokenAuthenticatorTest < Minitest::Test 5 | cover BearerTokenAuthenticator 6 | 7 | def setup 8 | @authenticator = BearerTokenAuthenticator.new(bearer_token: TEST_BEARER_TOKEN) 9 | end 10 | 11 | def test_initialize 12 | assert_equal TEST_BEARER_TOKEN, @authenticator.bearer_token 13 | end 14 | 15 | def test_header 16 | assert_kind_of Hash, @authenticator.header(nil) 17 | assert_equal "Bearer #{TEST_BEARER_TOKEN}", @authenticator.header(nil)["Authorization"] 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/x/errors/too_many_requests.rb: -------------------------------------------------------------------------------- 1 | require_relative "client_error" 2 | require_relative "../rate_limit" 3 | 4 | module X 5 | class TooManyRequests < ClientError 6 | def rate_limit 7 | rate_limits.max_by(&:reset_at) 8 | end 9 | 10 | def rate_limits 11 | @rate_limits ||= RateLimit::TYPES.filter_map do |type| 12 | RateLimit.new(type:, response:) if response["x-#{type}-remaining"].eql?("0") 13 | end 14 | end 15 | 16 | def reset_at 17 | rate_limit&.reset_at || Time.at(0) 18 | end 19 | 20 | def reset_in 21 | [(reset_at - Time.now).ceil, 0].max 22 | end 23 | 24 | alias_method :retry_after, :reset_in 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /test/x/version_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "../test_helper" 2 | 3 | module X 4 | class VersionTest < Minitest::Test 5 | def test_that_it_has_a_version_number 6 | refute_nil VERSION 7 | end 8 | 9 | def test_segments_array 10 | assert_kind_of Array, VERSION.segments 11 | end 12 | 13 | def test_major_version_integer 14 | assert_kind_of Integer, VERSION.segments[0] 15 | end 16 | 17 | def test_minor_version_integer 18 | assert_kind_of Integer, VERSION.segments[1] 19 | end 20 | 21 | def test_patch_version_integer 22 | assert_kind_of Integer, VERSION.segments[2] 23 | end 24 | 25 | def test_to_s 26 | assert_kind_of String, VERSION.to_s 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /examples/post_media_upload.rb: -------------------------------------------------------------------------------- 1 | require "x" 2 | require "x/media_uploader" 3 | 4 | x_credentials = { 5 | api_key: "INSERT YOUR X API KEY HERE", 6 | api_key_secret: "INSERT YOUR X API KEY SECRET HERE", 7 | access_token: "INSERT YOUR X ACCESS TOKEN HERE", 8 | access_token_secret: "INSERT YOUR X ACCESS TOKEN SECRET HERE" 9 | } 10 | 11 | client = X::Client.new(**x_credentials) 12 | file_path = "path/to/your/media.jpg" 13 | media_category = "tweet_image" # other options are: dm_image or subtitles; for videos or GIFs use chunked_upload 14 | 15 | media = X::MediaUploader.upload(client:, file_path:, media_category:) 16 | 17 | tweet_body = {text: "Posting media from @gem!", media: {media_ids: [media["media_id_string"]]}} 18 | 19 | tweet = client.post("tweets", tweet_body.to_json) 20 | 21 | puts tweet["data"]["id"] 22 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rake/testtask" 3 | 4 | Rake::TestTask.new(:test) do |t| 5 | t.libs << "test" 6 | t.libs << "lib" 7 | t.options = "--pride" 8 | t.test_files = FileList["test/**/*_test.rb"] 9 | end 10 | 11 | require "standard/rake" 12 | require "rubocop/rake_task" 13 | 14 | RuboCop::RakeTask.new 15 | 16 | require "steep" 17 | require "steep/cli" 18 | 19 | desc "Type check with Steep" 20 | task :steep do 21 | Steep::CLI.new(argv: ["check"], stdout: $stdout, stderr: $stderr, stdin: $stdin).run 22 | end 23 | 24 | require "mutant" 25 | 26 | desc "Run mutant" 27 | task :mutant do 28 | system(*%w[bundle exec mutant run]) or raise "Mutant task failed" 29 | end 30 | 31 | desc "Run linters" 32 | task lint: %i[rubocop standard] 33 | 34 | task default: %i[test lint mutant steep] 35 | -------------------------------------------------------------------------------- /lib/x/rate_limit.rb: -------------------------------------------------------------------------------- 1 | module X 2 | class RateLimit 3 | RATE_LIMIT_TYPE = "rate-limit".freeze 4 | APP_LIMIT_TYPE = "app-limit-24hour".freeze 5 | USER_LIMIT_TYPE = "user-limit-24hour".freeze 6 | TYPES = [RATE_LIMIT_TYPE, APP_LIMIT_TYPE, USER_LIMIT_TYPE].freeze 7 | 8 | attr_accessor :type, :response 9 | 10 | def initialize(type:, response:) 11 | @type = type 12 | @response = response 13 | end 14 | 15 | def limit 16 | Integer(response.fetch("x-#{type}-limit")) 17 | end 18 | 19 | def remaining 20 | Integer(response.fetch("x-#{type}-remaining")) 21 | end 22 | 23 | def reset_at 24 | Time.at(Integer(response.fetch("x-#{type}-reset"))) 25 | end 26 | 27 | def reset_in 28 | [(reset_at - Time.now).ceil, 0].max 29 | end 30 | 31 | alias_method :retry_after, :reset_in 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /examples/chunked_media_upload.rb: -------------------------------------------------------------------------------- 1 | require "x" 2 | require "x/media_uploader" 3 | 4 | x_credentials = { 5 | api_key: "INSERT YOUR X API KEY HERE", 6 | api_key_secret: "INSERT YOUR X API KEY SECRET HERE", 7 | access_token: "INSERT YOUR X ACCESS TOKEN HERE", 8 | access_token_secret: "INSERT YOUR X ACCESS TOKEN SECRET HERE" 9 | } 10 | 11 | client = X::Client.new(**x_credentials) 12 | file_path = "path/to/your/media.mp4" 13 | media_category = "tweet_video" # other options include: tweet_image, tweet_gif, dm_image, dm_video, dm_gif, subtitles 14 | 15 | media = X::MediaUploader.chunked_upload(client:, file_path:, media_category:) 16 | 17 | X::MediaUploader.await_processing(client:, media:) 18 | 19 | tweet_body = {text: "Posting media from @gem!", media: {media_ids: [media["media_id_string"]]}} 20 | 21 | tweet = client.post("tweets", tweet_body.to_json) 22 | 23 | puts tweet["data"]["id"] 24 | -------------------------------------------------------------------------------- /examples/pagination.rb: -------------------------------------------------------------------------------- 1 | require "x" 2 | 3 | x_credentials = { 4 | api_key: "INSERT YOUR X API KEY HERE", 5 | api_key_secret: "INSERT YOUR X API KEY SECRET HERE", 6 | access_token: "INSERT YOUR X ACCESS TOKEN HERE", 7 | access_token_secret: "INSERT YOUR X ACCESS TOKEN SECRET HERE" 8 | } 9 | 10 | client = X::Client.new(base_url: "https://api.twitter.com/1.1/", **x_credentials) 11 | 12 | screen_name = "sferik" 13 | count = 5000 14 | cursor = -1 15 | follower_ids = [] 16 | 17 | loop do 18 | response = client.get("followers/ids.json?screen_name=#{screen_name}&count=#{count}&cursor=#{cursor}") 19 | follower_ids << response["ids"] 20 | cursor = response["next_cursor"] 21 | break if cursor.zero? 22 | rescue X::TooManyRequests => e 23 | # NOTE: Your process could go to sleep for up to 15 minutes but if you 24 | # retry any sooner, it will almost certainly fail with the same exception. 25 | sleep e.retry_after 26 | retry 27 | end 28 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2023 Erik Berlin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /x.gemspec: -------------------------------------------------------------------------------- 1 | require_relative "lib/x/version" 2 | 3 | Gem::Specification.new do |spec| 4 | spec.name = "x" 5 | spec.version = X::VERSION 6 | spec.authors = ["Erik Berlin"] 7 | spec.email = ["sferik@gmail.com"] 8 | 9 | spec.summary = "A Ruby interface to the X API." 10 | spec.homepage = "https://sferik.github.io/x-ruby" 11 | spec.license = "MIT" 12 | spec.required_ruby_version = ">= 3.1.3" 13 | spec.platform = Gem::Platform::RUBY 14 | 15 | spec.metadata = { 16 | "allowed_push_host" => "https://rubygems.org", 17 | "rubygems_mfa_required" => "true", 18 | "homepage_uri" => spec.homepage, 19 | "source_code_uri" => "https://github.com/sferik/x-ruby", 20 | "changelog_uri" => "https://github.com/sferik/x-ruby/blob/master/CHANGELOG.md", 21 | "bug_tracker_uri" => "https://github.com/sferik/x-ruby/issues", 22 | "documentation_uri" => "https://rubydoc.info/gems/x/" 23 | } 24 | 25 | spec.files = Dir[ 26 | "bin/*", 27 | "lib/**/*.rb", 28 | "sig/*.rbs", 29 | "*.md", 30 | "LICENSE.txt" 31 | ] 32 | spec.bindir = "exe" 33 | spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } 34 | spec.require_paths = ["lib"] 35 | end 36 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | require: 2 | - rubocop-minitest 3 | - rubocop-performance 4 | - rubocop-rake 5 | - standard 6 | - standard-performance 7 | 8 | AllCops: 9 | NewCops: enable 10 | TargetRubyVersion: 3.1 11 | 12 | Layout/ArgumentAlignment: 13 | EnforcedStyle: with_fixed_indentation 14 | IndentationWidth: 2 15 | 16 | Layout/CaseIndentation: 17 | EnforcedStyle: end 18 | 19 | Layout/EndAlignment: 20 | EnforcedStyleAlignWith: start_of_line 21 | 22 | Layout/LineLength: 23 | Max: 140 24 | 25 | Layout/ParameterAlignment: 26 | EnforcedStyle: with_fixed_indentation 27 | IndentationWidth: 2 28 | 29 | Layout/SpaceInsideHashLiteralBraces: 30 | EnforcedStyle: no_space 31 | 32 | Metrics/ParameterLists: 33 | CountKeywordArgs: false 34 | 35 | Minitest/MultipleAssertions: 36 | Max: 5 37 | 38 | Style/Alias: 39 | EnforcedStyle: prefer_alias_method 40 | 41 | Style/Documentation: 42 | Enabled: false 43 | 44 | Style/FrozenStringLiteralComment: 45 | EnforcedStyle: never 46 | 47 | Style/OpenStructUse: 48 | Enabled: false 49 | 50 | Style/StringLiterals: 51 | EnforcedStyle: double_quotes 52 | 53 | Style/StringLiteralsInInterpolation: 54 | EnforcedStyle: double_quotes 55 | 56 | Style/TernaryParentheses: 57 | EnforcedStyle: require_parentheses 58 | -------------------------------------------------------------------------------- /lib/x/errors/http_error.rb: -------------------------------------------------------------------------------- 1 | require "json" 2 | require_relative "error" 3 | 4 | module X 5 | class HTTPError < Error 6 | JSON_CONTENT_TYPE_REGEXP = %r{application/(problem\+|)json} 7 | 8 | attr_reader :response, :code 9 | 10 | def initialize(response:) 11 | super(error_message(response)) 12 | @response = response 13 | @code = response.code 14 | end 15 | 16 | def error_message(response) 17 | if json?(response) 18 | message_from_json_response(response) 19 | else 20 | response.message 21 | end 22 | end 23 | 24 | def message_from_json_response(response) 25 | response_object = JSON.parse(response.body) 26 | if response_object.key?("title") && response_object.key?("detail") 27 | "#{response_object.fetch("title")}: #{response_object.fetch("detail")}" 28 | elsif response_object.key?("error") 29 | response_object.fetch("error") 30 | elsif response_object["errors"].instance_of?(Array) 31 | response_object.fetch("errors").map { |error| error.fetch("message") }.join(", ") 32 | else 33 | response.message 34 | end 35 | end 36 | 37 | def json?(response) 38 | JSON_CONTENT_TYPE_REGEXP === response["content-type"] 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift File.expand_path("../lib", __dir__) 2 | 3 | unless $PROGRAM_NAME.end_with?("mutant") 4 | require "simplecov" 5 | 6 | SimpleCov.start do 7 | add_filter "test" 8 | minimum_coverage(100) 9 | end 10 | end 11 | 12 | require "minitest/autorun" 13 | require "mutant/minitest/coverage" 14 | require "webmock/minitest" 15 | require "x" 16 | 17 | TEST_BEARER_TOKEN = "TEST_BEARER_TOKEN".freeze 18 | TEST_API_KEY = "TEST_API_KEY".freeze 19 | TEST_API_KEY_SECRET = "TEST_API_KEY_SECRET".freeze 20 | TEST_ACCESS_TOKEN = "TEST_ACCESS_TOKEN".freeze 21 | TEST_ACCESS_TOKEN_SECRET = "TEST_ACCESS_TOKEN_SECRET".freeze 22 | TEST_OAUTH_NONCE = "TEST_OAUTH_NONCE".freeze 23 | TEST_OAUTH_TIMESTAMP = Time.utc(1983, 11, 24).to_i.to_s 24 | TEST_MEDIA_ID = 1_234_567_890 25 | 26 | def test_oauth_credentials 27 | { 28 | api_key: TEST_API_KEY, 29 | api_key_secret: TEST_API_KEY_SECRET, 30 | access_token: TEST_ACCESS_TOKEN, 31 | access_token_secret: TEST_ACCESS_TOKEN_SECRET 32 | } 33 | end 34 | 35 | def test_oauth_params 36 | { 37 | "oauth_consumer_key" => TEST_API_KEY, 38 | "oauth_nonce" => TEST_OAUTH_NONCE, 39 | "oauth_signature_method" => X::OAuthAuthenticator::OAUTH_SIGNATURE_METHOD, 40 | "oauth_timestamp" => TEST_OAUTH_TIMESTAMP, 41 | "oauth_token" => TEST_ACCESS_TOKEN, 42 | "oauth_version" => X::OAuthAuthenticator::OAUTH_VERSION 43 | } 44 | end 45 | -------------------------------------------------------------------------------- /test/x/rate_limit_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "../test_helper" 2 | 3 | module X 4 | class RateLimitTest < Minitest::Test 5 | cover RateLimit 6 | 7 | def setup 8 | Time.stub :now, Time.utc(1983, 11, 24) do 9 | response = { 10 | "x-rate-limit-limit" => "100", 11 | "x-rate-limit-remaining" => "0", 12 | "x-rate-limit-reset" => (Time.now.to_i + 60).to_s 13 | } 14 | @rate_limit = RateLimit.new(type: "rate-limit", response:) 15 | end 16 | end 17 | 18 | def test_limit 19 | assert_equal 100, @rate_limit.limit 20 | end 21 | 22 | def test_remaining 23 | assert_equal 0, @rate_limit.remaining 24 | end 25 | 26 | def test_reset_at 27 | Time.stub :now, Time.utc(1983, 11, 24) do 28 | assert_equal Time.at(Time.now.to_i + 60), @rate_limit.reset_at 29 | end 30 | end 31 | 32 | def test_reset_in 33 | Time.stub :now, Time.utc(1983, 11, 24) do 34 | assert_equal 60, @rate_limit.reset_in 35 | end 36 | end 37 | 38 | def test_reset_in_minimum_value 39 | @rate_limit.response["x-rate-limit-reset"] = (Time.now.to_i - 60).to_s 40 | 41 | assert_equal 0, @rate_limit.reset_in 42 | end 43 | 44 | def test_reset_in_ceil 45 | @rate_limit.response["x-rate-limit-reset"] = (Time.now + 61).to_i.to_s 46 | 47 | assert_equal 61, @rate_limit.reset_in 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /.github/workflows/jekyll-gh-pages.yml: -------------------------------------------------------------------------------- 1 | name: Deploy Jekyll with GitHub Pages dependencies preinstalled 2 | 3 | on: 4 | # Runs on pushes targeting the default branch 5 | push: 6 | branches: ["main"] 7 | 8 | # Allows you to run this workflow manually from the Actions tab 9 | workflow_dispatch: 10 | 11 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 12 | permissions: 13 | contents: read 14 | pages: write 15 | id-token: write 16 | 17 | # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. 18 | # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. 19 | concurrency: 20 | group: "pages" 21 | cancel-in-progress: false 22 | 23 | jobs: 24 | # Build job 25 | build: 26 | runs-on: ubuntu-latest 27 | steps: 28 | - name: Checkout 29 | uses: actions/checkout@v4 30 | - name: Setup Pages 31 | uses: actions/configure-pages@v5 32 | - name: Build with Jekyll 33 | uses: actions/jekyll-build-pages@v1 34 | with: 35 | source: ./ 36 | destination: ./_site 37 | - name: Upload artifact 38 | uses: actions/upload-pages-artifact@v3 39 | 40 | # Deployment job 41 | deploy: 42 | environment: 43 | name: github-pages 44 | url: ${{ steps.deployment.outputs.page_url }} 45 | runs-on: ubuntu-latest 46 | needs: build 47 | steps: 48 | - name: Deploy to GitHub Pages 49 | id: deployment 50 | uses: actions/deploy-pages@v4 51 | -------------------------------------------------------------------------------- /test/x/error_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "../test_helper" 2 | 3 | module X 4 | class ErrorsTest < Minitest::Test 5 | cover Client 6 | 7 | def setup 8 | @client = Client.new 9 | end 10 | 11 | ResponseParser::ERROR_MAP.each do |status, error_class| 12 | name = error_class.name.split("::").last 13 | define_method :"test_initialize_#{name.downcase}_error" do 14 | response = Net::HTTPResponse::CODE_TO_OBJ[status.to_s].new("1.1", status, error_class.name) 15 | exception = error_class.new(response:) 16 | 17 | assert_equal error_class.name, exception.message 18 | assert_equal response, exception.response 19 | assert_equal status, exception.code 20 | end 21 | end 22 | 23 | Connection::NETWORK_ERRORS.each do |error_class| 24 | define_method "test_#{error_class.name.split("::").last.downcase}_raises_network_error" do 25 | stub_request(:get, "https://api.twitter.com/2/tweets").to_raise(error_class) 26 | 27 | assert_raises NetworkError do 28 | @client.get("tweets") 29 | end 30 | end 31 | end 32 | 33 | def test_unexpected_response 34 | stub_request(:get, "https://api.twitter.com/2/tweets").to_return(status: 600) 35 | 36 | assert_raises Error do 37 | @client.get("tweets") 38 | end 39 | end 40 | 41 | def test_problem_json 42 | body = {error: "problem"}.to_json 43 | stub_request(:get, "https://api.twitter.com/2/tweets") 44 | .to_return(status: 400, headers: {"content-type" => "application/problem+json"}, body:) 45 | 46 | begin 47 | @client.get("tweets") 48 | rescue BadRequest => e 49 | assert_equal "problem", e.message 50 | end 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/x/request_builder.rb: -------------------------------------------------------------------------------- 1 | require "net/http" 2 | require "uri" 3 | require_relative "authenticator" 4 | require_relative "version" 5 | 6 | module X 7 | class RequestBuilder 8 | DEFAULT_HEADERS = { 9 | "Content-Type" => "application/json; charset=utf-8", 10 | "User-Agent" => "X-Client/#{VERSION} #{RUBY_ENGINE}/#{RUBY_VERSION} (#{RUBY_PLATFORM})" 11 | }.freeze 12 | HTTP_METHODS = { 13 | get: Net::HTTP::Get, 14 | post: Net::HTTP::Post, 15 | put: Net::HTTP::Put, 16 | delete: Net::HTTP::Delete 17 | }.freeze 18 | 19 | def build(http_method:, uri:, body: nil, headers: {}, authenticator: Authenticator.new) 20 | request = create_request(http_method:, uri:, body:) 21 | add_headers(request:, headers:) 22 | add_authentication(request:, authenticator:) 23 | request 24 | end 25 | 26 | private 27 | 28 | def create_request(http_method:, uri:, body:) 29 | http_method_class = HTTP_METHODS[http_method] 30 | 31 | raise ArgumentError, "Unsupported HTTP method: #{http_method}" unless http_method_class 32 | 33 | escaped_uri = escape_query_params(uri) 34 | request = http_method_class.new(escaped_uri) 35 | request.body = body 36 | request 37 | end 38 | 39 | def add_authentication(request:, authenticator:) 40 | authenticator.header(request).each do |key, value| 41 | request.add_field(key, value) 42 | end 43 | end 44 | 45 | def add_headers(request:, headers:) 46 | DEFAULT_HEADERS.merge(headers).each do |key, value| 47 | request.delete(key) 48 | request.add_field(key, value) 49 | end 50 | end 51 | 52 | def escape_query_params(uri) 53 | URI(uri).tap { |u| u.query = URI.encode_www_form(URI.decode_www_form(u.query)) if u.query } 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /lib/x/response_parser.rb: -------------------------------------------------------------------------------- 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/http_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 | class ResponseParser 21 | ERROR_MAP = { 22 | 400 => BadRequest, 23 | 401 => Unauthorized, 24 | 403 => Forbidden, 25 | 404 => NotFound, 26 | 406 => NotAcceptable, 27 | 409 => ConnectionException, 28 | 410 => Gone, 29 | 413 => PayloadTooLarge, 30 | 422 => UnprocessableEntity, 31 | 429 => TooManyRequests, 32 | 500 => InternalServerError, 33 | 502 => BadGateway, 34 | 503 => ServiceUnavailable, 35 | 504 => GatewayTimeout 36 | }.freeze 37 | JSON_CONTENT_TYPE_REGEXP = %r{application/json} 38 | 39 | def parse(response:, array_class: nil, object_class: nil) 40 | raise error(response) unless response.is_a?(Net::HTTPSuccess) 41 | 42 | return unless json?(response) 43 | 44 | JSON.parse(response.body, array_class:, object_class:) 45 | end 46 | 47 | private 48 | 49 | def error(response) 50 | error_class(response).new(response:) 51 | end 52 | 53 | def error_class(response) 54 | ERROR_MAP[Integer(response.code)] || HTTPError 55 | end 56 | 57 | def json?(response) 58 | JSON_CONTENT_TYPE_REGEXP === response["content-type"] 59 | end 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /lib/x/redirect_handler.rb: -------------------------------------------------------------------------------- 1 | require "net/http" 2 | require "uri" 3 | require_relative "authenticator" 4 | require_relative "connection" 5 | require_relative "errors/too_many_redirects" 6 | require_relative "request_builder" 7 | 8 | module X 9 | class RedirectHandler 10 | DEFAULT_MAX_REDIRECTS = 10 11 | 12 | attr_accessor :max_redirects 13 | attr_reader :connection, :request_builder 14 | 15 | def initialize(connection: Connection.new, request_builder: RequestBuilder.new, 16 | max_redirects: DEFAULT_MAX_REDIRECTS) 17 | @connection = connection 18 | @request_builder = request_builder 19 | @max_redirects = max_redirects 20 | end 21 | 22 | def handle(response:, request:, base_url:, authenticator: Authenticator.new, redirect_count: 0) 23 | if response.is_a?(Net::HTTPRedirection) 24 | raise TooManyRedirects, "Too many redirects" if redirect_count > max_redirects 25 | 26 | new_uri = build_new_uri(response, base_url) 27 | 28 | new_request = build_request(request, new_uri, Integer(response.code), authenticator) 29 | new_response = connection.perform(request: new_request) 30 | 31 | handle(response: new_response, request: new_request, base_url:, redirect_count: redirect_count + 1) 32 | else 33 | response 34 | end 35 | end 36 | 37 | private 38 | 39 | def build_new_uri(response, base_url) 40 | location = response.fetch("location") 41 | # If location is relative, it will join with the original base URL, otherwise it will overwrite it 42 | URI.join(base_url, location) 43 | end 44 | 45 | def build_request(request, uri, response_code, authenticator) 46 | http_method, body = case response_code 47 | in 307 | 308 48 | [request.method.downcase.to_sym, request.body] 49 | else 50 | [:get, nil] 51 | end 52 | 53 | request_builder.build(http_method:, uri:, body:, authenticator:) 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /lib/x/connection.rb: -------------------------------------------------------------------------------- 1 | require "forwardable" 2 | require "net/http" 3 | require "openssl" 4 | require "uri" 5 | require_relative "errors/network_error" 6 | 7 | module X 8 | class Connection 9 | extend Forwardable 10 | 11 | DEFAULT_HOST = "api.twitter.com".freeze 12 | DEFAULT_PORT = 443 13 | DEFAULT_OPEN_TIMEOUT = 60 # seconds 14 | DEFAULT_READ_TIMEOUT = 60 # seconds 15 | DEFAULT_WRITE_TIMEOUT = 60 # seconds 16 | DEFAULT_DEBUG_OUTPUT = File.open(File::NULL, "w") 17 | NETWORK_ERRORS = [ 18 | Errno::ECONNREFUSED, 19 | Errno::ECONNRESET, 20 | Net::OpenTimeout, 21 | Net::ReadTimeout, 22 | OpenSSL::SSL::SSLError 23 | ].freeze 24 | 25 | attr_accessor :open_timeout, :read_timeout, :write_timeout, :debug_output 26 | attr_reader :proxy_url, :proxy_uri 27 | 28 | def_delegator :proxy_uri, :host, :proxy_host 29 | def_delegator :proxy_uri, :port, :proxy_port 30 | def_delegator :proxy_uri, :user, :proxy_user 31 | def_delegator :proxy_uri, :password, :proxy_pass 32 | 33 | def initialize(open_timeout: DEFAULT_OPEN_TIMEOUT, read_timeout: DEFAULT_READ_TIMEOUT, 34 | write_timeout: DEFAULT_WRITE_TIMEOUT, debug_output: DEFAULT_DEBUG_OUTPUT, proxy_url: nil) 35 | @open_timeout = open_timeout 36 | @read_timeout = read_timeout 37 | @write_timeout = write_timeout 38 | @debug_output = debug_output 39 | self.proxy_url = proxy_url unless proxy_url.nil? 40 | end 41 | 42 | def perform(request:) 43 | host = request.uri.host || DEFAULT_HOST 44 | port = request.uri.port || DEFAULT_PORT 45 | http_client = build_http_client(host, port) 46 | http_client.use_ssl = request.uri.scheme.eql?("https") 47 | http_client.request(request) 48 | rescue *NETWORK_ERRORS => e 49 | raise NetworkError, "Network error: #{e}" 50 | end 51 | 52 | def proxy_url=(proxy_url) 53 | @proxy_url = proxy_url 54 | proxy_uri = URI(proxy_url) 55 | raise ArgumentError, "Invalid proxy URL: #{proxy_uri}" unless proxy_uri.is_a?(URI::HTTP) 56 | 57 | @proxy_uri = proxy_uri 58 | end 59 | 60 | private 61 | 62 | def build_http_client(host = DEFAULT_HOST, port = DEFAULT_PORT) 63 | http_client = if proxy_uri 64 | Net::HTTP.new(host, port, proxy_host, proxy_port, proxy_user, proxy_pass) 65 | else 66 | Net::HTTP.new(host, port) 67 | end 68 | configure_http_client(http_client) 69 | end 70 | 71 | def configure_http_client(http_client) 72 | http_client.tap do |c| 73 | c.open_timeout = open_timeout 74 | c.read_timeout = read_timeout 75 | c.write_timeout = write_timeout 76 | c.set_debug_output(debug_output) 77 | end 78 | end 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /sponsor_logos/sentry.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /lib/x/oauth_authenticator.rb: -------------------------------------------------------------------------------- 1 | require "base64" 2 | require "cgi" 3 | require "json" 4 | require "openssl" 5 | require "securerandom" 6 | require "uri" 7 | require_relative "authenticator" 8 | 9 | module X 10 | class OAuthAuthenticator < Authenticator 11 | OAUTH_VERSION = "1.0".freeze 12 | OAUTH_SIGNATURE_METHOD = "HMAC-SHA1".freeze 13 | OAUTH_SIGNATURE_ALGORITHM = "sha1".freeze 14 | 15 | attr_accessor :api_key, :api_key_secret, :access_token, :access_token_secret 16 | 17 | def initialize(api_key:, api_key_secret:, access_token:, access_token_secret:) # rubocop:disable Lint/MissingSuper 18 | @api_key = api_key 19 | @api_key_secret = api_key_secret 20 | @access_token = access_token 21 | @access_token_secret = access_token_secret 22 | end 23 | 24 | def header(request) 25 | method, url, query_params = parse_request(request) 26 | {AUTHENTICATION_HEADER => build_oauth_header(method, url, query_params)} 27 | end 28 | 29 | private 30 | 31 | def parse_request(request) 32 | uri = request.uri 33 | query_params = parse_query_params(uri.query.to_s) 34 | [request.method, uri_without_query(uri), query_params] 35 | end 36 | 37 | def parse_query_params(query_string) 38 | URI.decode_www_form(query_string).to_h 39 | end 40 | 41 | def uri_without_query(uri) 42 | uri.to_s.chomp("?#{uri.query}") 43 | end 44 | 45 | def build_oauth_header(method, url, query_params) 46 | oauth_params = default_oauth_params 47 | all_params = query_params.merge(oauth_params) 48 | oauth_params["oauth_signature"] = generate_signature(method, url, all_params) 49 | format_oauth_header(oauth_params) 50 | end 51 | 52 | def default_oauth_params 53 | { 54 | "oauth_consumer_key" => api_key, 55 | "oauth_nonce" => SecureRandom.hex, 56 | "oauth_signature_method" => OAUTH_SIGNATURE_METHOD, 57 | "oauth_timestamp" => Integer(Time.now).to_s, 58 | "oauth_token" => access_token, 59 | "oauth_version" => OAUTH_VERSION 60 | } 61 | end 62 | 63 | def generate_signature(method, url, params) 64 | base_string = signature_base_string(method, url, params) 65 | hmac_signature(base_string) 66 | end 67 | 68 | def hmac_signature(base_string) 69 | hmac = OpenSSL::HMAC.digest(OAUTH_SIGNATURE_ALGORITHM, signing_key, base_string) 70 | Base64.strict_encode64(hmac) 71 | end 72 | 73 | def signature_base_string(method, url, params) 74 | "#{method}&#{CGI.escapeURIComponent(url)}&#{CGI.escapeURIComponent(URI.encode_www_form(params.sort))}" 75 | end 76 | 77 | def signing_key 78 | "#{api_key_secret}&#{access_token_secret}" 79 | end 80 | 81 | def format_oauth_header(params) 82 | "OAuth #{params.sort.map { |k, v| "#{k}=\"#{CGI.escapeURIComponent(v)}\"" }.join(", ")}" 83 | end 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /test/x/request_builder_test.rb: -------------------------------------------------------------------------------- 1 | require "uri" 2 | require_relative "../test_helper" 3 | 4 | module X 5 | class RequestBuilderTest < Minitest::Test 6 | cover RequestBuilder 7 | 8 | def setup 9 | @authenticator = OAuthAuthenticator.new(api_key: TEST_API_KEY, api_key_secret: TEST_API_KEY_SECRET, 10 | access_token: TEST_ACCESS_TOKEN, access_token_secret: TEST_ACCESS_TOKEN_SECRET) 11 | @request_builder = RequestBuilder.new 12 | @uri = URI("http://example.com") 13 | end 14 | 15 | def test_build_get_request 16 | expected = "OAuth oauth_consumer_key=\"TEST_API_KEY\", oauth_nonce=\"TEST_OAUTH_NONCE\", " \ 17 | "oauth_signature=\"mnm1SUSsJ0X4aBwAAkwpsTf01gg%3D\", oauth_signature_method=\"HMAC-SHA1\", " \ 18 | "oauth_timestamp=\"438480000\", oauth_token=\"TEST_ACCESS_TOKEN\", oauth_version=\"1.0\"" 19 | @authenticator.stub :default_oauth_params, test_oauth_params do 20 | request = @request_builder.build(http_method: :get, uri: @uri, authenticator: @authenticator) 21 | 22 | assert_equal "GET", request.method 23 | assert_equal @uri, request.uri 24 | assert_equal expected, request["Authorization"] 25 | assert_equal "application/json; charset=utf-8", request["Content-Type"] 26 | end 27 | end 28 | 29 | def test_build_post_request 30 | expected = "OAuth oauth_consumer_key=\"TEST_API_KEY\", oauth_nonce=\"TEST_OAUTH_NONCE\", " \ 31 | "oauth_signature=\"pcXcvPVpQINrqI3H3lCg8N1ayG0%3D\", oauth_signature_method=\"HMAC-SHA1\", " \ 32 | "oauth_timestamp=\"438480000\", oauth_token=\"TEST_ACCESS_TOKEN\", oauth_version=\"1.0\"" 33 | 34 | @authenticator.stub :default_oauth_params, test_oauth_params do 35 | request = @request_builder.build(http_method: :post, uri: @uri, body: "{}", authenticator: @authenticator) 36 | 37 | assert_equal "POST", request.method 38 | assert_equal @uri, request.uri 39 | assert_equal "{}", request.body 40 | assert_equal expected, request["Authorization"] 41 | end 42 | end 43 | 44 | def test_custom_headers 45 | request = @request_builder.build(http_method: :get, uri: @uri, 46 | headers: {"User-Agent" => "Custom User Agent"}, authenticator: @authenticator) 47 | 48 | assert_equal "Custom User Agent", request["User-Agent"] 49 | end 50 | 51 | def test_build_without_authenticator_parameter 52 | request = @request_builder.build(http_method: :get, uri: @uri) 53 | 54 | assert_empty request["Authorization"] 55 | end 56 | 57 | def test_unsupported_http_method 58 | exception = assert_raises ArgumentError do 59 | @request_builder.build(http_method: :unsupported, uri: @uri, authenticator: @authenticator) 60 | end 61 | 62 | assert_equal "Unsupported HTTP method: unsupported", exception.message 63 | end 64 | 65 | def test_escape_query_params 66 | uri = "https://upload.twitter.com/1.1/media/upload.json?media_type=video/mp4" 67 | request = @request_builder.build(http_method: :post, uri:, authenticator: @authenticator) 68 | 69 | assert_equal "media_type=video%2Fmp4", request.uri.query 70 | end 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [0.14.1] - 2023-12-20 2 | * Fix infinite loop when an upload fails (5dfc604) 3 | 4 | ## [0.14.0] - 2023-12-08 5 | * Allow passing custom objects per-request (768889f) 6 | 7 | ## [0.13.0] - 2023-12-04 8 | * Introduce X::RateLimit, which is returned with X::TooManyRequests errors (196caec) 9 | 10 | ## [0.12.1] - 2023-11-28 11 | * Ensure split chunks are written as binary (c6e257f) 12 | * Require tmpdir in X::MediaUploader (9e7c7f1) 13 | 14 | ## [0.12.0] - 2023-11-02 15 | * Ensure Authenticator is passed to RedirectHandler (fc8557b) 16 | * Add AUTHENTICATION_HEADER to X::Authenticator base class (85a2818) 17 | * Introduce X::HTTPError (90ae132) 18 | * Add `code` attribute to error classes (b003639) 19 | 20 | ## [0.11.0] - 2023-10-24 21 | 22 | * Add base Authenticator class (8c66ce2) 23 | * Consistently use keyword arguments (3beb271) 24 | * Use patern matching to build request (4d001c7) 25 | * Rename ResponseHandler to ResponseParser (498e890) 26 | * Rename methods to be more consistent (5b8c655) 27 | * Rename MediaUpload to MediaUploader (84f0c15) 28 | * Add mutant and kill mutants (b124968) 29 | * Fix authentication bug with request URLs that contain spaces (8de3174) 30 | * Refactor errors (853d39c) 31 | * Make Connection class threadsafe (d95d285) 32 | 33 | ## [0.10.0] - 2023-10-08 34 | 35 | * Add media upload helper methods (6c6a267) 36 | * Add PayloadTooLargeError class (cd61850) 37 | 38 | ## [0.9.1] - 2023-10-06 39 | 40 | * Allow successful empty responses (06bf7db) 41 | * Update default User-Agent string (296b36a) 42 | * Move query parameter escaping into RequestBuilder (56d6bd2) 43 | 44 | ## [0.9.0] - 2023-09-26 45 | 46 | * Add support for HTTP proxies (3740f4f) 47 | 48 | ## [0.8.1] - 2023-09-20 49 | 50 | * Fix bug where setting Connection#base_uri= doesn't update the HTTP client (d5a89db) 51 | 52 | ## [0.8.0] - 2023-09-14 53 | 54 | * Add (back) bearer token authentication (62e141d) 55 | * Follow redirects (90a8c55) 56 | * Parse error responses with Content-Type: application/problem+json (0b697d9) 57 | 58 | ## [0.7.1] - 2023-09-02 59 | 60 | * Fix bug in X::Authenticator#split_uri (ebc9d5f) 61 | 62 | ## [0.7.0] - 2023-09-02 63 | 64 | * Remove OAuth gem (7c29bb1) 65 | 66 | ## [0.6.0] - 2023-08-30 67 | 68 | * Add configurable debug output stream for logging (fd2d4b0) 69 | * Remove bearer token authentication (efff940) 70 | * Define RBS type signatures (d7f63ba) 71 | 72 | ## [0.5.1] - 2023-08-16 73 | 74 | * Fix bearer token authentication (1a1ca93) 75 | 76 | ## [0.5.0] - 2023-08-10 77 | 78 | * Add configurable write timeout (2a31f84) 79 | * Use built-in Gem::Version class (066e0b6) 80 | 81 | ## [0.4.0] - 2023-08-06 82 | 83 | * Refactor Client into Authenticator, RequestBuilder, Connection, ResponseHandler (6bee1e9) 84 | * Add configurable open timeout (1000f9d) 85 | * Allow configuration of content type (f33a732) 86 | 87 | ## [0.3.0] - 2023-08-04 88 | 89 | * Add accessors to X::Client (e61fa73) 90 | * Add configurable read timeout (41502b9) 91 | * Handle network-related errors (9ed1fb4) 92 | * Include response body in errors (a203e6a) 93 | 94 | ## [0.2.0] - 2023-08-02 95 | 96 | * Allow configuration of base URL (4bc0531) 97 | * Improve error handling (14dc0cd) 98 | 99 | ## [0.1.0] - 2023-08-02 100 | 101 | * Initial release 102 | -------------------------------------------------------------------------------- /test/x/too_many_requests_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "../test_helper" 2 | 3 | module X 4 | class TooManyRequestsTest < Minitest::Test 5 | cover TooManyRequests 6 | 7 | def setup 8 | response = Net::HTTPTooManyRequests.new("1.1", 429, "Too Many Requests") 9 | 10 | rate_limit(response) 11 | app_limit(response) 12 | user_limit(response) 13 | 14 | @exception = TooManyRequests.new(response:) 15 | end 16 | 17 | def rate_limit(response) 18 | Time.stub :now, Time.utc(1983, 11, 24) do 19 | response["x-rate-limit-reset"] = (Time.now + 60).to_i.to_s 20 | end 21 | response["x-rate-limit-limit"] = "100" 22 | response["x-rate-limit-remaining"] = "0" 23 | end 24 | 25 | def app_limit(response) 26 | Time.stub :now, Time.utc(1983, 11, 24) do 27 | response["x-app-limit-24hour-reset"] = (Time.now + 61).to_i.to_s 28 | end 29 | response["x-app-limit-24hour-limit"] = "100" 30 | response["x-app-limit-24hour-remaining"] = "0" 31 | end 32 | 33 | def user_limit(response) 34 | Time.stub :now, Time.utc(1983, 11, 24) do 35 | response["x-user-limit-24hour-remaining"] = (Time.now + 60).to_i.to_s 36 | end 37 | response["x-user-limit-24hour-reset"] = "100" 38 | response["x-user-limit-24hour-reset"] = "0" 39 | end 40 | 41 | def test_initialize_with_empty_response 42 | response = Net::HTTPTooManyRequests.new("1.1", 429, "Too Many Requests") 43 | exception = TooManyRequests.new(response:) 44 | 45 | assert_equal 0, exception.rate_limits.count 46 | assert_equal Time.at(0).utc, exception.reset_at 47 | assert_equal 0, exception.reset_in 48 | assert_equal "Too Many Requests", exception.message 49 | end 50 | 51 | def test_rate_limit 52 | Time.stub :now, Time.utc(1983, 11, 24) do 53 | @exception.response["x-app-limit-24hour-reset"] = (Time.now + 61).to_i.to_s 54 | @exception.response["x-app-limit-24hour-remaining"] = "0" 55 | 56 | assert_equal Time.now + 61, @exception.rate_limit.reset_at 57 | end 58 | end 59 | 60 | def test_rate_limits 61 | Time.stub :now, Time.utc(1983, 11, 24) do 62 | @exception.response["x-app-limit-24hour-limit"] = "200" 63 | @exception.response["x-app-limit-24hour-remaining"] = "0" 64 | limits = @exception.rate_limits 65 | 66 | assert_equal 2, limits.count 67 | assert_equal "rate-limit", limits.first.type 68 | assert_equal "app-limit-24hour", limits.last.type 69 | end 70 | end 71 | 72 | def test_rate_limits_exlude_non_exhausted_limits 73 | Time.stub :now, Time.utc(1983, 11, 24) do 74 | @exception.response["x-app-limit-24hour-limit"] = "200" 75 | @exception.response["x-app-limit-24hour-remaining"] = "1" 76 | limits = @exception.rate_limits 77 | 78 | assert_equal 1, limits.count 79 | assert_equal "rate-limit", limits.first.type 80 | end 81 | end 82 | 83 | def test_reset_at 84 | Time.stub :now, Time.utc(1983, 11, 24) do 85 | @exception.response["x-app-limit-24hour-remaining"] = "0" 86 | @exception.response["x-app-limit-24hour-reset"] = (Time.now + 200).to_i.to_s 87 | 88 | assert_equal Time.at(Time.now.to_i + 200), @exception.reset_at 89 | end 90 | end 91 | 92 | def test_reset_in 93 | Time.stub :now, Time.utc(1983, 11, 24) do 94 | @exception.response["x-app-limit-24hour-remaining"] = "0" 95 | @exception.response["x-app-limit-24hour-reset"] = (Time.now + 200).to_i.to_s 96 | 97 | assert_equal 200, @exception.reset_in 98 | end 99 | end 100 | 101 | def test_reset_in_ceil 102 | @exception.response["x-rate-limit-reset"] = (Time.now + 61).to_i.to_s 103 | 104 | assert_equal 61, @exception.reset_in 105 | end 106 | 107 | def test_retry_after 108 | Time.stub :now, Time.utc(1983, 11, 24) do 109 | @exception.response["x-app-limit-24hour-remaining"] = "0" 110 | @exception.response["x-app-limit-24hour-reset"] = (Time.now + 200).to_i.to_s 111 | 112 | assert_equal 200, @exception.retry_after 113 | end 114 | end 115 | end 116 | end 117 | -------------------------------------------------------------------------------- /test/x/oauth_authenticator_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "../test_helper" 2 | 3 | module X 4 | class OAuthAuthenticatorTest < Minitest::Test 5 | cover OAuthAuthenticator 6 | 7 | def setup 8 | @authenticator = OAuthAuthenticator.new(api_key: TEST_API_KEY, api_key_secret: TEST_API_KEY_SECRET, 9 | access_token: TEST_ACCESS_TOKEN, access_token_secret: TEST_ACCESS_TOKEN_SECRET) 10 | end 11 | 12 | def test_initialization 13 | assert_equal TEST_API_KEY, @authenticator.api_key 14 | assert_equal TEST_API_KEY_SECRET, @authenticator.api_key_secret 15 | assert_equal TEST_ACCESS_TOKEN, @authenticator.access_token 16 | assert_equal TEST_ACCESS_TOKEN_SECRET, @authenticator.access_token_secret 17 | end 18 | 19 | def test_default_oauth_nonce 20 | request = Net::HTTP::Get.new(URI("https://example.com/")) 21 | SecureRandom.stub :hex, TEST_OAUTH_NONCE do 22 | authorization = @authenticator.header(request)["Authorization"] 23 | 24 | assert_includes authorization, "oauth_nonce=\"#{TEST_OAUTH_NONCE}\"" 25 | end 26 | end 27 | 28 | def test_default_oauth_timestamp 29 | request = Net::HTTP::Get.new(URI("https://example.com/")) 30 | Time.stub :now, Time.utc(1983, 11, 24) do 31 | authorization = @authenticator.header(request)["Authorization"] 32 | 33 | assert_includes authorization, "oauth_timestamp=\"#{TEST_OAUTH_TIMESTAMP}\"" 34 | end 35 | end 36 | 37 | def test_header_contains_authorization_key 38 | request = Net::HTTP::Get.new(URI("https://example.com/")) 39 | header = @authenticator.header(request) 40 | 41 | assert header.key?("Authorization"), "Header does not contain \"Authorization\" key" 42 | end 43 | 44 | def test_header_starts_with_oauth 45 | request = Net::HTTP::Get.new(URI("https://example.com/")) 46 | authorization = @authenticator.header(request)["Authorization"] 47 | 48 | assert authorization.start_with?("OAuth ") 49 | end 50 | 51 | def test_header_contains_required_oauth_fields 52 | request = Net::HTTP::Get.new(URI("https://example.com/")) 53 | authorization = @authenticator.header(request)["Authorization"] 54 | 55 | assert_includes authorization, "oauth_consumer_key=\"#{TEST_API_KEY}\"" 56 | assert_includes authorization, "oauth_token=\"#{TEST_ACCESS_TOKEN}\"" 57 | end 58 | 59 | def test_header_contains_oauth_signature_method 60 | request = Net::HTTP::Get.new(URI("https://example.com/")) 61 | authorization = @authenticator.header(request)["Authorization"] 62 | 63 | assert_includes authorization, "oauth_signature_method=\"HMAC-SHA1\"" 64 | end 65 | 66 | def test_header_contains_oauth_version 67 | request = Net::HTTP::Get.new(URI("https://example.com/")) 68 | authorization = @authenticator.header(request)["Authorization"] 69 | 70 | assert_includes authorization, "oauth_version=\"1.0\"" 71 | end 72 | 73 | def test_header_in_alphabetical_order 74 | request = Net::HTTP::Get.new(URI("https://example.com/")) 75 | authorization = @authenticator.header(request)["Authorization"] 76 | oauth_keys = authorization.scan(/oauth_[a-z0-9_]+/) 77 | 78 | assert_equal oauth_keys.sort, oauth_keys, "OAuth keys are not sorted in alphabetical order" 79 | end 80 | 81 | def test_signature 82 | request = Net::HTTP::Get.new(URI("https://example.com/?query=test")) 83 | expected = "OAuth oauth_consumer_key=\"TEST_API_KEY\", oauth_nonce=\"TEST_OAUTH_NONCE\", " \ 84 | "oauth_signature=\"1kHVZMzcNj51v60H63%2FTZErArAk%3D\", oauth_signature_method=\"HMAC-SHA1\", " \ 85 | "oauth_timestamp=\"438480000\", oauth_token=\"TEST_ACCESS_TOKEN\", oauth_version=\"1.0\"" 86 | @authenticator.stub :default_oauth_params, test_oauth_params do 87 | authorization = @authenticator.header(request)["Authorization"] 88 | 89 | assert_equal expected, authorization 90 | end 91 | end 92 | 93 | def test_uri_without_query 94 | uri = URI("http://example.com/test?param=value") 95 | 96 | assert_equal "http://example.com/test", @authenticator.send(:uri_without_query, uri) 97 | 98 | uri_no_query = URI("http://example.com/test") 99 | 100 | assert_equal "http://example.com/test", @authenticator.send(:uri_without_query, uri_no_query) 101 | end 102 | 103 | def test_parse_query_params 104 | query_string = "param1=value1¶m2=value2" 105 | expected_hash = {"param1" => "value1", "param2" => "value2"} 106 | 107 | assert_equal expected_hash, @authenticator.send(:parse_query_params, query_string) 108 | end 109 | end 110 | end 111 | -------------------------------------------------------------------------------- /lib/x/client.rb: -------------------------------------------------------------------------------- 1 | require "forwardable" 2 | require_relative "bearer_token_authenticator" 3 | require_relative "connection" 4 | require_relative "oauth_authenticator" 5 | require_relative "redirect_handler" 6 | require_relative "request_builder" 7 | require_relative "response_parser" 8 | 9 | module X 10 | class Client 11 | extend Forwardable 12 | 13 | DEFAULT_BASE_URL = "https://api.twitter.com/2/".freeze 14 | DEFAULT_ARRAY_CLASS = Array 15 | DEFAULT_OBJECT_CLASS = Hash 16 | 17 | attr_accessor :base_url, :default_array_class, :default_object_class 18 | attr_reader :api_key, :api_key_secret, :access_token, :access_token_secret, :bearer_token 19 | 20 | def_delegators :@connection, :open_timeout, :read_timeout, :write_timeout, :proxy_url, :debug_output 21 | def_delegators :@connection, :open_timeout=, :read_timeout=, :write_timeout=, :proxy_url=, :debug_output= 22 | def_delegators :@redirect_handler, :max_redirects 23 | def_delegators :@redirect_handler, :max_redirects= 24 | 25 | def initialize(api_key: nil, api_key_secret: nil, access_token: nil, access_token_secret: nil, 26 | bearer_token: nil, 27 | base_url: DEFAULT_BASE_URL, 28 | open_timeout: Connection::DEFAULT_OPEN_TIMEOUT, 29 | read_timeout: Connection::DEFAULT_READ_TIMEOUT, 30 | write_timeout: Connection::DEFAULT_WRITE_TIMEOUT, 31 | debug_output: Connection::DEFAULT_DEBUG_OUTPUT, 32 | proxy_url: nil, 33 | default_array_class: DEFAULT_ARRAY_CLASS, 34 | default_object_class: DEFAULT_OBJECT_CLASS, 35 | max_redirects: RedirectHandler::DEFAULT_MAX_REDIRECTS) 36 | initialize_oauth(api_key, api_key_secret, access_token, access_token_secret, bearer_token) 37 | initialize_authenticator 38 | @base_url = base_url 39 | initialize_default_classes(default_array_class, default_object_class) 40 | @connection = Connection.new(open_timeout:, read_timeout:, write_timeout:, debug_output:, proxy_url:) 41 | @request_builder = RequestBuilder.new 42 | @redirect_handler = RedirectHandler.new(connection: @connection, request_builder: @request_builder, max_redirects:) 43 | @response_parser = ResponseParser.new 44 | end 45 | 46 | def get(endpoint, headers: {}, array_class: default_array_class, object_class: default_object_class) 47 | execute_request(:get, endpoint, headers:, array_class:, object_class:) 48 | end 49 | 50 | def post(endpoint, body = nil, headers: {}, array_class: default_array_class, object_class: default_object_class) 51 | execute_request(:post, endpoint, body:, headers:, array_class:, object_class:) 52 | end 53 | 54 | def put(endpoint, body = nil, headers: {}, array_class: default_array_class, object_class: default_object_class) 55 | execute_request(:put, endpoint, body:, headers:, array_class:, object_class:) 56 | end 57 | 58 | def delete(endpoint, headers: {}, array_class: default_array_class, object_class: default_object_class) 59 | execute_request(:delete, endpoint, headers:, array_class:, object_class:) 60 | end 61 | 62 | def api_key=(api_key) 63 | @api_key = api_key 64 | initialize_authenticator 65 | end 66 | 67 | def api_key_secret=(api_key_secret) 68 | @api_key_secret = api_key_secret 69 | initialize_authenticator 70 | end 71 | 72 | def access_token=(access_token) 73 | @access_token = access_token 74 | initialize_authenticator 75 | end 76 | 77 | def access_token_secret=(access_token_secret) 78 | @access_token_secret = access_token_secret 79 | initialize_authenticator 80 | end 81 | 82 | def bearer_token=(bearer_token) 83 | @bearer_token = bearer_token 84 | initialize_authenticator 85 | end 86 | 87 | private 88 | 89 | def initialize_oauth(api_key, api_key_secret, access_token, access_token_secret, bearer_token) 90 | @api_key = api_key 91 | @api_key_secret = api_key_secret 92 | @access_token = access_token 93 | @access_token_secret = access_token_secret 94 | @bearer_token = bearer_token 95 | end 96 | 97 | def initialize_default_classes(default_array_class, default_object_class) 98 | @default_array_class = default_array_class 99 | @default_object_class = default_object_class 100 | end 101 | 102 | def initialize_authenticator 103 | @authenticator = if api_key && api_key_secret && access_token && access_token_secret 104 | OAuthAuthenticator.new(api_key:, api_key_secret:, access_token:, access_token_secret:) 105 | elsif bearer_token 106 | BearerTokenAuthenticator.new(bearer_token:) 107 | elsif @authenticator.nil? 108 | Authenticator.new 109 | else 110 | @authenticator 111 | end 112 | end 113 | 114 | def execute_request(http_method, endpoint, body: nil, headers: {}, array_class: default_array_class, object_class: default_object_class) 115 | uri = URI.join(base_url, endpoint) 116 | request = @request_builder.build(http_method:, uri:, body:, headers:, authenticator: @authenticator) 117 | response = @connection.perform(request:) 118 | response = @redirect_handler.handle(response:, request:, base_url:, authenticator: @authenticator) 119 | @response_parser.parse(response:, array_class:, object_class:) 120 | end 121 | end 122 | end 123 | -------------------------------------------------------------------------------- /test/x/client_initailization_test.rb: -------------------------------------------------------------------------------- 1 | require "ostruct" 2 | require_relative "../test_helper" 3 | 4 | module X 5 | class ClientInitializationTest < Minitest::Test 6 | cover Client 7 | 8 | def setup 9 | @client = Client.new 10 | end 11 | 12 | def test_initialize_oauth_credentials 13 | client = Client.new(**test_oauth_credentials) 14 | 15 | authenticator = client.instance_variable_get(:@authenticator) 16 | 17 | assert_instance_of OAuthAuthenticator, authenticator 18 | assert_equal TEST_API_KEY, authenticator.api_key 19 | assert_equal TEST_API_KEY_SECRET, authenticator.api_key_secret 20 | assert_equal TEST_ACCESS_TOKEN, authenticator.access_token 21 | assert_equal TEST_ACCESS_TOKEN_SECRET, authenticator.access_token_secret 22 | end 23 | 24 | def test_missing_oauth_credentials 25 | test_oauth_credentials.each_key do |missing_credential| 26 | client = Client.new(**test_oauth_credentials.except(missing_credential)) 27 | 28 | assert_instance_of Authenticator, client.instance_variable_get(:@authenticator) 29 | end 30 | end 31 | 32 | def test_setting_oauth_credentials 33 | test_oauth_credentials.each do |credential, value| 34 | @client.public_send(:"#{credential}=", value) 35 | 36 | assert_equal value, @client.public_send(credential) 37 | end 38 | 39 | assert_instance_of OAuthAuthenticator, @client.instance_variable_get(:@authenticator) 40 | end 41 | 42 | def test_setting_oauth_credentials_reinitializes_authenticator 43 | test_oauth_credentials.each do |credential, value| 44 | initialize_authenticator_called = false 45 | @client.stub :initialize_authenticator, -> { initialize_authenticator_called = true } do 46 | @client.public_send(:"#{credential}=", value) 47 | end 48 | 49 | assert_equal value, @client.public_send(credential) 50 | assert initialize_authenticator_called, "Expected initialize_authenticator to be called" 51 | end 52 | end 53 | 54 | def test_setting_bearer_token 55 | @client.bearer_token = "bearer_token" 56 | 57 | authenticator = @client.instance_variable_get(:@authenticator) 58 | 59 | assert_equal "bearer_token", @client.bearer_token 60 | assert_instance_of BearerTokenAuthenticator, authenticator 61 | end 62 | 63 | def test_authenticator_remains_unchanged_if_no_new_credentials 64 | initial_authenticator = @client.instance_variable_get(:@authenticator) 65 | 66 | @client.api_key = nil 67 | @client.api_key_secret = nil 68 | @client.access_token = nil 69 | @client.access_token_secret = nil 70 | @client.bearer_token = nil 71 | 72 | new_authenticator = @client.instance_variable_get(:@authenticator) 73 | 74 | assert_equal initial_authenticator, new_authenticator 75 | end 76 | 77 | def test_initialize_with_default_connection_options 78 | connection = @client.instance_variable_get(:@connection) 79 | 80 | assert_equal Connection::DEFAULT_OPEN_TIMEOUT, connection.open_timeout 81 | assert_equal Connection::DEFAULT_READ_TIMEOUT, connection.read_timeout 82 | assert_equal Connection::DEFAULT_WRITE_TIMEOUT, connection.write_timeout 83 | assert_equal Connection::DEFAULT_DEBUG_OUTPUT, connection.debug_output 84 | assert_nil connection.proxy_url 85 | end 86 | 87 | def test_initialize_connection_options 88 | client = Client.new(open_timeout: 10, read_timeout: 20, write_timeout: 30, debug_output: $stderr, proxy_url: "https://user:pass@proxy.com:42") 89 | 90 | connection = client.instance_variable_get(:@connection) 91 | 92 | assert_equal 10, connection.open_timeout 93 | assert_equal 20, connection.read_timeout 94 | assert_equal 30, connection.write_timeout 95 | assert_equal $stderr, connection.debug_output 96 | assert_equal "https://user:pass@proxy.com:42", connection.proxy_url 97 | end 98 | 99 | def test_defaults 100 | @client = Client.new 101 | 102 | assert_equal "https://api.twitter.com/2/", @client.base_url 103 | assert_equal 10, @client.max_redirects 104 | assert_equal Hash, @client.default_object_class 105 | assert_equal Array, @client.default_array_class 106 | end 107 | 108 | def test_overwrite_defaults 109 | @client = Client.new(base_url: "https://api.twitter.com/1.1/", max_redirects: 5, default_object_class: OpenStruct, 110 | default_array_class: Set) 111 | 112 | assert_equal "https://api.twitter.com/1.1/", @client.base_url 113 | assert_equal 5, @client.max_redirects 114 | assert_equal OpenStruct, @client.default_object_class 115 | assert_equal Set, @client.default_array_class 116 | end 117 | 118 | def test_passes_options_to_redirect_handler 119 | client = Client.new(max_redirects: 5) 120 | connection = client.instance_variable_get(:@connection) 121 | request_builder = client.instance_variable_get(:@request_builder) 122 | redirect_handler = client.instance_variable_get(:@redirect_handler) 123 | max_redirects = redirect_handler.instance_variable_get(:@max_redirects) 124 | 125 | assert_equal connection, redirect_handler.connection 126 | assert_equal request_builder, redirect_handler.request_builder 127 | assert_equal 5, max_redirects 128 | end 129 | end 130 | end 131 | -------------------------------------------------------------------------------- /test/x/client_request_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "../test_helper" 2 | 3 | module X 4 | class ClientRequestTest < Minitest::Test 5 | cover Client 6 | 7 | def setup 8 | @client = Client.new 9 | end 10 | 11 | X::RequestBuilder::HTTP_METHODS.each_key do |http_method| 12 | define_method :"test_#{http_method}_request" do 13 | stub_request(http_method, "https://api.twitter.com/2/tweets") 14 | @client.public_send(http_method, "tweets") 15 | 16 | assert_requested http_method, "https://api.twitter.com/2/tweets" 17 | end 18 | 19 | define_method :"test_#{http_method}_request_with_headers" do 20 | headers = {"User-Agent" => "Custom User Agent"} 21 | stub_request(http_method, "https://api.twitter.com/2/tweets") 22 | @client.public_send(http_method, "tweets", headers:) 23 | 24 | assert_requested http_method, "https://api.twitter.com/2/tweets", headers: 25 | end 26 | 27 | define_method :"test_#{http_method}_request_with_custom_response_objects" do 28 | stub_request(http_method, "https://api.twitter.com/2/tweets") 29 | .to_return(body: '{"set": [1, 2, 2, 3]}', headers: {"Content-Type" => "application/json"}) 30 | ostruct = @client.public_send(http_method, "tweets", object_class: OpenStruct, array_class: Set) 31 | 32 | assert_equal OpenStruct.new(set: Set.new([1, 2, 3])), ostruct 33 | end 34 | 35 | define_method :"test_#{http_method}_request_with_custom_response_objects_client_configuration" do 36 | stub_request(http_method, "https://api.twitter.com/2/tweets") 37 | .to_return(body: '{"set": [1, 2, 2, 3]}', headers: {"Content-Type" => "application/json"}) 38 | client = Client.new(default_object_class: OpenStruct, default_array_class: Set) 39 | ostruct = client.public_send(http_method, "tweets") 40 | 41 | assert_equal OpenStruct.new(set: Set.new([1, 2, 3])), ostruct 42 | end 43 | end 44 | 45 | def test_execute_request_with_custom_response_objects_client_configuration 46 | stub_request(:get, "https://api.twitter.com/2/tweets") 47 | .to_return(body: '{"set": [1, 2, 2, 3]}', headers: {"Content-Type" => "application/json"}) 48 | client = Client.new(default_object_class: OpenStruct, default_array_class: Set) 49 | ostruct = client.send(:execute_request, :get, "tweets") 50 | 51 | assert_kind_of OpenStruct, ostruct 52 | assert_kind_of Set, ostruct.set 53 | assert_equal Set.new([1, 2, 3]), ostruct.set 54 | end 55 | 56 | def test_redirect_handler_preserves_authentication 57 | client = Client.new(bearer_token: TEST_BEARER_TOKEN, max_redirects: 5) 58 | stub_request(:get, "https://api.twitter.com/old_endpoint") 59 | .with(headers: {"Authorization" => /Bearer #{TEST_BEARER_TOKEN}/o}) 60 | .to_return(status: 301, headers: {"Location" => "https://api.twitter.com/new_endpoint"}) 61 | stub_request(:get, "https://api.twitter.com/new_endpoint") 62 | .with(headers: {"Authorization" => /Bearer #{TEST_BEARER_TOKEN}/o}) 63 | client.get("/old_endpoint") 64 | 65 | assert_requested :get, "https://api.twitter.com/old_endpoint" 66 | assert_requested :get, "https://api.twitter.com/new_endpoint" 67 | end 68 | 69 | def test_follows_301_redirect 70 | stub_request(:get, "https://api.twitter.com/old_endpoint") 71 | .to_return(status: 301, headers: {"Location" => "https://api.twitter.com/new_endpoint"}) 72 | stub_request(:get, "https://api.twitter.com/new_endpoint") 73 | @client.get("/old_endpoint") 74 | 75 | assert_requested :get, "https://api.twitter.com/new_endpoint" 76 | end 77 | 78 | def test_follows_302_redirect 79 | stub_request(:get, "https://api.twitter.com/old_endpoint") 80 | .to_return(status: 302, headers: {"Location" => "https://api.twitter.com/new_endpoint"}) 81 | stub_request(:get, "https://api.twitter.com/new_endpoint") 82 | @client.get("/old_endpoint") 83 | 84 | assert_requested :get, "https://api.twitter.com/new_endpoint" 85 | end 86 | 87 | def test_follows_307_redirect 88 | stub_request(:post, "https://api.twitter.com/temporary_redirect") 89 | .to_return(status: 307, headers: {"Location" => "https://api.twitter.com/new_endpoint"}) 90 | body = {key: "value"}.to_json 91 | stub_request(:post, "https://api.twitter.com/new_endpoint") 92 | .with(body:) 93 | @client.post("/temporary_redirect", body) 94 | 95 | assert_requested :post, "https://api.twitter.com/new_endpoint", body: 96 | end 97 | 98 | def test_follows_308_redirect 99 | stub_request(:put, "https://api.twitter.com/temporary_redirect") 100 | .to_return(status: 308, headers: {"Location" => "https://api.twitter.com/new_endpoint"}) 101 | body = {key: "value"}.to_json 102 | stub_request(:put, "https://api.twitter.com/new_endpoint") 103 | .with(body:) 104 | @client.put("/temporary_redirect", body) 105 | 106 | assert_requested :put, "https://api.twitter.com/new_endpoint", body: 107 | end 108 | 109 | def test_avoids_infinite_redirect_loop 110 | stub_request(:get, "https://api.twitter.com/infinite_loop") 111 | .to_return(status: 302, headers: {"Location" => "https://api.twitter.com/infinite_loop"}) 112 | 113 | assert_raises TooManyRedirects do 114 | @client.get("/infinite_loop") 115 | end 116 | end 117 | end 118 | end 119 | -------------------------------------------------------------------------------- /test/x/connection_test.rb: -------------------------------------------------------------------------------- 1 | require "net/http" 2 | require "uri" 3 | require_relative "../test_helper" 4 | 5 | module X 6 | class ConnectionTest < Minitest::Test 7 | cover Connection 8 | 9 | def setup 10 | @connection = Connection.new 11 | end 12 | 13 | def test_initialization_defaults 14 | assert_equal Connection::DEFAULT_OPEN_TIMEOUT, @connection.open_timeout 15 | assert_equal Connection::DEFAULT_READ_TIMEOUT, @connection.read_timeout 16 | assert_equal Connection::DEFAULT_WRITE_TIMEOUT, @connection.write_timeout 17 | assert_equal Connection::DEFAULT_DEBUG_OUTPUT, @connection.debug_output 18 | assert_nil @connection.proxy_url 19 | end 20 | 21 | def test_custom_initialization 22 | connection = Connection.new(open_timeout: 10, read_timeout: 20, write_timeout: 30, debug_output: $stderr, 23 | proxy_url: "http://example.com:8080") 24 | 25 | assert_equal 10, connection.open_timeout 26 | assert_equal 20, connection.read_timeout 27 | assert_equal 30, connection.write_timeout 28 | assert_equal $stderr, connection.debug_output 29 | assert_equal "http://example.com:8080", connection.proxy_url 30 | end 31 | 32 | def test_http_client_defaults 33 | http_client = @connection.send(:build_http_client) 34 | 35 | assert_equal Connection::DEFAULT_HOST, http_client.address 36 | assert_equal Connection::DEFAULT_PORT, http_client.port 37 | assert_equal Connection::DEFAULT_OPEN_TIMEOUT, http_client.open_timeout 38 | assert_equal Connection::DEFAULT_READ_TIMEOUT, http_client.read_timeout 39 | assert_equal Connection::DEFAULT_WRITE_TIMEOUT, http_client.write_timeout 40 | end 41 | 42 | def test_debug_output 43 | http_client = @connection.send(:build_http_client) 44 | 45 | assert_equal Connection::DEFAULT_DEBUG_OUTPUT, http_client.instance_variable_get(:@debug_output) 46 | end 47 | 48 | def test_proxy 49 | @connection.proxy_url = "http://user:pass@example.com:8080" 50 | 51 | assert_equal URI("http://user:pass@example.com:8080"), @connection.proxy_uri 52 | assert_equal "example.com", @connection.proxy_host 53 | assert_equal "user", @connection.proxy_user 54 | assert_equal "pass", @connection.proxy_pass 55 | assert_equal 8080, @connection.proxy_port 56 | end 57 | 58 | def test_host_port_with_proxy 59 | connection = Connection.new(proxy_url: "https://user:pass@example.com") 60 | http_client = connection.send(:build_http_client, "example.com", 8080) 61 | 62 | assert_predicate http_client, :proxy? 63 | assert_equal "example.com", http_client.address 64 | assert_equal 8080, http_client.port 65 | end 66 | 67 | def test_client_properties 68 | connection = Connection.new(open_timeout: 10, read_timeout: 20, write_timeout: 30, debug_output: $stderr, 69 | proxy_url: "https://proxy.com") 70 | http_client = connection.send(:build_http_client) 71 | 72 | assert_predicate http_client, :proxy? 73 | assert_equal 10, http_client.open_timeout 74 | assert_equal 20, http_client.read_timeout 75 | assert_equal 30, http_client.write_timeout 76 | assert_equal $stderr, http_client.instance_variable_get(:@debug_output) 77 | end 78 | 79 | def test_invalid_proxy_url 80 | error = assert_raises(ArgumentError) { @connection.proxy_url = "ftp://ftp.twitter.com/" } 81 | 82 | assert_equal "Invalid proxy URL: ftp://ftp.twitter.com/", error.message 83 | end 84 | 85 | def test_proxy_settings_are_respected_in_http_client 86 | @connection.proxy_url = "http://user:pass@example.com:8080" 87 | http_client = @connection.send(:build_http_client) 88 | 89 | assert_equal "example.com", http_client.proxy_address 90 | assert_equal 8080, http_client.proxy_port 91 | assert_equal "user", http_client.proxy_user 92 | assert_equal "pass", http_client.proxy_pass 93 | end 94 | 95 | def test_set_env_proxy 96 | old_value = ENV.fetch("http_proxy", nil) 97 | ENV["http_proxy"] = "https://user:pass@example.com:8080" 98 | http_client = Connection.new.send(:build_http_client) 99 | 100 | assert_predicate http_client, :proxy? 101 | assert_equal "user", http_client.proxy_user 102 | assert_equal "pass", http_client.proxy_pass 103 | assert_equal "example.com", http_client.proxy_address 104 | assert_equal 8080, http_client.proxy_port 105 | ensure 106 | ENV["http_proxy"] = old_value 107 | end 108 | 109 | def test_perform 110 | stub_request(:get, "http://example.com:80") 111 | request = Net::HTTP::Get.new(URI("http://example.com:80")) 112 | @connection.perform(request:) 113 | 114 | assert_requested :get, "http://example.com:80" 115 | end 116 | 117 | def test_network_error 118 | stub_request(:get, "https://example.com").to_raise(Errno::ECONNREFUSED) 119 | request = Net::HTTP::Get.new(URI("https://example.com")) 120 | error = assert_raises(NetworkError) { @connection.perform(request:) } 121 | 122 | assert_equal "Network error: Connection refused - Exception from WebMock", error.message 123 | end 124 | 125 | def test_no_host_or_port 126 | stub_request(:get, "http://api.twitter.com:443/2/tweets") 127 | request = Net::HTTP::Get.new(URI("http://api.twitter.com:443/2/tweets")) 128 | request.stub(:uri, URI("/2/tweets")) { @connection.perform(request:) } 129 | 130 | assert_requested :get, "http://api.twitter.com:443/2/tweets" 131 | end 132 | end 133 | end 134 | -------------------------------------------------------------------------------- /test/x/response_parser_test.rb: -------------------------------------------------------------------------------- 1 | require "ostruct" 2 | require_relative "../test_helper" 3 | 4 | module X 5 | class ResponseParserTest < Minitest::Test 6 | cover ResponseParser 7 | 8 | def setup 9 | @response_parser = ResponseParser.new 10 | @uri = URI("http://example.com") 11 | end 12 | 13 | def response(uri = @uri) 14 | Net::HTTP.get_response(uri) 15 | end 16 | 17 | def test_success_response 18 | stub_request(:get, @uri.to_s) 19 | .to_return(body: '{"message": "success"}', headers: {"Content-Type" => "application/json"}) 20 | 21 | assert_equal({"message" => "success"}, @response_parser.parse(response:)) 22 | end 23 | 24 | def test_non_json_success_response 25 | stub_request(:get, @uri.to_s) 26 | .to_return(body: "", headers: {"Content-Type" => "text/html"}) 27 | 28 | assert_nil @response_parser.parse(response:) 29 | end 30 | 31 | def test_that_it_parses_204_no_content_response 32 | stub_request(:get, @uri.to_s).to_return(status: 204) 33 | 34 | assert_nil @response_parser.parse(response:) 35 | end 36 | 37 | def test_bad_request_error 38 | stub_request(:get, @uri.to_s).to_return(status: 400) 39 | exception = assert_raises(BadRequest) { @response_parser.parse(response:) } 40 | 41 | assert_kind_of Net::HTTPBadRequest, exception.response 42 | assert_equal "400", exception.code 43 | end 44 | 45 | def test_unknown_error_code 46 | stub_request(:get, @uri.to_s).to_return(status: 418) 47 | assert_raises(Error) { @response_parser.parse(response:) } 48 | end 49 | 50 | def test_too_many_requests_with_headers 51 | stub_request(:get, @uri.to_s) 52 | .to_return(status: 429, headers: {"x-rate-limit-remaining" => "0"}) 53 | exception = assert_raises(TooManyRequests) { @response_parser.parse(response:) } 54 | 55 | assert_predicate exception.rate_limits.first.remaining, :zero? 56 | end 57 | 58 | def test_error_with_title_only 59 | stub_request(:get, @uri.to_s) 60 | .to_return(status: [400, "Bad Request"], body: '{"title": "Some Error"}', headers: {"Content-Type" => "application/json"}) 61 | exception = assert_raises(BadRequest) { @response_parser.parse(response:) } 62 | 63 | assert_equal "Bad Request", exception.message 64 | end 65 | 66 | def test_error_with_detail_only 67 | stub_request(:get, @uri.to_s) 68 | .to_return(status: [400, "Bad Request"], 69 | body: '{"detail": "Something went wrong"}', headers: {"Content-Type" => "application/json"}) 70 | exception = assert_raises(BadRequest) { @response_parser.parse(response:) } 71 | 72 | assert_equal "Bad Request", exception.message 73 | end 74 | 75 | def test_error_with_title_and_detail_error_message 76 | stub_request(:get, @uri.to_s) 77 | .to_return(status: 400, 78 | body: '{"title": "Some Error", "detail": "Something went wrong"}', headers: {"Content-Type" => "application/json"}) 79 | exception = assert_raises(BadRequest) { @response_parser.parse(response:) } 80 | 81 | assert_equal("Some Error: Something went wrong", exception.message) 82 | end 83 | 84 | def test_error_with_error_message 85 | stub_request(:get, @uri.to_s) 86 | .to_return(status: 400, body: '{"error": "Some Error"}', headers: {"Content-Type" => "application/json"}) 87 | exception = assert_raises(BadRequest) { @response_parser.parse(response:) } 88 | 89 | assert_equal("Some Error", exception.message) 90 | end 91 | 92 | def test_error_with_errors_array_message 93 | stub_request(:get, @uri.to_s) 94 | .to_return(status: 400, 95 | body: '{"errors": [{"message": "Some Error"}, {"message": "Another Error"}]}', headers: {"Content-Type" => "application/json"}) 96 | exception = assert_raises(BadRequest) { @response_parser.parse(response:) } 97 | 98 | assert_equal("Some Error, Another Error", exception.message) 99 | end 100 | 101 | def test_error_with_errors_message 102 | stub_request(:get, @uri.to_s) 103 | .to_return(status: 400, body: '{"errors": {"message": "Some Error"}}', headers: {"Content-Type" => "application/json"}) 104 | exception = assert_raises(BadRequest) { @response_parser.parse(response:) } 105 | 106 | assert_empty exception.message 107 | end 108 | 109 | def test_non_json_error_response 110 | stub_request(:get, @uri.to_s) 111 | .to_return(status: [400, "Bad Request"], body: "Bad Request", headers: {"Content-Type" => "text/html"}) 112 | exception = assert_raises(BadRequest) { @response_parser.parse(response:) } 113 | 114 | assert_equal "Bad Request", exception.message 115 | end 116 | 117 | def test_default_response_objects 118 | stub_request(:get, @uri.to_s) 119 | .to_return(body: '{"array": [1, 2, 2, 3]}', headers: {"Content-Type" => "application/json"}) 120 | hash = @response_parser.parse(response:) 121 | 122 | assert_kind_of Hash, hash 123 | assert_kind_of Array, hash["array"] 124 | assert_equal [1, 2, 2, 3], hash["array"] 125 | end 126 | 127 | def test_custom_response_objects 128 | stub_request(:get, @uri.to_s) 129 | .to_return(body: '{"set": [1, 2, 2, 3]}', headers: {"Content-Type" => "application/json"}) 130 | ostruct = @response_parser.parse(response:, object_class: OpenStruct, array_class: Set) 131 | 132 | assert_kind_of OpenStruct, ostruct 133 | assert_kind_of Set, ostruct.set 134 | assert_equal Set.new([1, 2, 3]), ostruct.set 135 | end 136 | end 137 | end 138 | -------------------------------------------------------------------------------- /sponsor_logos/better_stack.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /lib/x/media_uploader.rb: -------------------------------------------------------------------------------- 1 | require "securerandom" 2 | require "tmpdir" 3 | 4 | module X 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 | PROCESSING_INFO_STATES = %w[failed succeeded].freeze 18 | 19 | def upload(client:, file_path:, media_category:, media_type: infer_media_type(file_path, media_category), 20 | boundary: SecureRandom.hex) 21 | validate!(file_path:, media_category:) 22 | upload_client = client.dup.tap { |c| c.base_url = "https://upload.twitter.com/1.1/" } 23 | upload_body = construct_upload_body(file_path:, media_type:, boundary:) 24 | headers = {"Content-Type" => "multipart/form-data, boundary=#{boundary}"} 25 | upload_client.post("media/upload.json?media_category=#{media_category}", upload_body, headers:) 26 | end 27 | 28 | def chunked_upload(client:, file_path:, media_category:, media_type: infer_media_type(file_path, 29 | media_category), boundary: SecureRandom.hex, chunk_size_mb: 8) 30 | validate!(file_path:, media_category:) 31 | upload_client = client.dup.tap { |c| c.base_url = "https://upload.twitter.com/1.1/" } 32 | media = init(upload_client:, file_path:, media_type:, media_category:) 33 | chunk_size = chunk_size_mb * BYTES_PER_MB 34 | append(upload_client:, file_paths: split(file_path, chunk_size), 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"] || PROCESSING_INFO_STATES.include?(status["processing_info"]["state"]) 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 split(file_path, chunk_size) 68 | file_number = -1 69 | file_paths = [] # @type var file_paths: Array[String] 70 | 71 | File.open(file_path, "rb") do |f| 72 | while (chunk = f.read(chunk_size)) 73 | path = "#{Dir.mktmpdir}/x#{format("%03d", file_number += 1)}" 74 | File.binwrite(path, chunk) 75 | file_paths << path 76 | end 77 | end 78 | file_paths 79 | end 80 | 81 | def init(upload_client:, file_path:, media_type:, media_category:) 82 | total_bytes = File.size(file_path) 83 | query = "command=INIT&media_type=#{media_type}&media_category=#{media_category}&total_bytes=#{total_bytes}" 84 | upload_client.post("media/upload.json?#{query}") 85 | end 86 | 87 | def append(upload_client:, file_paths:, media:, media_type:, boundary: SecureRandom.hex) 88 | threads = file_paths.map.with_index do |file_path, index| 89 | Thread.new do 90 | upload_body = construct_upload_body(file_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:, file_path:, headers:) 94 | end 95 | end 96 | threads.each(&:join) 97 | end 98 | 99 | def upload_chunk(upload_client:, query:, upload_body:, file_path:, headers: {}) 100 | upload_client.post("media/upload.json?#{query}", upload_body, headers:) 101 | rescue NetworkError, ServerError 102 | retries ||= 0 103 | ((retries += 1) < MAX_RETRIES) ? retry : raise 104 | ensure 105 | cleanup_file(file_path) 106 | end 107 | 108 | def cleanup_file(file_path) 109 | dirname = File.dirname(file_path) 110 | File.delete(file_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 123 | -------------------------------------------------------------------------------- /test/x/redirect_handler_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "../test_helper" 2 | 3 | module X 4 | class RedirectHandlerTest < Minitest::Test 5 | cover RedirectHandler 6 | 7 | def setup 8 | @connection = Connection.new 9 | @request_builder = RequestBuilder.new 10 | @redirect_handler = RedirectHandler.new(connection: @connection, request_builder: @request_builder) 11 | end 12 | 13 | def test_initialize_with_defaults 14 | redirect_handler = RedirectHandler.new 15 | 16 | assert_instance_of Connection, redirect_handler.connection 17 | assert_instance_of RequestBuilder, redirect_handler.request_builder 18 | end 19 | 20 | def test_handle_with_no_redirects 21 | request = Net::HTTP::Get.new("/some_path") 22 | 23 | response = Net::HTTPSuccess.new("1.1", "200", "OK") 24 | 25 | assert_equal(response, @redirect_handler.handle(response:, request:, base_url: "http://example.com")) 26 | end 27 | 28 | def test_handle_with_one_redirect 29 | authenticator = BearerTokenAuthenticator.new(bearer_token: TEST_BEARER_TOKEN) 30 | request = Net::HTTP::Get.new("/") 31 | stub_request(:get, "http://www.example.com/").with(headers: {"Authorization" => /Bearer #{TEST_BEARER_TOKEN}/o}) 32 | 33 | response = Net::HTTPFound.new("1.1", "302", "Found") 34 | response["Location"] = "http://www.example.com" 35 | 36 | @redirect_handler.handle(response:, request:, base_url: "http://example.com", authenticator:) 37 | 38 | assert_requested :get, "http://www.example.com" 39 | end 40 | 41 | def test_handle_with_two_redirects 42 | request = Net::HTTP::Delete.new("/") 43 | stub_request(:delete, "http://example.com/2").to_return(status: 307, headers: {"Location" => "http://example.com/3"}) 44 | stub_request(:delete, "http://example.com/3") 45 | 46 | response = Net::HTTPFound.new("1.1", "307", "Found") 47 | response["Location"] = "http://example.com/2" 48 | 49 | @redirect_handler.handle(response:, request:, base_url: "http://example.com") 50 | 51 | assert_requested :delete, "http://example.com/2" 52 | assert_requested :delete, "http://example.com/3" 53 | end 54 | 55 | def test_handle_with_relative_url 56 | request = Net::HTTP::Get.new("/some_path") 57 | stub_request(:get, "http://example.com/some_relative_path") 58 | 59 | response = Net::HTTPFound.new("1.1", "302", "Found") 60 | response["Location"] = "/some_relative_path" 61 | 62 | @redirect_handler.handle(response:, request:, base_url: "http://example.com") 63 | 64 | assert_requested :get, "http://example.com/some_relative_path" 65 | end 66 | 67 | def test_handle_with_301_moved_permanently 68 | request = Net::HTTP::Get.new("/some_path") 69 | stub_request(:get, "http://example.com/new_path") 70 | 71 | response = Net::HTTPMovedPermanently.new("1.1", "301", "Moved Permanently") 72 | response["Location"] = "http://example.com/new_path" 73 | 74 | @redirect_handler.handle(response:, request:, base_url: "http://example.com") 75 | 76 | assert_requested :get, "http://example.com/new_path" 77 | end 78 | 79 | def test_handle_with_302_found 80 | request = Net::HTTP::Get.new("/some_path") 81 | stub_request(:get, "http://example.com/temp_path") 82 | 83 | response = Net::HTTPFound.new("1.1", "302", "Found") 84 | response["Location"] = "http://example.com/temp_path" 85 | 86 | @redirect_handler.handle(response:, request:, base_url: "http://example.com") 87 | 88 | assert_requested :get, "http://example.com/temp_path" 89 | end 90 | 91 | def test_handle_with_303_see_other 92 | request = Net::HTTP::Post.new("/some_path") 93 | stub_request(:post, "http://example.com/some_path") 94 | stub_request(:get, "http://example.com/other_path") 95 | 96 | response = Net::HTTPSeeOther.new("1.1", "303", "See Other") 97 | response["Location"] = "http://example.com/other_path" 98 | 99 | @redirect_handler.handle(response:, request:, base_url: "http://example.com") 100 | 101 | assert_requested :get, "http://example.com/other_path" 102 | end 103 | 104 | def test_handle_with_307_temporary_redirect 105 | request = Net::HTTP::Post.new("/some_path") 106 | request.body = "request_body" 107 | stub_request(:post, "http://example.com/temp_path") 108 | 109 | response = Net::HTTPTemporaryRedirect.new("1.1", "307", "Temporary Redirect") 110 | response["Location"] = "http://example.com/temp_path" 111 | 112 | @redirect_handler.handle(response:, request:, base_url: "http://example.com") 113 | 114 | assert_requested :post, "http://example.com/temp_path", body: "request_body" 115 | end 116 | 117 | def test_handle_with_308_permanent_redirect 118 | request = Net::HTTP::Post.new("/some_path") 119 | request.body = "request_body" 120 | stub_request(:post, "http://example.com/new_path") 121 | 122 | response = Net::HTTPPermanentRedirect.new("1.1", "308", "Permanent Redirect") 123 | response["Location"] = "http://example.com/new_path" 124 | 125 | @redirect_handler.handle(response:, request:, base_url: "http://example.com") 126 | 127 | assert_requested :post, "http://example.com/new_path", body: "request_body" 128 | end 129 | 130 | def test_handle_with_too_many_redirects 131 | request = Net::HTTP::Get.new("/some_path") 132 | stub_request(:get, "http://example.com/some_path").to_return(status: 302, headers: {"Location" => "http://example.com/some_path"}) 133 | 134 | response = Net::HTTPFound.new("1.1", "302", "Found") 135 | response["Location"] = "http://example.com/some_path" 136 | 137 | e = assert_raises(TooManyRedirects) do 138 | @redirect_handler.handle(response:, request:, base_url: "http://example.com") 139 | end 140 | 141 | assert_equal "Too many redirects", e.message 142 | assert_requested :get, "http://example.com/some_path", times: RedirectHandler::DEFAULT_MAX_REDIRECTS + 1 143 | end 144 | end 145 | end 146 | -------------------------------------------------------------------------------- /test/x/media_uploader_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "../test_helper" 2 | require_relative "../../lib/x/media_uploader" 3 | 4 | module X 5 | class MediaUploaderTest < Minitest::Test 6 | cover MediaUploader 7 | 8 | def setup 9 | @client = Client.new 10 | @upload_client = Client.new(base_url: "https://upload.twitter.com/1.1/") 11 | @media = {"media_id" => TEST_MEDIA_ID} 12 | end 13 | 14 | def test_upload 15 | file_path = "test/sample_files/sample.jpg" 16 | stub_request(:post, "https://upload.twitter.com/1.1/media/upload.json?media_category=#{MediaUploader::TWEET_IMAGE}") 17 | .to_return(body: @media.to_json, headers: {"Content-Type" => "application/json"}) 18 | 19 | result = MediaUploader.upload(client: @client, file_path:, media_category: MediaUploader::TWEET_IMAGE, boundary: "AaB03x") 20 | 21 | assert_equal TEST_MEDIA_ID, result["media_id"] 22 | end 23 | 24 | def test_chunked_upload 25 | file_path = "test/sample_files/sample.mp4" 26 | total_bytes = File.size(file_path) 27 | chunk_size_mb = (total_bytes - 1) / MediaUploader::BYTES_PER_MB.to_f 28 | stub_request(:post, "https://upload.twitter.com/1.1/media/upload.json?command=INIT&media_category=tweet_video&media_type=video/mp4&total_bytes=#{total_bytes}") 29 | .to_return(status: 202, headers: {"content-type" => "application/json"}, body: @media.to_json) 30 | 2.times { |segment_index| stub_request(:post, "https://upload.twitter.com/1.1/media/upload.json?command=APPEND&media_id=#{TEST_MEDIA_ID}&segment_index=#{segment_index}").to_return(status: 204) } 31 | stub_request(:post, "https://upload.twitter.com/1.1/media/upload.json?command=FINALIZE&media_id=#{TEST_MEDIA_ID}") 32 | .to_return(status: 201, headers: {"content-type" => "application/json"}, body: @media.to_json) 33 | 34 | response = MediaUploader.chunked_upload(client: @client, file_path:, media_category: MediaUploader::TWEET_VIDEO, chunk_size_mb:) 35 | 36 | assert_equal TEST_MEDIA_ID, response["media_id"] 37 | end 38 | 39 | def test_append_method 40 | file_path = "test/sample_files/sample.mp4" 41 | file_paths = MediaUploader.send(:split, file_path, File.size(file_path) - 1) 42 | 43 | file_paths.each_with_index do |_chunk_path, segment_index| 44 | stub_request(:post, "https://upload.twitter.com/1.1/media/upload.json?command=APPEND&media_id=#{TEST_MEDIA_ID}&segment_index=#{segment_index}") 45 | .with(headers: {"Content-Type" => "multipart/form-data, boundary=AaB03x"}).to_return(status: 204) 46 | end 47 | MediaUploader.send(:append, upload_client: @upload_client, file_paths:, media: @media, media_type: "video/mp4", boundary: "AaB03x") 48 | 49 | file_paths.each_with_index { |_, segment_index| assert_requested(:post, "https://upload.twitter.com/1.1/media/upload.json?command=APPEND&media_id=#{TEST_MEDIA_ID}&segment_index=#{segment_index}") } 50 | end 51 | 52 | def test_await_processing 53 | stub_request(:get, "https://upload.twitter.com/1.1/media/upload.json?command=STATUS&media_id=#{TEST_MEDIA_ID}") 54 | .to_return(headers: {"content-type" => "application/json"}, body: '{"processing_info": {"state": "pending"}}') 55 | .to_return(headers: {"content-type" => "application/json"}, body: '{"processing_info": {"state": "succeeded"}}') 56 | result = MediaUploader.await_processing(client: @client, media: @media) 57 | 58 | assert_equal "succeeded", result["processing_info"]["state"] 59 | end 60 | 61 | def test_await_processing_and_failed 62 | stub_request(:get, "https://upload.twitter.com/1.1/media/upload.json?command=STATUS&media_id=#{TEST_MEDIA_ID}") 63 | .to_return(headers: {"content-type" => "application/json"}, body: '{"processing_info": {"state": "pending"}}') 64 | .to_return(headers: {"content-type" => "application/json"}, body: '{"processing_info": {"state": "failed"}}') 65 | result = MediaUploader.await_processing(client: @client, media: @media) 66 | 67 | assert_equal "failed", result["processing_info"]["state"] 68 | end 69 | 70 | def test_retry 71 | file_path = "test/sample_files/sample.mp4" 72 | stub_request(:post, "https://upload.twitter.com/1.1/media/upload.json?command=INIT&media_category=tweet_video&media_type=video/mp4&total_bytes=#{File.size(file_path)}") 73 | .to_return(status: 202, headers: {"content-type" => "application/json"}, body: @media.to_json) 74 | stub_request(:post, "https://upload.twitter.com/1.1/media/upload.json?command=APPEND&media_id=#{TEST_MEDIA_ID}&segment_index=0") 75 | .to_return(status: 500).to_return(status: 204) 76 | stub_request(:post, "https://upload.twitter.com/1.1/media/upload.json?command=FINALIZE&media_id=#{TEST_MEDIA_ID}") 77 | .to_return(status: 201, headers: {"content-type" => "application/json"}, body: @media.to_json) 78 | 79 | assert MediaUploader.chunked_upload(client: @client, file_path:, media_category: MediaUploader::TWEET_VIDEO) 80 | end 81 | 82 | def test_validate_with_valid_params 83 | file_path = "test/sample_files/sample.jpg" 84 | 85 | assert_nil MediaUploader.send(:validate!, file_path:, media_category: MediaUploader::TWEET_IMAGE) 86 | end 87 | 88 | def test_validate_with_invalid_file_path 89 | file_path = "invalid/file/path" 90 | assert_raises(RuntimeError) do 91 | MediaUploader.send(:validate!, file_path:, media_category: MediaUploader::TWEET_IMAGE) 92 | end 93 | end 94 | 95 | def test_validate_with_invalid_media_category 96 | file_path = "test/sample_files/sample.jpg" 97 | assert_raises(ArgumentError) do 98 | MediaUploader.send(:validate!, file_path:, media_category: "invalid_category") 99 | end 100 | end 101 | 102 | def test_infer_media_type_for_gif 103 | assert_equal MediaUploader::GIF_MIME_TYPE, MediaUploader.send(:infer_media_type, "test/sample_files/sample.gif", "tweet_gif") 104 | end 105 | 106 | def test_infer_media_type_for_jpg 107 | assert_equal MediaUploader::JPEG_MIME_TYPE, MediaUploader.send(:infer_media_type, "test/sample_files/sample.jpg", "tweet_image") 108 | end 109 | 110 | def test_infer_media_type_for_mp4 111 | assert_equal MediaUploader::MP4_MIME_TYPE, MediaUploader.send(:infer_media_type, "test/sample_files/sample.mp4", "tweet_video") 112 | end 113 | 114 | def test_infer_media_type_for_png 115 | assert_equal MediaUploader::PNG_MIME_TYPE, MediaUploader.send(:infer_media_type, "test/sample_files/sample.png", "tweet_image") 116 | end 117 | 118 | def test_infer_media_type_for_srt 119 | assert_equal MediaUploader::SUBRIP_MIME_TYPE, MediaUploader.send(:infer_media_type, "test/sample_files/sample.srt", "subtitles") 120 | end 121 | 122 | def test_infer_media_type_for_webp 123 | assert_equal MediaUploader::WEBP_MIME_TYPE, MediaUploader.send(:infer_media_type, "test/sample_files/sample.webp", "tweet_image") 124 | end 125 | 126 | def test_infer_media_type_with_default 127 | assert_equal MediaUploader::DEFAULT_MIME_TYPE, MediaUploader.send(:infer_media_type, "test/sample_files/sample.dne", "tweet_image") 128 | end 129 | end 130 | end 131 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![tests](https://github.com/sferik/x-ruby/actions/workflows/test.yml/badge.svg)](https://github.com/sferik/x-ruby/actions/workflows/test.yml) 2 | [![mutation tests](https://github.com/sferik/x-ruby/actions/workflows/mutant.yml/badge.svg)](https://github.com/sferik/x-ruby/actions/workflows/mutant.yml) 3 | [![linter](https://github.com/sferik/x-ruby/actions/workflows/lint.yml/badge.svg)](https://github.com/sferik/x-ruby/actions/workflows/lint.yml) 4 | [![typer checker](https://github.com/sferik/x-ruby/actions/workflows/steep.yml/badge.svg)](https://github.com/sferik/x-ruby/actions/workflows/steep.yml) 5 | [![maintainability](https://api.codeclimate.com/v1/badges/40bbddf2c9170742ca9e/maintainability)](https://codeclimate.com/github/sferik/x-ruby/maintainability) 6 | [![gem version](https://badge.fury.io/rb/x.svg)](https://rubygems.org/gems/x) 7 | 8 | # A [Ruby](https://www.ruby-lang.org) interface to the [X API](https://developer.x.com) 9 | 10 | ## Follow 11 | 12 | For updates and announcements, follow [this gem](https://x.com/gem) and [its creator](https://x.com/sferik) on X. 13 | 14 | ## Installation 15 | 16 | Install the gem and add to the application's Gemfile: 17 | 18 | bundle add x 19 | 20 | Or, if Bundler is not being used to manage dependencies: 21 | 22 | gem install x 23 | 24 | ## Usage 25 | 26 | First, obtain X credentails from . 27 | 28 | ```ruby 29 | require "x" 30 | 31 | x_credentials = { 32 | api_key: "INSERT YOUR X API KEY HERE", 33 | api_key_secret: "INSERT YOUR X API KEY SECRET HERE", 34 | access_token: "INSERT YOUR X ACCESS TOKEN HERE", 35 | access_token_secret: "INSERT YOUR X ACCESS TOKEN SECRET HERE", 36 | } 37 | 38 | # Initialize an X API client with your OAuth credentials 39 | x_client = X::Client.new(**x_credentials) 40 | 41 | # Get data about yourself 42 | x_client.get("users/me") 43 | # {"data"=>{"id"=>"7505382", "name"=>"Erik Berlin", "username"=>"sferik"}} 44 | 45 | # Post 46 | post = x_client.post("tweets", '{"text":"Hello, World! (from @gem)"}') 47 | # {"data"=>{"edit_history_tweet_ids"=>["1234567890123456789"], "id"=>"1234567890123456789", "text"=>"Hello, World! (from @gem)"}} 48 | 49 | # Delete the post 50 | x_client.delete("tweets/#{post["data"]["id"]}") 51 | # {"data"=>{"deleted"=>true}} 52 | 53 | # Initialize an API v1.1 client 54 | v1_client = X::Client.new(base_url: "https://api.twitter.com/1.1/", **x_credentials) 55 | 56 | # Define a custom response object 57 | Language = Struct.new(:code, :name, :local_name, :status, :debug) 58 | 59 | # Parse a response with custom array and object classes 60 | languages = v1_client.get("help/languages.json", object_class: Language, array_class: Set) 61 | # #, … 62 | 63 | # Access data with dots instead of brackets 64 | languages.first.local_name 65 | 66 | # Initialize an Ads API client 67 | ads_client = X::Client.new(base_url: "https://ads-api.twitter.com/12/", **x_credentials) 68 | 69 | # Get your ad accounts 70 | ads_client.get("accounts") 71 | ``` 72 | 73 | See other common usage [examples](https://github.com/sferik/x-ruby/tree/main/examples). 74 | 75 | ## History and Philosophy 76 | 77 | This library is a rewrite of the [Twitter Ruby library](https://github.com/sferik/twitter). Over 16 years of development, that library ballooned to over 3,000 lines of code (plus 7,500 lines of tests), not counting dependencies. This library is about 500 lines of code (plus 1000 test lines) and has no runtime dependencies. That doesn’t mean new features won’t be added over time, but the benefits of more code must be weighed against the benefits of less: 78 | 79 | * Less code is easier to maintain. 80 | * Less code means fewer bugs. 81 | * Less code runs faster. 82 | 83 | In the immortal words of [Ezra Zygmuntowicz](https://github.com/ezmobius) and his [Merb](https://github.com/merb) project (may they both rest in peace): 84 | 85 | > No code is faster than no code. 86 | 87 | The tests for the previous version of this library executed in about 2 seconds. That sounds pretty fast until you see that tests for this library run in one-twentieth of a second. This means you can automatically run the tests any time you write a file and receive immediate feedback. For such of workflows, 2 seconds feels painfully slow. 88 | 89 | This code is not littered with comments that are intended to generate documentation. Rather, this code is intended to be simple enough to serve as its own documentation. If you want to understand how something works, don’t read the documentation—it might be wrong—read the code. The code is always right. 90 | 91 | ## Features 92 | 93 | If this entire library is implemented in just 500 lines of code, why should you use it at all vs. writing your own library that suits your needs? If you feel inspired to do that, don’t let me discourage you, but this library has some advanced features that may not be apparent without diving into the code: 94 | 95 | * OAuth 1.0 Revision A 96 | * OAuth 2.0 Bearer Token 97 | * Thread safety 98 | * HTTP redirect following 99 | * HTTP proxy support 100 | * HTTP logging 101 | * HTTP timeout configuration 102 | * HTTP error handling 103 | * Rate limit handling 104 | * Parsing JSON into custom response objects (e.g. OpenStruct) 105 | * Configurable base URLs for accessing different APIs/versions 106 | * Parallel uploading of large media files in chunks 107 | 108 | ## Sponsorship 109 | 110 | The X gem is free to use, but with X API pricing tiers, it actually costs money to develop and maintain. By contributing to the project, you help us: 111 | 112 | 1. Maintain the library: Keeping it up-to-date and secure. 113 | 2. Add new features: Enhancements that make your life easier. 114 | 3. Provide support: Faster responses to issues and feature requests. 115 | 116 | ⭐️ Bonus: Sponsors will get priority support and influence over the project roadmap. We will also list your name or your company's logo on our GitHub page. 117 | 118 | Building and maintaining an open-source project like this takes a considerable amount of time and effort. Your sponsorship can help sustain this project. Even a small monthly donation makes a huge difference! 119 | 120 | [Click here to sponsor this project.](https://github.com/sponsors/sferik) 121 | 122 | ## Sponsors 123 | 124 | Many thanks to our sponsors (listed in order of when they sponsored this project): 125 | 126 | Better Stack 127 | 128 | Sentry 129 | 130 | IFTTT 131 | 132 | ## Development 133 | 134 | 1. Checkout and repo: 135 | 136 | git checkout git@github.com:sferik/x-ruby.git 137 | 138 | 2. Enter the repo’s directory: 139 | 140 | cd x-ruby 141 | 142 | 3. Install dependencies via Bundler: 143 | 144 | bin/setup 145 | 146 | 4. Run the default Rake task to ensure all tests pass: 147 | 148 | bundle exec rake 149 | 150 | 5. Create a new branch for your feature or bug fix: 151 | 152 | git checkout -b my-new-branch 153 | 154 | ## Contributing 155 | 156 | Bug reports and pull requests are welcome on GitHub at https://github.com/sferik/x-ruby. 157 | 158 | Pull requests will only be accepted if they meet all the following criteria: 159 | 160 | 1. Code must conform to [Standard Ruby](https://github.com/standardrb/standard#readme). This can be verified with: 161 | 162 | bundle exec rake standard 163 | 164 | 2. Code must conform to the [RuboCop rules](https://github.com/rubocop/rubocop#readme). This can be verified with: 165 | 166 | bundle exec rake rubocop 167 | 168 | 3. 100% C0 code coverage. This can be verified with: 169 | 170 | bundle exec rake test 171 | 172 | 4. 100% mutation coverage. This can be verified with: 173 | 174 | bundle exec rake mutant 175 | 176 | 5. RBS type signatures (in `sig/x.rbs`). This can be verified with: 177 | 178 | bundle exec rake steep 179 | 180 | ## License 181 | 182 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 183 | -------------------------------------------------------------------------------- /sig/x.rbs: -------------------------------------------------------------------------------- 1 | module X 2 | VERSION: Gem::Version 3 | 4 | class Authenticator 5 | AUTHENTICATION_HEADER: String 6 | 7 | def header: (Net::HTTPRequest? request) -> Hash[String, String] 8 | end 9 | 10 | class BearerTokenAuthenticator < Authenticator 11 | attr_accessor bearer_token: String 12 | def initialize: (bearer_token: String) -> void 13 | def header: (Net::HTTPRequest? request) -> Hash[String, String] 14 | end 15 | 16 | class OAuthAuthenticator < Authenticator 17 | OAUTH_VERSION: String 18 | OAUTH_SIGNATURE_METHOD: String 19 | OAUTH_SIGNATURE_ALGORITHM: String 20 | 21 | attr_accessor api_key: String 22 | attr_accessor api_key_secret: String 23 | attr_accessor access_token: String 24 | attr_accessor access_token_secret: String 25 | def initialize: (api_key: String, api_key_secret: String, access_token: String, access_token_secret: String) -> void 26 | def header: (Net::HTTPRequest request) -> Hash[String, String] 27 | 28 | private 29 | def parse_request: (Net::HTTPRequest request) -> [String, String, Hash[String, String]] 30 | def parse_query_params: (String query_string) -> Hash[String, String] 31 | def uri_without_query: (URI::Generic uri) -> String 32 | def build_oauth_header: (String method, String url, Hash[String, String] query_params) -> String 33 | def default_oauth_params: -> Hash[String, String] 34 | def generate_signature: (String method, String url, Hash[String, String] params) -> String 35 | def hmac_signature: (String base_string) -> String 36 | def signature_base_string: (String method, String url, Hash[String, String] params) -> String 37 | def signing_key: -> String 38 | def format_oauth_header: (Hash[String, String] params) -> String 39 | def escape: (String value) -> String 40 | end 41 | 42 | class Error < StandardError 43 | end 44 | 45 | class ClientError < HTTPError 46 | end 47 | 48 | class BadGateway < ClientError 49 | end 50 | 51 | class BadRequest < ClientError 52 | end 53 | 54 | class ConnectionException < ClientError 55 | end 56 | 57 | class HTTPError < Error 58 | JSON_CONTENT_TYPE_REGEXP: Regexp 59 | 60 | attr_reader response : Net::HTTPResponse 61 | attr_reader code : String 62 | 63 | def initialize: (response: Net::HTTPResponse) -> void 64 | 65 | private 66 | def error_message: (Net::HTTPResponse response) -> String 67 | def message_from_json_response: (Net::HTTPResponse response) -> String 68 | def json?: (Net::HTTPResponse response) -> bool 69 | end 70 | 71 | class Forbidden < ClientError 72 | end 73 | 74 | class GatewayTimeout < ClientError 75 | end 76 | 77 | class Gone < ClientError 78 | end 79 | 80 | class InternalServerError < ServerError 81 | end 82 | 83 | class NetworkError < Error 84 | end 85 | 86 | class NotAcceptable < ClientError 87 | end 88 | 89 | class NotFound < ClientError 90 | end 91 | 92 | class PayloadTooLarge < ClientError 93 | end 94 | 95 | class ServerError < HTTPError 96 | end 97 | 98 | class ServiceUnavailable < ServerError 99 | end 100 | 101 | class TooManyRedirects < Error 102 | end 103 | 104 | class TooManyRequests < ClientError 105 | @rate_limits: Array[RateLimit] 106 | 107 | def rate_limit: -> RateLimit? 108 | def rate_limits: -> Array[RateLimit] 109 | def reset_at: -> Time 110 | def reset_in: -> Integer? 111 | end 112 | 113 | class Unauthorized < ClientError 114 | end 115 | 116 | class UnprocessableEntity < ClientError 117 | end 118 | 119 | class Connection 120 | DEFAULT_HOST: String 121 | DEFAULT_PORT: Integer 122 | DEFAULT_OPEN_TIMEOUT: Integer 123 | DEFAULT_READ_TIMEOUT: Integer 124 | DEFAULT_WRITE_TIMEOUT: Integer 125 | DEFAULT_DEBUG_OUTPUT: IO 126 | NETWORK_ERRORS: Array[(singleton(Errno::ECONNREFUSED) | singleton(Errno::ECONNRESET) | singleton(Net::OpenTimeout) | singleton(Net::ReadTimeout) | singleton(OpenSSL::SSL::SSLError))] 127 | 128 | @proxy_url: URI::Generic | String 129 | 130 | extend Forwardable 131 | 132 | attr_accessor open_timeout : Float | Integer 133 | attr_accessor read_timeout : Float | Integer 134 | attr_accessor write_timeout : Float | Integer 135 | attr_accessor debug_output : IO 136 | 137 | attr_reader proxy_uri: URI::Generic? 138 | attr_reader proxy_host : String? 139 | attr_reader proxy_port : Integer? 140 | attr_reader proxy_user : String? 141 | attr_reader proxy_pass : String? 142 | 143 | def initialize: (?open_timeout: Float | Integer, ?read_timeout: Float | Integer, ?write_timeout: Float | Integer, ?proxy_url: URI::Generic? | String?, ?debug_output: IO) -> void 144 | def proxy_url=: (URI::Generic | String proxy_url) -> void 145 | def perform: (request: Net::HTTPRequest) -> Net::HTTPResponse 146 | 147 | private 148 | def build_http_client: (?String host, ?Integer port) -> Net::HTTP 149 | def configure_http_client: (Net::HTTP http_client) -> Net::HTTP 150 | end 151 | 152 | class RateLimit 153 | RATE_LIMIT_TYPE: String 154 | APP_LIMIT_TYPE: String 155 | USER_LIMIT_TYPE: String 156 | TYPES: Array[String] 157 | 158 | attr_accessor type: String 159 | attr_accessor response: Net::HTTPResponse 160 | def initialize: (type: String, response: Net::HTTPResponse) -> void 161 | def limit: -> Integer 162 | def remaining: -> Integer 163 | def reset_at: -> Time 164 | def reset_in: -> Integer? 165 | end 166 | 167 | class RequestBuilder 168 | HTTP_METHODS: Hash[Symbol, (singleton(Net::HTTP::Get) | singleton(Net::HTTP::Post) | singleton(Net::HTTP::Put) | singleton(Net::HTTP::Delete))] 169 | DEFAULT_HEADERS: Hash[String, String] 170 | 171 | def initialize: (?content_type: String, ?user_agent: String) -> void 172 | def build: (http_method: Symbol, uri: URI::Generic, ?body: String?, ?headers: Hash[String, String], ?authenticator: Authenticator) -> (Net::HTTPRequest) 173 | 174 | private 175 | def create_request: (http_method: Symbol, uri: URI::Generic, body: String?) -> (Net::HTTPRequest) 176 | def add_authentication: (request: Net::HTTPRequest, authenticator: Authenticator) -> void 177 | def add_headers: (request: Net::HTTPRequest, headers: Hash[String, String]) -> void 178 | def escape_query_params: (URI::Generic uri) -> URI::Generic 179 | end 180 | 181 | class RedirectHandler 182 | DEFAULT_MAX_REDIRECTS: Integer 183 | 184 | attr_reader authenticator: Authenticator 185 | attr_reader connection: Connection 186 | attr_reader request_builder: RequestBuilder 187 | attr_reader max_redirects: Integer 188 | def initialize: (?connection: Connection, ?request_builder: RequestBuilder, ?max_redirects: Integer) -> void 189 | def handle: (response: Net::HTTPResponse, request: Net::HTTPRequest, base_url: String, ?authenticator: Authenticator, ?redirect_count: Integer) -> Net::HTTPResponse 190 | 191 | private 192 | def build_new_uri: (Net::HTTPResponse response, String base_url) -> URI::Generic 193 | def build_request: (Net::HTTPRequest request, URI::Generic new_uri, Integer response_code, Authenticator authenticator) -> Net::HTTPRequest 194 | def send_new_request: (URI::Generic new_uri, Net::HTTPRequest new_request) -> Net::HTTPResponse 195 | end 196 | 197 | class ResponseParser 198 | ERROR_MAP: Hash[Integer, singleton(BadGateway) | singleton(BadRequest) | singleton(ConnectionException) | singleton(Forbidden) | singleton(GatewayTimeout) | singleton(Gone) | singleton(InternalServerError) | singleton(NotAcceptable) | singleton(NotFound) | singleton(PayloadTooLarge) | singleton(ServiceUnavailable) | singleton(TooManyRequests) | singleton(Unauthorized) | singleton(UnprocessableEntity)] 199 | JSON_CONTENT_TYPE_REGEXP: Regexp 200 | 201 | def parse: (response: Net::HTTPResponse, ?array_class: Class?, ?object_class: Class?) -> untyped 202 | 203 | private 204 | def error: (Net::HTTPResponse response) -> HTTPError 205 | def error_class: (Net::HTTPResponse response) -> (singleton(BadGateway) | singleton(BadRequest) | singleton(ConnectionException) | singleton(Forbidden) | singleton(GatewayTimeout) | singleton(Gone) | singleton(InternalServerError) | singleton(NotAcceptable) | singleton(NotFound) | singleton(PayloadTooLarge) | singleton(ServiceUnavailable) | singleton(TooManyRequests) | singleton(Unauthorized) | singleton(UnprocessableEntity)) 206 | def json?: (Net::HTTPResponse response) -> bool 207 | end 208 | 209 | class Client 210 | DEFAULT_BASE_URL: String 211 | DEFAULT_ARRAY_CLASS: singleton(Array) 212 | DEFAULT_OBJECT_CLASS: singleton(Hash) 213 | extend Forwardable 214 | @authenticator: Authenticator | BearerTokenAuthenticator | OAuthAuthenticator 215 | @connection: Connection 216 | @request_builder: RequestBuilder 217 | @redirect_handler: RedirectHandler 218 | @response_parser: ResponseParser 219 | 220 | attr_accessor base_url: String 221 | attr_accessor default_array_class: singleton(Array) 222 | attr_accessor default_object_class: singleton(Hash) 223 | attr_reader api_key: String? 224 | attr_reader api_key_secret: String? 225 | attr_reader access_token: String? 226 | attr_reader access_token_secret: String? 227 | attr_reader bearer_token: String? 228 | def initialize: (?api_key: nil, ?api_key_secret: nil, ?access_token: nil, ?access_token_secret: nil, ?bearer_token: nil, ?base_url: String, ?open_timeout: Integer, ?read_timeout: Integer, ?write_timeout: Integer, ?debug_output: untyped, ?proxy_url: nil, ?default_array_class: singleton(Array), ?default_object_class: singleton(Hash), ?max_redirects: Integer) -> void 229 | def get: (String endpoint, ?headers: Hash[String, String], ?array_class: Class, ?object_class: Class) -> untyped 230 | def post: (String endpoint, ?String? body, ?headers: Hash[String, String], ?array_class: Class, ?object_class: Class) -> untyped 231 | def put: (String endpoint, ?String? body, ?headers: Hash[String, String], ?array_class: Class, ?object_class: Class) -> untyped 232 | def delete: (String endpoint, ?headers: Hash[String, String], ?array_class: Class, ?object_class: Class) -> untyped 233 | def api_key=: (String api_key) -> void 234 | def api_key_secret=: (String api_key_secret) -> void 235 | def access_token=: (String access_token) -> void 236 | def access_token_secret=: (String access_token_secret) -> void 237 | def bearer_token=: (String bearer_token) -> void 238 | 239 | private 240 | def initialize_oauth: (String? api_key, String? api_key_secret, String? access_token, String? access_token_secret, String? bearer_token) -> void 241 | def initialize_default_classes: (singleton(Array) default_array_class, singleton(Hash) default_object_class) -> singleton(Hash) 242 | def initialize_authenticator: -> (Authenticator | BearerTokenAuthenticator | OAuthAuthenticator) 243 | def execute_request: (:delete | :get | :post | :put http_method, String endpoint, ?body: String?, ?headers: Hash[String, String], ?array_class: Class, ?object_class: Class) -> nil 244 | end 245 | 246 | module MediaUploader 247 | MAX_RETRIES: Integer 248 | BYTES_PER_MB: Integer 249 | MEDIA_CATEGORIES: Array[String] 250 | DM_GIF: String 251 | DM_IMAGE: String 252 | DM_VIDEO: String 253 | SUBTITLES: String 254 | TWEET_GIF: String 255 | TWEET_IMAGE: String 256 | TWEET_VIDEO: String 257 | DEFAULT_MIME_TYPE: String 258 | MIME_TYPES: Array[String] 259 | GIF_MIME_TYPE: String 260 | JPEG_MIME_TYPE: String 261 | MP4_MIME_TYPE: String 262 | PNG_MIME_TYPE: String 263 | SUBRIP_MIME_TYPE: String 264 | WEBP_MIME_TYPE: String 265 | MIME_TYPE_MAP: Hash[String, String] 266 | PROCESSING_INFO_STATES: Array[String] 267 | extend MediaUploader 268 | 269 | def upload: (client: Client, file_path: String, media_category: String, ?media_type: String, ?boundary: String) -> untyped 270 | def chunked_upload: (client: Client, file_path: String, media_category: String, ?media_type: String, ?boundary: String, ?chunk_size_mb: Integer) -> untyped 271 | def await_processing: (client: Client, media: untyped) -> untyped 272 | 273 | private 274 | def validate!: (file_path: String, media_category: String) -> nil 275 | def infer_media_type: (String file_path, String media_category) -> String 276 | def split: (String file_path, Integer chunk_size) -> Array[String] 277 | def init: (upload_client: Client, file_path: String, media_type: String, media_category: String) -> untyped 278 | def append: (upload_client: Client, file_paths: Array[String], media: untyped, media_type: String, ?boundary: String) -> Array[String] 279 | def upload_chunk: (upload_client: Client, query: String, upload_body: String, file_path: String, ?headers: Hash[String, String]) -> Integer? 280 | def cleanup_file: (String file_path) -> Integer? 281 | def finalize: (upload_client: Client, media: untyped) -> untyped 282 | def construct_upload_body: (file_path: String, media_type: String, ?boundary: String) -> String 283 | end 284 | end 285 | --------------------------------------------------------------------------------