xdk-rs 0.2.0

Async Rust client for the X (Twitter) API: OAuth1, OAuth2 PKCE, bearer tokens, media upload, streaming. xdk is X's SDK name; this is an independent project, not affiliated with X Corp.
Documentation

xdk-rs

Crates.io docs.rs License: MIT OR Apache-2.0

Async Rust client for the X (Twitter) API v2: credentials held in code, one typed call per endpoint, OAuth1, OAuth2 PKCE, bearer tokens, media upload, and streaming. xdk is the name X uses for its own SDKs; this is an independent project, not affiliated with, endorsed by, or maintained by X. The xr command-line tool (xurl-rs) is built on this crate.

Quick start

The package is xdk-rs; the library it installs is xdk:

cargo add xdk-rs
cargo add tokio --features macros,rt-multi-thread

Those two commands add both dependencies to Cargo.toml at their current versions. #[tokio::main] needs both tokio features.

A complete program: the app-only bearer token from the X developer portal in XURL_BEARER_TOKEN, one search, the text printed.

use xdk::api::Client;

#[tokio::main]
async fn main() -> xdk::Result<()> {
    let token = std::env::var("XURL_BEARER_TOKEN").expect("XURL_BEARER_TOKEN holds the app-only bearer token");
    let client = Client::builder().bearer(token).build()?;

    let posts = client.search_posts("rust", 10).send().await?;
    for post in &posts.data {
        println!("{}: {}", post.id, post.text);
    }
    Ok(())
}

A bearer token is app-only: it reads public data (search, post and user lookups) and cannot act as a user, so get_me, create_post, likes, follows, and direct messages answer 401 or 403 under it. Anything user-scoped needs a client built from an OAuth2 credential, described under Authentication.

Every request draws credits at the rates on X's pricing page, and the app must be enrolled as described in the repository's Before you start section; without that step, reads fail with client-not-enrolled even when the credential is valid.

Authentication

The client holds credentials directly and picks the scheme per call from the endpoint's accepted set, in OAuth2, OAuth1, bearer order. Four paths:

  • Bearer (app-only): Client::builder().bearer(token). Public reads and search.
  • OAuth2 user context: an OAuth2Credential carrying the app's client ID and secret, the access token, and the optional refresh token and expiry. The client refreshes an expired token on the next call and hands the rotated pair to the OnTokenRefreshed hook registered with on_token_refreshed. X rotates the refresh token on every refresh, so the hook is where the new pair gets persisted; a hook that returns an error fails the call that triggered the refresh.
  • OAuth1 HMAC-SHA1: an OAuth1Credential with the consumer pair and the user's access pair, for legacy v1.1 endpoints and some v2 write paths.
  • The token store: Client::new(&Config, Auth) or Client::from_env() builds a client over the ~/.xurl store the xr CLI writes, so a program can reuse a sign-in done with xr auth oauth2 with nothing exported; from_env also reads CLIENT_ID, CLIENT_SECRET, REDIRECT_URI, AUTH_URL, TOKEN_URL, API_BASE_URL, INFO_URL, and XURL_BEARER_TOKEN when they are set. TokenStore::refresh_hook_for is the reference implementation of the refresh hook.
use std::time::{Duration, SystemTime};

use xdk::api::Client;
use xdk::auth::OAuth2Credential;

#[tokio::main]
async fn main() -> xdk::Result<()> {
    let var = |name: &str| std::env::var(name).unwrap_or_else(|_| panic!("{name} is not set"));
    let credential = OAuth2Credential {
        client_id: var("CLIENT_ID"),
        client_secret: var("CLIENT_SECRET"),
        access_token: var("ACCESS_TOKEN"),
        refresh_token: std::env::var("REFRESH_TOKEN").ok(),
        expires_at: Some(SystemTime::now() + Duration::from_secs(7200)),
    };
    let client = Client::builder().oauth2(credential).build()?;

    let me = client.get_me().send().await?;
    println!("signed in as @{}", me.data.username);
    Ok(())
}

Errors and rate limits

Every call returns xdk::Result<T>, whose error is the #[non_exhaustive] xdk::Error. Beyond Display, an error answers four questions: kind() names its category as a stable string, exit_code() maps it to the process exit code the xr CLI uses, next_action() names the one thing a caller can do about it as a closed NextAction (sign in, register an app, enroll the app, and so on), and docs_url() points at the page that explains it when one exists. An error that wraps a lower-level failure keeps it: std::error::Error::source() returns the reqwest, std::io, serde_json, or serde_yaml error underneath, so a caller can walk the chain or downcast to it.

A 429 carries the reset its own response named, as seconds since the Unix epoch in the reset_at field of Error::Api, and NextAction::WaitAndRetry with it; a 429 that named none carries neither. ClientBuilder::wait_on_rate_limit makes the client wait for that reset and send the request once more, when the wait fits the bound it is given. Client::last_rate_limit() returns the x-rate-limit-* window from the most recent response that reported one, which is what a loop pacing its own requests reads.

use xdk::api::Client;

#[tokio::main]
async fn main() {
    let client = Client::builder().bearer("app-only-token").build().expect("client");
    match client.search_posts("rust", 10).send().await {
        Ok(posts) => println!("{} posts", posts.data.len()),
        Err(err) => {
            eprintln!("{}: {err}", err.kind());
            if let Some(action) = err.next_action() {
                eprintln!("next: {action:?}");
            }
            if let Some(url) = err.docs_url() {
                eprintln!("see {url}");
            }
            std::process::exit(err.exit_code());
        }
    }
}

Testing

Two ways to exercise code built on this crate without spending credits.

The testing feature ships xdk::testing::MockX, an in-process mock of the API. MockX::start().await binds a local server seeded with the fixture responses the crate's own response types are validated against, so a read against it deserializes into the same types a live call would, and every response carries a rate-limit window. app_client() and user_client() return clients pointed at it, stub overrides a route or rehearses a failure, and requests() lists what arrived. Enable it in a test profile only, so a release build pulls none of its dependencies:

cargo add xdk-rs --dev --features testing

The module's docs.rs page carries a complete program, and the offline_search example runs one end to end with no X app, no credential, and no network, from a clone of the repository:

cargo run -p xdk-rs --example offline_search --features testing

X's playground is X's own local server that simulates the API v2 with seeded users and posts, for proving the wire protocol end to end rather than for unit tests. Two limits: it needs a Go toolchain, and its parameter vocabulary predates X's post-vocabulary rename (spec 2.168), so a call that sends the current expansion names comes back as an invalid-request error.

go install github.com/xdevplatform/playground/cmd/playground@latest
export PATH="$PATH:$(go env GOPATH)/bin"
playground start --port 8089

Point a client at it with Client::builder().bearer("test_token").base_url("http://localhost:8089"); a bearer reaches its app-only endpoints, and user-context calls stop at the auth matrix before a request leaves the machine, the same refusal they get without credentials against the real API.

What the crate publishes

Every published module is one an embedder has a reason to call:

  • api: the client and its builder, one Call per shortcut, the MediaUpload builder behind Client::upload_media, the typed responses, the raw-request path for endpoints without a shortcut, and the last rate-limit window a response reported. A tracing subscriber on api::VOCABULARY_TARGET receives one DEBUG event for each key a typed response carried in X's legacy post vocabulary and the library read under its current name, with the fields legacy, normalized, value_type, value_len, and collision, and no values.
  • auth: credentials held in code, the refresh hook that receives a rotated token pair, the store-backed Auth, and the OAuth2 sign-in flows.
  • config: base URL, timeouts, and the environment overrides a client built from the environment reads.
  • error: Error, Result, the machine-readable NextAction, and the exit codes a CLI built on this crate maps them to.
  • store: TokenStore, the on-disk credential store the CLI shares, and the reference implementation of the refresh hook.

Items marked #[doc(hidden)] are seams the xr binary reaches across the crate boundary; they stay callable but are not part of this surface.

To see which keys X still sends in its legacy post vocabulary, install any tracing subscriber filtered to the target, here with tracing-subscriber:

use tracing_subscriber::filter::Targets;
use tracing_subscriber::prelude::*;

tracing_subscriber::registry()
    .with(tracing_subscriber::fmt::layer())
    .with(Targets::new().with_target(xdk::api::VOCABULARY_TARGET, tracing::Level::DEBUG))
    .init();

Each legacy key then prints once per response, while the typed value reads under its current name:

DEBUG xdk::vocabulary: legacy="edit_history_tweet_ids" normalized="edit_history_post_ids" value_type="array" value_len=1 collision=false

Cargo features

  • rustls (default): TLS through rustls with the platform's certificate verifier; no system TLS library is linked.
  • native-tls: TLS through the operating system's library (OpenSSL on Linux, Secure Transport on macOS, SChannel on Windows) via native-tls. To use it alone, turn the default off: cargo add xdk-rs --no-default-features --features native-tls. With both backends enabled, reqwest picks native-tls.
  • testing: an in-process mock of the API seeded from the crate's fixtures, for tests that must not spend credits; see Testing.

A build with neither TLS feature fails at compile time with a message naming both, rather than at the first https request.

Stability

The crate follows SemVer 2.0.0. Its contract is the public Rust API, which cargo semver-checks reads on every pull request, and the behavior the rustdoc documents. Items marked #[doc(hidden)] are outside it.

A break ships only in a release that moves the breaking position, and its changelog entry carries a before/after snippet. Versioning has the rules.

Versioning

The crate is 0.x, so Cargo reads the middle number as the breaking position: a breaking change moves it (0.1.x to 0.2.0), and an addition or a fix moves the last number (0.1.0 to 0.1.1), which a ^0.1 requirement picks up. Two rules make the breaking releases livable:

  • Every breaking entry in the changelog carries a before/after snippet, not only a description, so the developer who adopted at one 0.y and upgrades two later types the new form straight from the changelog. A release that breaks something and ships no snippet does not go out.
  • An MSRV bump never ships in the last number. rust-version is declared once, in the workspace's [workspace.package], and both crates inherit it, so a bump moves the floor of xdk-rs and xurl-rs together: the middle number for xdk-rs and a minor for xurl-rs, independent version lines notwithstanding.

The two crates version and tag independently: the library on xdk-rs-vX.Y.Z, the CLI on vX.Y.Z. Breaking changes the CLI takes across its majors are written up under docs/migrating.

Relationship to xr and xurl

xr (xurl-rs) is the command-line tool built on this crate and an independent Rust port of xdevplatform/xurl, X's own Go CLI. The two crates share the ~/.xurl token store, so a sign-in done with xr auth oauth2 is usable from a program through Client::from_env(). The repository's README routes between the two.

License

Licensed under either of Apache License, Version 2.0 or MIT license at your option.