For updates and announcements, follow this gem and its creator on X.
The gems require Ruby 3.4 or later. Install the gem and add to the application's Gemfile:
bundle add x
Or, if Bundler is not being used to manage dependencies:
gem install x
The API documentation of every gem, x-core, x-uploader, x-streaming, and x-objects together, is at sferik.github.io/x-ruby/api.
The x gem is a thin meta-gem that combines four gems, which are released from this repository in lockstep:
| Gem | What it does | Runtime dependencies |
|---|---|---|
x-core |
HTTP: authentication, requests, redirects, errors, and rate limits | simple_oauth, and net-http, a default gem |
x-uploader |
Uploads: images, GIFs, videos, and subtitles, with videos and subtitles in chunks, plus profile images and banners | x-core |
x-streaming |
Streams: the sample and filtered streams, reconnected as X recommends, and the rules of the filtered stream | x-core |
x-objects |
Resources: User, Post, List, DirectMessage, Space, Community, Media, Poll, Place, and cursors |
x-core |
require "x" loads all four, and mixes the object methods (find_user, find_all_posts, search_posts, …), the upload methods (upload_media, add_alt_text, update_profile_image, …), and streaming into X::Client. Any other request can return objects too, given a resource class as its object_class. If you only want raw JSON, depend on x-core alone. If you want the objects with an HTTP client of your own, depend on x-objects alone: it makes no request itself, and takes x-core for the errors the X gems share. It asks the client you give it for get, post, put, and delete, each taking a path that already carries the query, an optional body for the two that send one, and array_class: and object_class:, and may be passed any other keyword X::Client takes within 1.x; the client contract has the whole of it, and the X::Objects::_Client interface in sig/x-objects.rbs states it for a type checker.
Every class you write is named directly under X, whatever gem declares it: X::Client and X::NotFound from x-core, X::User and X::MissingResource from x-objects, X::UploadedMedia and X::MediaProcessingFailed from x-uploader, X::StreamingClient and X::StreamError from x-streaming. Each gem's module holds its own mixins and internals. X::Objects::Error, X::Uploader::Error, and X::Streaming::Error are the three exceptions, since each is the name a rescue reaches for to catch the failures of that gem alone.
Note
First, obtain X credentials from https://developer.x.com.
require "x"
x_credentials = {
api_key: "INSERT YOUR X API KEY HERE",
api_key_secret: "INSERT YOUR X API KEY SECRET HERE",
access_token: "INSERT YOUR X ACCESS TOKEN HERE",
access_token_secret: "INSERT YOUR X ACCESS TOKEN SECRET HERE",
}
# Initialize an X API client with your OAuth credentials
x_client = X::Client.new(**x_credentials)Every lookup requests all public fields and expansions, and returns immutable, thread-safe objects.
user = x_client.find_user("sferik") # a String is a username, an Integer is an ID
# find_user_by_username and find_user_by_id say which you mean
user.name # => "Erik Berlin"
user.followers_count # => 12345
post = x_client.find_post(1234567890) # X::Post
post.text # the full text, even of a post longer than 280 characters
post.created_at # => 2026-09-11 12:00:00 UTCText. Objects read every String as the API sends it, and the API escapes &, <, and > as &, <, and > in the text of a post and of a direct message, so post.text, post.expanded_text, and message.text hold them, and will throughout 1.x. Unescape the text to display it:
require "cgi/escape"
post.text # => "Ruby & Rails"
CGI.unescapeHTML(post.text) # => "Ruby & Rails"Nested data. An object the API nests in a resource, or a list of them, such as post.entities, post.urls, post.public_metrics, post.attachments, media.variants, or poll.options, reads as the API sends it, a frozen Hash keyed by String, or an Array of them, and will throughout 1.x; a response that holds anything else in its place raises X::InvalidAttribute. A later 1.x release may add a reader that returns an object for some of it, as post.matching_rules returns X::MatchingRules, but under a new name.
post.public_metrics["like_count"] # => 3, which post.like_count reads too
post.urls.map { |url| url["expanded_url"] }Any endpoint. For an endpoint without a method, pass a resource class as the object_class of a request. A response that holds one resource comes back as an object, and one that holds a list comes back as an X::Page of the page you requested, which reads as an array does, and holds the next_token of the page after it. The object holds only the fields you asked for, so hydrate fetches the rest.
user = x_client.get("users/by/username/sferik", object_class: X::User)
user.followers_count # => nil, since the request didn't ask for it
user.hydrate.followers_count # => 12345
blocked = x_client.get("users/#{user.id}/blocking", object_class: X::User) # => #<X::Page ...>
blocked.first # => #<X::User ...>
x_client.get("users/#{user.id}/blocking", params: {pagination_token: blocked.next_token}, object_class: X::User)Identity. Resources with the same class and ID are equal (==, eql?, and hash), even when they come from different requests.
post.author == x_client.find_user("sferik") # => true
[post.author, user].uniq.size # => 1References. Foreign keys have accessors that return objects. When the response included the referenced object, you get it with its fields. Otherwise, you get a stub that holds only the ID. hydrate fetches the full object once and memoizes it. refresh fetches it again.
post.author # => #<X::User id="7505382" name="Erik Berlin" username="sferik" ...>
post.replied_to # => #<X::Post id="1234567889">, a stub
post.replied_to.hydrate.text # one request
post.replied_to.hydrate.text # memoized, no request
post.replied_to.refresh.text # forces a new requestWithin one response, every reference to the same resource is the same object, so hydrating a user from one post hydrates it for every post on that page.
Pagination. Collections are X::Cursor objects, which include Enumerable, remember the client that fetched them, and fetch pages lazily. Iterating requests the maximum page size, and pages are cached, so iterating twice costs no extra requests. first(n) and take(n) request a page of n instead, raised to the endpoint's minimum, and keep it, so an iteration after them pays for none of it again, and the first page of each_page holds what they read. published_count reads the number the API publishes for a collection, such as a user's followers_count, without paging through it, while count pages through the whole collection, as Enumerable does. A cursor has no size, so sizing an enumerator over it, such as each_slice(2).size, never pages it. refresh returns a cursor with an empty cache, and ids fetches nothing but identifiers.
followers = user.followers # max_results=1000 per page
followers.first(10) # one request for ten users
followers.empty? # one request for one user, as any? and none? make
followers.to_a # fetches the remaining pages
followers.to_a # cached, no requests
followers.refresh.to_a # starts over
followers.published_count # => 12345, the user's followers_count, with no request
followers.ids # => [14100886, ...], requesting only identifiers
x_client.search_posts("ruby -is:retweet").each { |post| puts post.text }
x_client.search_users("ruby").first(10)
x_client.search_communities("ruby").first(10)
x_client.search_spaces("ruby", state: "live").first(10) # a client that signs with OAuth 1.0a searches as the app
x_client.find_all_spaces_by_creator([user, other]) # the live and scheduled spaces the users created
x_client.trends(1).first(10) # the topics trending worldwide, by WOEID, as X::Trend
x_client.personalized_trends # the topics X picks for the authenticated user
user.affiliates.first(10) # the users whose affiliation names the user
x_client.current_user!.home_timeline.first(10)Stubs. from_id refers to a resource without a request, so a cursor can be built from an identifier alone. A reference the response did not expand is a stub too, and stub? tells the two apart.
X::User.from_id(7505382, client: x_client).followers.ids
post.author.stub? # => false when the response included the author
X::User.hydrate_all(posts.map(&:author), client: x_client) # looks up what is not hydrated, included authors too, in one batch
X::User.hydrate_all(posts.filter_map(&:author).select(&:stub?), client: x_client) # looks up only the authors not included
message.peer(x_client.current_user!) # the other participant of a direct messageCosts. The X API bills each resource a request returns, so the size of a page is the size of the bill. first(n) and take(n) read only what they return. Iterating a cursor, to_a, and ids read the whole collection, up to 1,000 users a page for followers and following. count reads the whole collection too, a request per page, billed for every resource, so counting a user with a million followers reads a million users in a thousand requests. published_count reads the number the API publishes instead, for a user's followers, followed users, and list memberships, and for a list's members and followers, which costs nothing when the user or list holds it and one lookup when it is a stub, and it returns nil for any other collection. The published number counts what the collection holds, which can differ from what reading it finds, since the API leaves out what the authenticated user cannot see. Other Enumerable methods, such as find, include?, and lazy, cannot know how much they will read, so they request the largest page too; pass max_results: to read less per request. user.follows?(other) takes one lookup when either user is the authenticated user, by reading the other's connection_status, and otherwise scans the users user follows. The API has no lookup for a list membership, so list.member?(user) scans: the members of a private list, and for a public list, whichever is smaller of its members and the lists the user is on, as member_count and listed_count tell. Pass either scan max_pages: to read no more pages than that: one that reaches the limit with pages left raises X::PageLimitReached, rather than answer from the pages it read.
user.followers.first(10) # ten users
user.followers.published_count # the user's followers_count, reading no follower
user.followers.count # every follower, a request per 1,000
user.muting.published_count # => nil, since the API publishes no number
x_client.current_user!.follows?(other) # one lookup
other.follows?(x_client.current_user!) # one lookup
other.follows?(someone) # scans everyone other follows
other.follows?(someone, max_pages: 5) # scans five pages at most, or raises X::PageLimitReached
user.followers(max_results: 100).find { |follower| follower.verified? }The API bills a resource once per UTC day, however often it is read, and bills only the resources a response returns as data, not the ones it includes, so expanding post.author costs nothing extra. Batch lookups ask for each ID once. Reads of the authenticated user's own data cost a fifth as much as other reads when that user owns the app: their posts, mentions, likes, bookmarks, followers, following, blocks, mutes, and lists. So x_client.current_user!.posts is cheaper than searching for from:sferik. Actions take the authenticated user's ID from the prefix of an OAuth 1.0a access token, and so need no request to users/me.
Writes are billed by the request, and cost more than reads. Creating a post costs $0.015, or $0.20 when its text holds a URL, so a link costs more than ten times as much as the post around it. A like, repost, follow, or direct message costs $0.015, and undoing one costs $0.01. Prices change, so check the pricing page before a large run.
Post usage. post_usage reports how many posts the app's project has read this billing cycle, against its monthly cap, and how many it read each day. It returns nil when the response holds no usage, yielding the problems the API reported to a block, as current_user does, and post_usage! raises X::MissingResource instead.
usage = x_client.post_usage!(days: 30)
usage.project_cap - usage.project_usage # the posts left to read this cycle
usage.daily # => {2026-09-14 00:00:00 UTC => 1234, ...}
usage.daily_by_app # the same, keyed by the ID of each of the project's appsCounting posts. count_posts counts the posts from the last seven days that match a query, and count_all_posts counts every post, which needs full-archive access. Each costs one request per page of periods, whatever the count, and a count of many years of the archive takes many pages, so every count, count_posts, count_all_posts, and their _by_period forms, takes max_pages:, past which it raises X::PageLimitReached. The counts and usage endpoints refuse OAuth 1.0a, so a client that signs with it makes those requests through app_only, a copy of itself that authenticates as the app. The client fetches the app's bearer token the first time and reuses it.
x_client.count_posts("ruby") # => 12345
x_client.count_posts_by_period("ruby", granularity: "hour") # => {2026-09-14 12:00:00 UTC...2026-09-14 13:00:00 UTC => 42, ...}
X::Post.count_all("ruby", client: x_client, start_time: "2020-01-01T00:00:00Z")
X::Post.count_all("ruby", client: x_client, max_pages: 3) # three requests at mostMissing resources. A finder returns nil when the resource does not exist, and its bang form raises X::MissingResource, an X::Error. The API answers a lookup of a resource that is not there with 200 OK and no data, so this is not X::NotFound, which is the 404 of an endpoint that is not there. A request that creates a resource, such as create_post, create_list, or create_dm, raises X::MissingResource, holding the response's problems, if the API answers with success and no resource, so it never returns nil. A value a response holds that cannot be read as what the API documents it to be, such as a timestamp that is not ISO 8601, raises X::InvalidAttribute, an X::Error too, from the reader that reads it. A page that names the token of a page already fetched as its next raises X::UnreadableResponse, the base of X::InvalidAttribute, rather than page forever. A list the response omitted, such as space.host_ids or post.urls, reads as empty rather than nil.
x_client.find_user("nobody") # => nil
x_client.find_user!("nobody") # raises X::MissingResource
x_client.find_user("not a username") # raises ArgumentError, before any request
post.permalink # => "https://x.com/sferik/status/1234567890"
post.expanded_text # the text with every t.co link replaced by the URL it stands for
post.display_text_range # => 0...140, the Range of the text that is shown, or nilPartial errors. A response can succeed and still report problems, such as a pinned post that was deleted or one ID of a batch that does not exist. problems returns them as X::Problem objects: on a resource, the ones whose resource_id or value is its identifier or that of a resource it refers to, such as the author of a post, and the ones that name no identifier, and on each page of a cursor, every one the response reported. A finder yields each one to a block, and X::MissingResource#problems holds the ones that explain a missing resource. A request the API refuses raises an X::HTTPError whose problems are every one the response named, such as each parameter it refused, and whose problem is the one the response describes as a whole, or, for a response that names errors alone, as the v1.1 API sends, the first of them; a 402 Payment Required, which X sends for a request the account has no credit left to pay for, raises X::PaymentRequired.
user = x_client.find_user("sferik")
user.problems # => [#<X::Problem Not Found Error: Could not find post with pinned_tweet_id: [1234567890].>]
x_client.find_all_users(ids) { |problem| warn problem.detail if problem.not_found? }
x_client.find_user!("nobody") # raises X::MissingResource: Could not find X::User @nobody: Could not find user with username: [nobody].Parallel requests. Batch lookups split the IDs into groups of 100, the API maximum, and request four groups at a time, as the chunks of an upload are sent four at a time. Pass a concurrency to request more or fewer at once: a lower number spends a rate limit more slowly. Paginated endpoints return a token for the next page with each page, so their pages must be fetched in order. prefetch fetches the next page in a background thread while you process the current one. It is opt-in because it spends one extra request when you stop iterating early.
x_client.find_all_users(follower_ids) # parallel batches of 100, in the order asked for
x_client.find_all_users(follower_ids, concurrency: 1) # one batch at a time
X::Post.find_all(ids, client: x_client, concurrency: 8)
user.followers.prefetch.each { |follower| process(follower) }Actions. Actions are taken as the authenticated user. They take a resource of the class they act on, or its identifier, an Integer or a String of digits, and raise ArgumentError before any request for anything else, such as a username, or a resource of another class, such as a list passed to follow, so look a user up with find_user first.
me = x_client.current_user! # looked up each time it is called, so keep it
me.follow(user)
me.block(user)
me.like(post)
me.repost(post) # => true, as each action reports whether it took
me.bookmark(post) # bookmarks take OAuth 2.0 user context, which this client does not hold
me.bookmarks(folder: me.bookmark_folders.first).first(10) # the posts in a bookmark folder
x_client.like(post) # the same, via the client
post = x_client.create_post("Hello, World! (from @gem)")
reply = x_client.create_post("Hello back!", reply_to: post, media_ids: [media])
quote = x_client.create_post("Worth reading", quote: post)
photo = x_client.create_post(media_ids: [media]) # a post needs no text when it has media
x_client.hide_reply(reply) # as the author of the post it replies to
post.delete
list = x_client.create_list("Rubyists", private: true)
list.update(description: "People who write Ruby") # also name and private
list.add_member(user)
me.pin_list(list) # also unpin_list, follow_list, and unfollow_list
list.delete
message = x_client.create_dm(user, "Hello!") # create_direct_message, shortened
x_client.dms_with(user).first(10) # the conversation with a user
group = x_client.create_group_dm([user, other], "Hello, both of you!") # create_group_direct_message, shortened
x_client.create_dm_in(group, "Anyone free on Friday?") # to the conversation of a message, or its identifier
x_client.dms_in(group).first(10) # the messages of a conversation, one-to-one or groupX::Client still speaks raw JSON, for endpoints without objects or when you want full control.
# Get data about yourself
x_client.get("users/me")
# {"data"=>{"id"=>"7505382", "name"=>"Erik Berlin", "username"=>"sferik"}}
# Query parameters drop nil values and join arrays with commas
x_client.get("users", params: {ids: [7505382, 12], "user.fields": %w[id username]})
# Post, with a Hash encoded as JSON
post = x_client.post("tweets", {text: "Hello, World! (from @gem)"})
# {"data"=>{"edit_history_post_ids"=>["1234567890123456789"], "id"=>"1234567890123456789", "text"=>"Hello, World! (from @gem)"}}
# Delete the post
x_client.delete("tweets/#{post["data"]["id"]}")
# {"data"=>{"deleted"=>true}}
# Derive an API v1.1 client
v1_client = x_client.with(base_url: "https://api.x.com/1.1/")
# Post a form
v1_client.post("account/settings.json", form: {lang: "en"})
# Authenticate as the app, with a bearer token fetched with the API key and secret the first time, and again once X
# rejects it
x_client.app_only.get("tweets/search/stream/rules")
# Authenticate with OAuth 2.0, refreshing the access token when it expires or the API rejects it,
# and store the X::OAuth2Tokens of each refresh, since X accepts a refresh token only once; X::AuthorizationError
# means X refused to refresh, so rescue it beside X::Unauthorized to ask the user to authorize the app again, and a
# token endpoint that fails to answer raises the X::HTTPError of its status, such as the X::ServerError the client
# retries, or X::InvalidResponse for the page of a captive portal
oauth2_client = X::Client.new(client_id: "ID", client_secret: "SECRET", access_token: "TOKEN", refresh_token: "REFRESH",
expires_at: Time.now + 7200, save_tokens: ->(tokens) { store(tokens.access_token, tokens.refresh_token, tokens.expires_at) })
# Share the tokens of a user among processes: a refresh reads the store with load_tokens first, which returns the
# X::OAuth2Tokens stored, or nil, and takes the tokens another process stored in place of refreshing with a refresh
# token that process already spent
shared_tokens_client = X::Client.new(client_id: "ID", **load(user).to_h,
save_tokens: ->(tokens) { store(user, tokens) }, load_tokens: -> { load(user) })
# Store the tokens as JSON, which X::OAuth2Tokens.from_json reads back, the expiration time and all
json_tokens_client = X::Client.new(client_id: "ID", **X::OAuth2Tokens.from_json(redis.get(user)).to_h,
save_tokens: ->(tokens) { redis.set(user, tokens.to_json) })
# An access token issued without offline.access comes with no refresh token, and acts for the user until it expires
short_lived_client = X::Client.new(client_id: "ID", access_token: "TOKEN")
# Authenticate with an authenticator built elsewhere, in place of credentials; clients given the same one share its
# tokens, and each refresh reaches the save_tokens of every one of them
authenticator = X::OAuth2Authenticator.new(client_id: "ID", access_token: "TOKEN", refresh_token: "REFRESH")
shared_client = X::Client.new(authenticator:, save_tokens: ->(tokens) { store(tokens.refresh_token) })
# Authenticate with a scheme of your own: subclass X::Authenticator and return the headers that authenticate a request,
# reading of the request its http_method, uri, body, and [] for a header, which are all it answers
class VaultAuthenticator < X::Authenticator
def headers(_request) = {AUTHENTICATION_HEADER => "Bearer #{Vault.read("x/bearer_token")}"}
end
vault_client = X::Client.new(authenticator: VaultAuthenticator.new)
# Ask a user to authorize the app with OAuth 2.0 and PKCE, keeping the state and code verifier until X redirects back
authorization = X::OAuth2Authorization.new(client_id: "ID", redirect_uri: "https://example.com/callback",
scopes: %w[tweet.read tweet.write users.read offline.access])
session[:state] = authorization.state
session[:code_verifier] = authorization.code_verifier
redirect_to authorization.url
# Then, where X redirects back, exchange the code for a client that acts for the user; save_tokens is passed the
# tokens of the exchange, whose refresh_token is nil without offline.access, and those of each refresh after, and
# X::TokenReportFailed holds the client and the tokens when it raises for either, so neither is lost with the code or
# the refresh token they replaced
authorization = X::OAuth2Authorization.new(client_id: "ID", redirect_uri: "https://example.com/callback",
state: session[:state], code_verifier: session[:code_verifier])
user_client = authorization.client(request.url, save_tokens: ->(tokens) { store(tokens.refresh_token) })
user_client.scopes # => the scopes the user granted, which may be fewer than the app asked for
# Define a custom response object
Language = Struct.new(:code, :name, :local_name, :status, :debug)
# Parse a response with custom array and object classes
languages = v1_client.get("help/languages.json", object_class: Language, array_class: Set)
# #<Set: {#<struct Language code="ur", name="Urdu", local_name="اردو", status="beta", debug=false>, …
# Access data with dots instead of brackets
languages.first.local_name
# Initialize an Ads API client
ads_client = X::Client.new(base_url: "https://ads-api.x.com/12/", **x_credentials)
# Get your ad accounts
ads_client.get("accounts")
# Keep a connection open for the next request for five seconds rather than 30, behind a proxy that closes idle
# connections sooner than X does
patient_client = X::Client.new(keep_alive_timeout: 5, **x_credentials)
# Send headers with every request, such as one that names your application. They are defaults: a header of the same
# name passed to a request, or to a stream, is sent in place of the client's, and each of the client's is sent in
# place of a default of the gem, such as its User-Agent, whatever the case each is named in. They, and the gem's
# User-Agent, are sent with the requests that fetch and refresh the client's tokens too, as a gateway may require.
named_client = X::Client.new(headers: {"User-Agent" => "my-app/1.0 (+https://example.com)"}, **x_credentials)
named_client.get("users/me", headers: {"X-Trace" => "abc"}) # sends both
# with(headers:) replaces the headers of the client rather than adding to them, so give the copy every header it sends
traced_client = named_client.with(headers: named_client.headers.merge("X-Trace" => "abc")) # sharing its connections
# Send requests through a proxy. A client that is given none takes the proxy the environment names for the scheme of
# each request, in https_proxy or http_proxy, and reaches the hosts that no_proxy names directly.
proxied_client = X::Client.new(proxy_url: "http://user:password@proxy.example.com:8080", **x_credentials)
# Close the connections a client keeps open between requests, which the copies `with` makes of it share when they
# open their connections alike; a later request opens one again
x_client.closeA client never changes. It keeps the credentials and settings it was built with for as long as it lives, so a request never runs against a setting another thread is halfway through changing. with derives a client that differs, and takes anything X::Client.new takes.
v1_client = x_client.with(base_url: "https://api.x.com/1.1/")
rotated = x_client.with(access_token: "new_token", access_token_secret: "new_secret")A client keeps its credentials to its own origin. An endpoint that names a whole URL is sent to that URL, and it carries the client's credentials only when it names the scheme, host, and port of the base_url, which the API version above does. An endpoint, or a stream, of any other origin is sent without the Authorization header the authenticator signs and without any Authorization, Cookie, or Proxy-Authorization header of the client or the request, as a redirect that leads off the origin is, so the credentials of the API never reach a host they were not meant for. A client built with another base_url, such as the Ads API client above, carries its credentials to that origin.
A client keeps its credentials to itself. It has no reader for a secret it holds, and inspect names its authenticator without revealing what it signs with, so nothing that reflects over a client reads a credential out of one. with carries them to a client derived from it, and the hook given to save_tokens is passed the X::OAuth2Tokens of the refresh it reports.
x_client.inspect # => #<X::Client base_url="https://api.x.com/2/" authenticator=#<X::OAuth1Authenticator>>
x_client.api_key # the API key, the client ID, and the expiration time are public
x_client.api_key_secret # raises NoMethodError, as every secret a client holds does# The media category is inferred from the file: an image, an animated GIF, a video, or subtitles.
# A GIF with a single frame is uploaded as an image, since X processes only animated GIFs as GIFs.
media = x_client.upload_media("cat.jpg", alt_text: "A cat asleep on a keyboard")
media.id # => 1880028106020515840, where media["id"] is the String the API gave
media.expires_after_secs # => 86400, the seconds after the upload within which a post can attach it
x_client.create_post("Look at this cat", media_ids: [media])
x_client.find_media(media).url # the X::Media it became, looked up by media key
x_client.find_all_media(post.media) # the media of a post, up to 100 keys per request
# A video, and an animated GIF larger than 5 MB, is uploaded in chunks, four at a time unless concurrency says
# otherwise, up to 16, in chunks of up to 5 MB sized so that the upload fits the 10,000 segments the API numbers, and
# upload waits until a video or an animated GIF has been processed, for up to ten minutes unless processing_timeout says
# otherwise. An image larger than 5 MB, a GIF larger than 15 MB, or subtitles larger than 1 MB raise X::InvalidMedia
# before any request, since X takes no more of them, as does a video larger than the 16 GB X takes of an upload.
video = x_client.upload_media("cat.mp4")
video.ready? # => true, since upload_media waited; state is "succeeded"
subtitles = x_client.upload_media("cat.srt")
x_client.add_subtitles(video, subtitles, "EN", display_name: "English") # returns the video, an X::UploadedMedia
x_client.create_post("Look at this cat move", media_ids: [video])
# Update the profile image and banner of the authenticated user. These two call the API v1.1, which the API v2 has
# no endpoint for, and return nothing to read: look the user up to read the image and banner it now has.
x_client.update_profile_image("avatar.png")
x_client.update_profile_banner("banner.png")
# Media is a path, or an IO open on it. Media given as a String or a Pathname is read from the file it names, and
# media given as a File or a Tempfile through that IO, even once the Tempfile is unlinked, or from the file it names
# once it is closed, a chunk at a time, so media of any size uploads without being held in memory. A String that holds
# the contents of media, rather than its path, raises ArgumentError: wrap the contents in a StringIO. A String is
# read as a path on this machine, so never pass one a user gave, such as a parameter of a form, which could name any
# file the process can read, and upload it to X: pass the IO of the file the user uploaded instead.
x_client.upload_media(Pathname("cat.jpg"))
File.open("cat.mp4", "rb") { |file| x_client.upload_media(file) }
# Media given as any other IO, such as a StringIO, is read to its end and held. Its category is read from the bytes
# it begins with, as the category of a file is before its name: a GIF, PNG, JPEG, BMP, TIFF, WebP, MP4, QuickTime, WebM, MPEG transport
# stream, or WebVTT file is recognized by its signature. Pass media_category for anything else, such as SubRip subtitles, which begin
# with nothing a text file could not.
x_client.upload_media(StringIO.new(png))
x_client.upload_media(StringIO.new(srt), media_category: "subtitles")Each of these methods calls an uploader with the client: upload_media, chunked_upload_media, which uploads in chunks and returns before X processes the media, await_media_processing, and await_media_processing!, which raises X::MediaProcessingFailed where the other returns the status of processing that failed, or that ended in a state X does not document, call X::Uploader::MediaUpload, add_alt_text and add_subtitles call X::Uploader::Metadata, and update_profile_image and update_profile_banner call X::Uploader::Account. Media that already says its processing ended, as the response to the upload of an image does, is awaited without a request. The uploaders take an X::Client as client:, as in X::Uploader::MediaUpload.chunked_upload("cat.mp4", client: x_client, chunk_size: 4 * 1024 * 1024), which x_client.chunked_upload_media("cat.mp4", chunk_size: 4 * 1024 * 1024) calls.
upload_media uploads an image in a single request, which takes no chunks, so it ignores chunk_size:, concurrency:, and media_type: for one; chunked_upload_media uploads in chunks whatever the media, and is the way to upload without waiting for X to process it.
A video uploads in chunks of 4 MB, each a request of its own, which a rate limit can refuse. A client retries a request refused for a rate limit only max_rate_limit_retries times, 0 by default, so a chunk refused fails the upload with X::ChunkedUploadFailed, which holds the media it initialized. Upload a large video with a client whose max_rate_limit_retries is set, such as X::Client.new(**x_credentials, max_rate_limit_retries: 3), so that a rate limit is waited out, up to the client's max_rate_limit_wait, rather than fail the upload.
What each returns: upload_media, chunked_upload_media, await_media_processing, and await_media_processing! return an X::UploadedMedia, which reads as the Hash the API answered with as well as by its own methods; add_alt_text and add_subtitles return the media they describe as an X::UploadedMedia, the video for add_subtitles, so either can be passed on to create_post; and update_profile_image and update_profile_banner return nothing to read, nil: they are the only methods in these gems that call the v1.1 API, whose user no object of the object layer reads, so current_user! reads the image and banner the user now has.
A stream holds a connection open instead of answering a request, so X::StreamingClient, from x-streaming, handles one, and streaming builds it from a client. It shares the client's credentials, base URL, parsing classes, and on_response hook, and keeps the settings a long-lived connection needs: read_timeout, 30 seconds by default, max_reconnects, and on_reconnect.
The stream endpoints take app-only authentication, so a client that authenticates as a user streams with the bearer token that app_only holds. A client that authenticates with OAuth 2.0 as a user and holds neither the app's bearer token nor its API key and secret has no credentials of the app, so app_only raises X::UnsupportedOperation for it, and a stream it opens goes out as the user, which X refuses with 403 Forbidden, raising X::Forbidden; give the client one of them, or stream with a client built from the app's bearer token, or its API key and secret, instead.
X holds a stream open indefinitely, but drops it for deploys, network trouble, and slow readers. A stream that ends, drops, is refused a connection, or that X disconnects with an operational-disconnect reconnects at once, then waits a quarter second longer each attempt, up to 16 seconds; one that ends within a line drops what it sent of the line, which is neither parsed nor passed to on_response, and reconnects the same way. A server error, a 408 Request Timeout, a 409 Conflict, or a line that is not JSON waits 5 seconds, doubling each attempt, up to 320 seconds, and longer when the response asks for longer in its Retry-After header, but raises at once rather than wait longer than the max_rate_limit_wait of the client, as a rate limit does. A rate limit backs off from a minute, doubling each attempt, up to 320 seconds, as X asks, and waits longer for a limit that resets later, but raises X::TooManyRequests at once rather than wait longer than the max_rate_limit_wait of the client, 900 seconds by default, so that a limit on the connections of a day does not hold a stream closed for hours. Each of the three backs off on a count of its own, as X asks, so the dropped connections before a server error do not lengthen the wait after it. Delivering a post, or reading the keep-alive X sends every 20 seconds, starts every count over, so a filtered stream whose rules rarely match, connected for hours between drops, never runs out of reconnects; an operational-disconnect or a line that is not JSON does not, so a stream X keeps refusing backs off. A stream reconnects without limit by default; set max_reconnects to give up after that many attempts in a row, when it raises the error of the last, an X::NetworkError for a stream that ended or dropped, so a stream never returns but for a break from its block, or a stop. Pass on_reconnect:, a callable given the error that dropped the stream and the seconds it waits, to hear of each reconnect, since a stream that can never connect, through a host that does not resolve or a proxy set up wrong, otherwise reconnects in silence; call stop from it to give up, and the stream returns nil. An error it raises stops the stream and reaches the caller.
Important
Reconnects are unlimited by default, and silent, so a stream that cannot connect at all, as for a host that does not resolve or a network that is down, keeps trying every 16 seconds for as long as it runs. Only a certificate that does not verify, which will not verify on the next attempt either, raises at once, an X::NetworkError whose cause is the OpenSSL::SSL::SSLError. To give up on the rest, set max_reconnects, or pass on_reconnect, which is called before each reconnect with the error that dropped the stream, never nil, and the seconds it waits, and can stop the stream or raise. It is called with those two arguments, and no others, by every release of 1.x.
The rules of the filtered stream. The filtered stream delivers the posts that match the rules of the app, which belong to the stream and are read and changed through a streaming client: rules reads them, every page of them, add_rules adds them, and delete_rules deletes them, each authenticating as the app as a stream does. A rule is an X::StreamRule, a frozen value of the value it matches, the tag it is labelled with, and the id the API gave it, read as an Integer; rules returns them, and the API adds the rules it can, so add_rules returns the ones it added and yields each one it did not, such as a rule the app already has, as the X::Problem the API reported, and delete_rules returns the number it deleted and yields each problem the API reported of the rules it did not, such as a rule the app does not have. Without a block, each raises X::RulesRejected for those problems rather than drop them, whose problems are the problems and whose added or deleted_count is what the method would have returned, so a rule that was not added is never passed over in silence. A rule to add is an X::StreamRule, a Hash of a value and a tag, or a String, which is the value of a rule with no tag, so the rules of one app add themselves to another. A rule is deleted by the identifier it was given, which an X::StreamRule the API returned, a Hash holding an id, an Integer, or an X::MatchingRule names, so what rules returned deletes itself, as do the matching_rules of a post of x-objects, or by the value it matches, which a rule without an identifier or a String names, so what add_rules was given deletes what it added. Anything else raises ArgumentError before a request, even a post or another resource with an id, which would otherwise delete whichever rule shared its identifier. No rules add or delete none, and send no request. dry_run: true has the API check the rules and change none of them.
Stopping a stream. A stream runs until its block stops it. break out of the block to stop the stream and return a value, or throw to unwind to a catch further out; neither reconnects. An error raised by the block stops the stream too, even one a dropped connection would have reconnected after, and reaches the caller unchanged, as does an error raised by on_response or by the class that builds each object. A StopIteration is an error like any other here, so a block that exhausts an Enumerator of its own hears about it rather than ending the stream in silence. A block runs only when a post arrives, so it cannot stop a stream that delivers nothing; stop can, from any thread or the trap of a signal: it stops every stream the streaming client runs, the next time each waits on the API, and each returns nil. A stopped streaming client stays stopped: a stream it is asked to run later, such as one a thread had not yet opened when stop was called, returns nil at once, without a request, and stopped? tells. streaming builds a new streaming client each time it is called, so keep the one you stop in a variable, and call streaming again to stream after a stop. A block, an on_response, or an on_reconnect that is running when the stream is stopped runs to its end first.
Errors in a stream. X sends some problems in a line of their own, holding errors and no data, such as the operational-disconnect it sends before it closes a stream. Such a line is not a post, so a stream never yields it, whatever it builds objects as: it raises X::StreamError, whose problems are the X::Problem objects of the line. A line of operational-disconnects alone is X asking the stream to reconnect, so the stream reconnects after it, as after a dropped connection, and raises the error once it has no reconnects left; after a line of any other problems it does not reconnect, so you decide whether to open it again. X::Problem#disconnect? tells a disconnect apart. A post of the filtered stream names the rules it matched, which matching_rules reads as X::MatchingRule objects, each with the id and tag of a rule.
X sends a newline every 20 seconds to keep an idle stream alive, so a stream reads with a 30-second timeout of its own, which a keep-alive that arrives a little late does not trip. A connection that goes quiet is dropped and reconnected rather than held open until the 60-second read_timeout of an ordinary request. The read_timeout given to streaming must be at least 25 seconds, or nil for none, since one no longer than the 20 seconds between keep-alives would drop a stream that is quiet but connected whenever a keep-alive arrived a little late.
# Set up rules for the filtered stream
streaming = x_client.streaming
streaming.add_rules([{value: "ruby -is:retweet", tag: "ruby"}, "crystal"])
streaming.rules # => [#<X::StreamRule id=1 value="ruby -is:retweet" tag="ruby">, ...]
# Stream matching posts in real time, until interrupted
x_client.streaming.stream("tweets/search/stream") do |post|
puts post["data"]["text"]
end
# Stream posts as X::Post objects, reading the tags of the rules each matched; a line of errors alone, other than the
# operational-disconnect X sends before it closes a stream, after which it reconnects, raises X::StreamError and stops it
begin
x_client.streaming.stream("tweets/search/stream", object_class: X::Post) do |post|
puts "#{post.matching_rules.map(&:tag).join(", ")}: #{post.text}"
end
rescue X::StreamError => e
warn e.message
end
# Stop the stream from its block, which returns what break was given
first = x_client.streaming.stream("tweets/search/stream") { |post| break post }
# Stop a stream that runs in another thread, even one that delivers nothing
streaming = x_client.streaming
reader = Thread.new { streaming.stream("tweets/search/stream") { |post| queue << post } }
streaming.stop # => nil
reader.value # => nil
# Stop on Ctrl-C
streaming = x_client.streaming
Signal.trap("INT") { streaming.stop }
# Give up after five reconnects in a row
streaming_client = x_client.streaming(max_reconnects: 5)
# Report each reconnect
streaming_client = x_client.streaming(on_reconnect: ->(error, wait) { warn "#{error.message}; reconnecting in #{wait}s" })
# Log each reconnect, and give up on a stream that has failed to connect for minutes
streaming_client = x_client.streaming(on_reconnect: lambda do |error, wait|
warn "#{error.class}: #{error.message}; reconnecting in #{wait} seconds"
streaming_client.stop if error.is_a?(X::NetworkError) && wait >= 16
end)
# Delete the rules that were read, or the ones that match a value
streaming.delete_rules(streaming.rules) # => 2A client calls its on_response after every request with an X::Response, which counts the resources the response returned and reads its rate limits. The API returns rate limit headers with nearly every response, and some writes add limits on the requests of a day. The API bills each post a stream delivers, so a stream calls on_response for each one, with that post as the body.
An X::Response reads the response itself with status, headers, and body, and an X::HTTPError reads a refused one the same three ways. Every error a request raises names the request: http_method and uri are the method it was sent with and the URL it was sent to, on X::HTTPError, X::NetworkError, X::InvalidResponse, and X::TooManyRedirects alike, and the message names it too, as GET /2/users/1: Could not find user does, so a log of failures says which endpoint each one came from. The message leaves the query out, since a lookup asks for every field of a resource; uri keeps it. Header names are lowercase, whatever case the API sent them in, and a header it sent more than once is joined with a comma. http_response is the escape hatch for what those do not read: it holds the response as Net::HTTP built it. An X::InvalidResponse, raised for a successful response whose body is not JSON, is an X::HTTPError too, and reads that response the same three ways; its problem is nil and its problems are empty.
x_client = X::Client.new(**x_credentials, on_response: lambda { |response|
puts "#{response.http_method} #{response.uri.path}: #{response.resource_counts}" # {"data" => 100, "users" => 42}
limit = response.rate_limit
puts "#{limit.remaining} of #{limit.limit} left, resetting in #{limit.reset_in} seconds" if limit
})One request. A block passed to get, post, put, or delete receives the same X::Response, for code that reads the response of one request rather than of every request a client makes. It is passed what on_response is passed, at the same points: a response the API refused, before the error is raised, and each attempt of a request that was sent again. A request with both reports to the client's hook first, and both receive the one summary.
remaining = nil
user = x_client.get("users/me") { |response| remaining = response.rate_limit&.remaining }Rate limits. A request the API refuses for a rate limit raises X::TooManyRequests, whose retry_after is the number of seconds the response asks a request to wait in its Retry-After header, or else the number until the limit that refused it resets, or nil when the response says neither; the header counts from when the response was sent, so it holds however far your clock is from the API's. Its rate_limits and rate_limit read the response's limits as X::Response reads them; the limits with no requests left are exhausted_rate_limits, and the one that resets last, which reset_in counts down to, is limiting_rate_limit. A client can instead wait and retry, up to max_rate_limit_retries times, which is 0 by default. It waits only as long as max_rate_limit_wait, which is 900 seconds by default, the length of a 15-minute window. A request whose limit resets later, such as a limit on the requests of a day, raises at once, as does one refused for the usage cap of the project, which lasts until the month ends, and which error.problem&.usage_capped? tells apart from a rate limit. A refusal that does not say when its limit resets waits a minute before the first retry, doubling the wait for each retry after, as X recommends. Up to five seconds are added at random to each wait, since every request of an app shares the app's limits, and so the moment they reset: without the random share, the requests one reset releases would be sent again in one burst, to be refused together once more. They are added to the wait rather than taken off it, since a request sent before the limit resets is refused again, so max_rate_limit_wait is the longest reset a request waits for, not the longest it sleeps. Each retry signs the request afresh and passes its response to on_response. The API refuses a rate-limited request without acting on it, so retrying a write does not repeat it. A stream is the exception to the default: X::StreamingClient reconnects after a rate limit however max_rate_limit_retries is set, though it too waits no longer than max_rate_limit_wait, and does not reconnect after the usage cap.
x_client = X::Client.new(**x_credentials, max_rate_limit_retries: 3, max_rate_limit_wait: 60)
patient_client = x_client.with(max_rate_limit_retries: 0) # raise X::TooManyRequests at once againFailures the request did not cause. A 5xx response, a 408 Request Timeout, which says the API gave up waiting for the request, and a request whose answer never arrived say nothing about the request itself, so the same request may pass a moment later. A client sends one again after an X::ServerError or an X::RequestTimeout, or an X::NetworkError for a request that never reached the API, such as a connection refused or one that timed out opening, max_retries times, which is twice by default, waiting up to a second before the first retry and up to twice as long before each after, but never more than a minute, with a random share of up to half of each wait taken off: a failure of the API fails every request in flight at once, and requests that waited the same time would be sent again together, to fail together once more. Only a GET, PUT, or DELETE is sent again: the API may have acted on a POST whose answer never arrived, so that one is left to you. A request that reached the API and whose answer never arrived, such as one that timed out reading its response, is left to you too, since the API bills a read it answered whether or not the answer arrived. x-uploader sends each chunk of an upload again after any server error, 408, or network error, up to the max_retries of the client, since a chunk is safe to send twice. Any other 4xx is raised at once, since the request is the reason for it; a 429 waits for its rate limit as above. When the response carries a Retry-After header, such as a 503 that names the time its endpoint is expected back, the client waits as long as it asks, or as long as the backoff of the attempt, whichever is longer, so a request is never sent again before the API asked for it. A response that asks to be left alone for longer than a minute raises at once rather than hold you for minutes; error.retry_after reads the wait it asked for.
x_client = X::Client.new(**x_credentials, max_retries: 4) # or 0, to raise at onceWhatever max_retries is, an idempotent request that fails on a connection the client had kept open for it, which X or a proxy between can close while it is idle, is sent again on a connection opened for it, when the failure says the connection was closed before the request was sent: that request never reached the API, so nothing is repeated. A request that timed out is not sent again, this way or after max_retries, since the API may have acted on it.
See other common usage examples.
This library is a from-scratch rewrite of the Twitter Ruby library. Rather than carry that library’s design forward, it aims to be more minimal and modular: a lightweight core, x-core, that handles HTTP and little else, with optional gems, x-uploader, x-streaming, and x-objects, built on top of it. You pay only for what you use. An application that needs nothing but raw JSON can depend on x-core alone and never load the code for uploads, streams, or objects.
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:
- Less code is easier to maintain.
- Less code means fewer bugs.
- Less code runs faster.
In the immortal words of Ezra Zygmuntowicz and his Merb project (may they both rest in peace):
No code is faster than no code.
If this entire library is implemented in about 6,000 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 initially apparent, including:
- OAuth 1.0 Revision A
- OAuth 2.0
- App-only authentication
- Thread safety
- Persistent HTTP connections, reused across requests to the same host
- HTTP redirect following
- HTTP and HTTPS proxy support, configured or taken from the environment
- HTTP logging
- HTTP timeout configuration
- HTTP headers set per client, per request, or both
- HTTP error handling
- Rate limit handling
- Retrying a request after waiting for its rate limit to reset
- Sending an idempotent request again after a 5xx response, a 408, or a network failure
- Sending an idempotent request again on a new connection when one kept open had been closed at the other end
- Streaming (filtered stream, sample stream)
- Reconnecting a dropped stream
- Immutable resource objects with identity, references, and hydration
- Lazy, cached, Enumerable cursors that request the maximum page size
- Configurable base URLs for accessing different APIs/versions
- Query strings, JSON bodies, and form bodies built from Ruby values
- Uploading any file with one call, with alt text and subtitles
- Parallel uploading of large media files in chunks
- Parallel batch lookups, as many at a time as you ask for
- Partial errors reported by a response that otherwise succeeded
The gems follow Semantic Versioning, and 1.x promises their public interface: every class, module, constant, method, and keyword argument whose documentation says @api public, with the types the RBS signatures each gem ships give them, which declare that interface alone; the signatures of its internals, in sig/internal, are not shipped. A minor release may add to that interface and a patch release may fix it, but only a major release removes or changes what it promises.
Anything documented @api private is internal to the gem that declares it, even where Ruby lets you call it, as is every private constant, and either may change or go away in any release. Most of it sits in the module of its gem, such as X::Core, beside mixins that are public, such as X::Objects::API and X::Uploader::MediaUpload, so it is the documentation, not the namespace, that says which is which.
The five gems are released together at the same version, and each depends on the ones it needs, x-uploader, x-streaming, and x-objects on x-core and x on all four, with a constraint of that version or a later one of the same major version, such as >= 1.0.0, < 2 for 1.0.0, so a later release of 1.x of one installs beside the others, but never an earlier release than its own. They are built and tested together at each version, so upgrade them together, as depending on x does.
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:
- Maintain the library: Keeping it up-to-date and secure.
- Add new features: Enhancements that make your life easier.
- Provide support: Faster responses to issues and feature requests.
⭐️ 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.
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!
Click here to sponsor this project.
Many thanks to our sponsors (listed in order of when they sponsored this project):
The gem is available as open source under the terms of the MIT License.