├── 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 |
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 |
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 |
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 | [](https://github.com/sferik/x-ruby/actions/workflows/test.yml)
2 | [](https://github.com/sferik/x-ruby/actions/workflows/mutant.yml)
3 | [](https://github.com/sferik/x-ruby/actions/workflows/lint.yml)
4 | [](https://github.com/sferik/x-ruby/actions/workflows/steep.yml)
5 | [](https://codeclimate.com/github/sferik/x-ruby/maintainability)
6 | [](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 |
127 |
128 |
129 |
130 |
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 |
--------------------------------------------------------------------------------