xdk-rs 0.1.3

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 leave this in Cargo.toml. #[tokio::main] needs both tokio features:

[dependencies]
xdk-rs = "0.1"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }

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. A 429 carries no next action; Client::last_rate_limit() returns the x-rate-limit-* window from the most recent response that reported one, which is what a retry loop waits on.

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:

[dev-dependencies]
xdk-rs = { version = "0.1", 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: xdk-rs = { version = "0.1", default-features = false, 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.

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.