spoo-me 0.2.0

Official Rust SDK for the spoo.me URL shortener API
Documentation

spoo.me Rust SDK

The official Rust SDK for the spoo.me link management API.

# async fn run() -> Result<(), spoo_me::Error> {
let client = spoo_me::Client::new("spoo_your_api_key");

let link = client
    .links()
    .create("https://example.com/launch")
    .alias("launch") // or emoji: "🚀🔥"
    .max_clicks(10_000)
    .send()
    .await?;
println!("{}", link.short_url); // https://spoo.me/launch
# Ok(())
# }
  • Async-first on reqwest with rustls; wasm32 supported (compile-checked and runtime-tested in a headless browser in CI)
  • Typed errors, automatic retries, streaming pagination and exports
  • Timestamps in and out as chrono::DateTime<Utc>, whatever the wire format
  • Anonymous, API key, and Sign in with Spoo authentication
  • #![forbid(unsafe_code)], panic-free library paths, thin dependency tree

Install

cargo add spoo-me

Requires Rust 1.85 or newer. The command installs the SDK only; snippets on this page also use chrono (timestamps in the public types), and some use reqwest (client injection), futures-util (stream combinators) or serde with the derive feature (escape-hatch response types). Add the ones your code names.

Authentication

Create an API key from your spoo.me dashboard and pass it explicitly:

# fn run() -> Result<(), spoo_me::Error> {
let client = spoo_me::Client::from_env()?;
# Ok(())
# }

Client::from_env() reads SPOO_API_KEY for you; that constructor is the only place the crate touches the environment. Client::anonymous() works without an account: anonymous shortening and the public endpoints (stats, previews, the emoji set) need no credentials.

Self-hosting spoo.me, sharing a connection pool, or tagging your app:

# fn run() -> Result<(), spoo_me::Error> {
let client = spoo_me::Client::builder()
    .api_key("spoo_...")
    .base_url("https://links.example.com")
    .http_client(reqwest::Client::new())
    .client_tag("my-app/1.0")
    .build()?;
# Ok(())
# }

The client is Send + Sync + Clone and cheap to clone: share one across tasks.

Shorten links

# async fn run(client: spoo_me::Client) -> Result<(), spoo_me::Error> {
use chrono::{Duration, Utc};

let link = client
    .links()
    .create("https://example.com/launch")
    .alias("launch")
    .password("secure@123")
    .max_clicks(10_000)
    .expire_after(Utc::now() + Duration::days(30))
    .send()
    .await?;
# Ok(())
# }

Anonymous creations return a one-time claim_token. Store it and the link can be claimed into an account later:

# async fn run(client: spoo_me::Client, link: spoo_me::Link) -> Result<(), spoo_me::Error> {
use spoo_me::ClaimRequest;

let token = link.claim_token.unwrap_or_default();
let outcome = client
    .links()
    .claim([ClaimRequest::new(&link.id, token)])
    .await?;
# Ok(())
# }

Manage links

# async fn run(client: spoo_me::Client) -> Result<(), spoo_me::Error> {
use spoo_me::{SettableStatus, SortBy};

// Paginated listing with typed filters.
let page = client
    .links()
    .list()
    .page_size(50)
    .sort_by(SortBy::TotalClicks)
    .status(SettableStatus::Active)
    .search("promo")
    .send()
    .await?;

// Or walk everything lazily (`stream` feature, on by default).
use futures_util::TryStreamExt as _;
let mut all = client.links().list().send().await?.stream();
while let Some(link) = all.try_next().await? {
    println!("{}", link.id);
}

// Updates only touch what you set; remove_* clears a setting explicitly.
client
    .links()
    .update("665f0c2f9e7a4b1d2c3d4e5f")
    .long_url("https://example.com/v2")
    .remove_password()
    .send()
    .await?;

// Bulk operations report per-item outcomes instead of failing the batch.
let outcome = client
    .links()
    .bulk_set_status(["id1", "id2"], SettableStatus::Inactive)
    .await?;
for row in &outcome.results {
    if !row.ok {
        println!("{}: {:?}", row.id, row.error_code);
    }
}
# Ok(())
# }

Statistics and exports

# async fn run(client: spoo_me::Client) -> Result<(), spoo_me::Error> {
use spoo_me::{Dimension, ExportFormat, FilterDimension};

let report = client
    .stats()
    .account()
    .group_by(Dimension::Time)
    .group_by(Dimension::Country)
    .filter(FilterDimension::Browser, ["Chrome"])
    .send()
    .await?;
println!("{} clicks", report.summary.total_clicks);

let per_link = client.stats().for_link("665f0c2f9e7a4b1d2c3d4e5f").send().await?;

// Exports stream; filenames from the server are reduced to a bare name
// (no separators or dot-segments), so joining one into a directory cannot
// traverse out of it. Choosing a safe directory remains your job.
let export = client
    .stats()
    .export_link("665f0c2f9e7a4b1d2c3d4e5f")
    .format(ExportFormat::Csv)
    .send()
    .await?;
println!("saving {}", export.filename);
let bytes = export.bytes().await?; // or .bytes_stream()
# Ok(())
# }

Account-wide downloads come from stats().export(); per-link downloads with per-link filenames come from stats().export_link(id).

Public endpoints

# async fn run() -> Result<(), spoo_me::Error> {
let client = spoo_me::Client::anonymous();

let stats = client.public().stats("launch").send().await?;
let locked = client.public().stats("locked").password("hunter@22").send().await?;

// Where does this short link lead? Never reveals what the redirect would
// refuse to serve.
let preview = client.public().preview("launch").await?;

// The emoji-alias catalogue, ETag-cached on the client.
let set = client.emoji().set().await?;
# Ok(())
# }

Errors

Everything returns Result<T, spoo_me::Error>, and the library never panics. API failures carry the parsed error envelope plus predicates so you can branch without string matching:

# async fn run(client: spoo_me::Client) -> Result<(), spoo_me::Error> {
match client.links().get("gone").await {
    Ok(link) => println!("{:?}", link.alias),
    Err(err) if err.is_not_found() => println!("no such link"),
    Err(err) if err.is_rate_limited() => println!("wait {:?}", err.retry_after()),
    Err(err) if err.is_blocked() => println!("taken down"),
    Err(err) => return Err(err),
}
# Ok(())
# }

Transient failures (408, 429, 5xx, connection errors) are retried twice with jittered exponential backoff capped at 8 seconds, honoring Retry-After in both its legal forms. Requests that could duplicate work on replay (POST, PATCH) retry only where the server provably did nothing (429, 503).

Sign in with Spoo

The oauth feature carries the client half of the connected-apps flow: PKCE, the device-code exchange, and a self-refreshing session.

// Requires the `oauth` feature.
# async fn run() -> Result<(), spoo_me::Error> {
use std::sync::Arc;
use spoo_me::oauth::{generate_pkce_pair, generate_state, Session, TokenPair};

let anon = spoo_me::Client::anonymous();
let pkce = generate_pkce_pair();
let state = generate_state();
let url = anon
    .oauth()
    .authorization_url("your_app_id", &state, &pkce.challenge)
    .redirect_uri("http://127.0.0.1:8000/callback")
    .build()?;
// Open `url` in a browser; your callback receives `code` and `state`.
// Verify the echoed state matches the one you sent BEFORE exchanging the
// code, and reject the flow on a mismatch (CSRF protection).

let tokens = anon.oauth().exchange_code("the-code", pkce.verifier).await?;

let session = Arc::new(Session::new(tokens.tokens()).on_refresh(|pair| {
    // Persist the rotated pair: the previous refresh token is dead.
    let _ = pair;
}));
let client = spoo_me::Client::builder().session(session).build()?;
let me = client.auth().me().await?;
# Ok(())
# }

Sessions refresh proactively before the access token expires and once more after an unexpected 401. Refreshes are single-flight across tasks, and a dead refresh token surfaces as Error::SessionExpired.

Scope

This SDK covers the v1 data plane: shortening (including emoji aliases), link management, bulk operations, claiming, statistics, exports, public link surfaces, the emoji catalogue, identity read, and Sign in with Spoo. Account administration (API key management, profile editing), service endpoints (health, contact), and the legacy v0 API are deliberately out of scope.

Area Methods
Shorten links().create(), links().check_alias()
Manage links().list(), get(), get_by_address(), update(), set_status(), delete(), delete_all_on_domain()
Bulk bulk_delete(), bulk_set_status(), bulk_set_expiry(), bulk_move_domain()
Claim links().claim()
Stats stats().account(), stats().for_link()
Export stats().export(), stats().export_link()
Public public().stats(), public().preview()
Emoji emoji().set()
Identity auth().me()
Sign in with Spoo oauth().authorization_url(), exchange_code(), refresh_tokens(), Session

Raw requests

For v1 endpoints the SDK does not cover yet, the client exposes typed passthroughs that reuse its auth, retries, timeout and error mapping:

# async fn run(client: spoo_me::Client) -> Result<(), spoo_me::Error> {
#[derive(serde::Deserialize)]
struct Whatever { ok: bool }

let value: Whatever = client.get("/api/v1/new-endpoint", &[("k", "v")]).await?;
# Ok(())
# }

These are a supported pressure valve. If you need one, the surface has a gap worth an issue on this repo.

Features

Feature Default What it adds
stream yes Lazy pagination and export bodies as futures_core::Stream
oauth no Sign in with Spoo: PKCE, exchange, refreshing sessions
tracing no Per-request spans and retry events
native-tls no Platform TLS instead of rustls

Using oauth on wasm32? getrandom requires the final binary to pick its backend: add --cfg getrandom_backend="wasm_js" to your RUSTFLAGS (usually via .cargo/config.toml).

Credits

The original spoo-me crate was created and designed by rdni. This SDK keeps that crate's design language: consuming builders, layered errors, and documented-everything discipline.

License

MIT. Versions up to 0.1.1 were published under Apache-2.0.