rocksky 0.3.0

Async Rust SDK for the Rocksky XRPC API.
Documentation
# rocksky — Rust SDK

Async Rust SDK for the [Rocksky](https://rocksky.app) XRPC API.

- **Async-first** — built on `tokio` + `reqwest`
- **Typed** — strongly typed `serde` models, snake_case API
- **Builder-friendly** — every list/paginated endpoint and every multi-field
  request is a fluent builder (`.limit(10).offset(0).send().await`)
- **Idiomatic** — namespaced accessors: `client.actor()`, `client.scrobble()`, …
- **Escape hatch**`client.call()` / `client.procedure()` for any XRPC method
  not yet wrapped
- **Pipe-friendly** — every response round-trips through `serde_json`, so you
  can stream results straight to `stdout` or shell pipelines (`jq`, etc.)
- Compiles on **Rust 1.75+**

## Install

```toml
[dependencies]
rocksky = "0.2"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
```

Pin TLS backend if you don't want `rustls` (the default):

```toml
rocksky = { version = "0.2", default-features = false, features = ["native-tls"] }
```

## Quickstart

```rust
use rocksky::Client;

#[tokio::main]
async fn main() -> rocksky::Result<()> {
    let client = Client::new();

    let me = client.actor().get_profile("tsiry-sandratraina.com").await?;
    println!("{} — {}",
        me.display_name.as_deref().unwrap_or(""),
        me.did.as_deref().unwrap_or(""));

    let scrobbles = client
        .scrobble()
        .list()
        .did(me.did.clone().unwrap_or_default())
        .limit(10)
        .send()
        .await?;
    for s in scrobbles {
        println!("  {} — {}",
            s.artist.as_deref().unwrap_or("?"),
            s.title.as_deref().unwrap_or("?"));
    }
    Ok(())
}
```

### Authenticating

Pass a bearer token at construction time:

```rust
let client = rocksky::Client::builder()
    .token("eyJhbGciOi…")
    .build();
```

…or change it later:

```rust
client.set_token(Some("new-token".into())).await;
```

### Self-hosting / custom base URL

Default is `https://api.rocksky.app`:

```rust
let client = rocksky::Client::builder()
    .base_url("http://localhost:8000")
    .token(token)
    .timeout(std::time::Duration::from_secs(10))
    .build();
```

## Builders everywhere

The fluent style maps every optional field to a chainable method, then calls
`.send()` to dispatch:

```rust
// List builder
let songs = client.actor().get_songs("did:plc:7vdlgi2bflelz7mmuxoqjfcr")
    .limit(50)
    .offset(0)
    .start_date(chrono::Utc::now() - chrono::Duration::days(30))
    .send().await?;

// Mutation builder (only the required args are positional)
let _ = client.scrobble().create("Hounds of Love", "Kate Bush")
    .album("Hounds of Love")
    .duration(298_000)
    .year(1985)
    .send().await?;

// Charts
let top = client.charts().top_tracks()
    .limit(100)
    .send().await?;
```

## Resources

| Namespace | Accessor | Methods (selected) |
|-----------|----------|--------------------|
| `app.rocksky.actor` | `client.actor()` | `get_profile`, `get_profile_me`, `get_albums`, `get_artists`, `get_songs`, `get_scrobbles`, `get_loved_songs`, `get_playlists`, `get_neighbours`, `get_compatibility` |
| `app.rocksky.album` | `client.album()` | `get`, `list`, `get_tracks` |
| `app.rocksky.artist` | `client.artist()` | `get`, `list`, `get_albums`, `get_tracks`, `get_listeners`, `get_recent_listeners` |
| `app.rocksky.song` | `client.song()` | `get`, `get_by_mbid`, `get_by_isrc`, `get_by_spotify_id`, `list`, `match_song`, `get_recent_listeners`, `create` |
| `app.rocksky.scrobble` | `client.scrobble()` | `get`, `list`, `create` |
| `app.rocksky.charts` | `client.charts()` | `top_tracks`, `top_artists`, `scrobbles_chart` |
| `app.rocksky.feed` | `client.feed()` | `get`, `search`, `stories`, `recommendations`, `artist_recommendations`, `album_recommendations`, `get_generator`, `list_generators` |
| `app.rocksky.graph` | `client.graph()` | `follow`, `unfollow`, `get_followers`, `get_follows`, `get_known_followers` |
| `app.rocksky.shout` | `client.shout()` | `create`, `reply`, `remove`, `report`, `for_profile`, `for_album`, `for_artist`, `for_track`, `replies` |
| `app.rocksky.like` | `client.like()` | `like_song`, `dislike_song`, `like_shout`, `dislike_shout` |
| `app.rocksky.playlist` | `client.playlist()` | `get`, `list`, `create`, `remove`, `start`, `insert_files`, `insert_directory` |
| `app.rocksky.player` | `client.player()` | `currently_playing`, `queue`, `play`, `pause`, `next`, `previous`, `seek`, `play_file`, `play_directory`, `add_items_to_queue` |
| `app.rocksky.spotify` | `client.spotify()` | `currently_playing`, `play`, `pause`, `next`, `previous`, `seek` |
| `app.rocksky.apikey` | `client.apikey()` | `list`, `create`, `update`, `remove` |
| `app.rocksky.stats` | `client.stats()` | `get`, `wrapped` |
| `app.rocksky.mirror` | `client.mirror()` | `list_sources`, `put_source` |
| `app.rocksky.dropbox` | `client.dropbox()` | `list_files`, `metadata`, `temporary_link`, `download_file` |
| `app.rocksky.googledrive` | `client.googledrive()` | `list_files`, `get_file`, `download_file` |

For methods that aren't typed yet, drop down to the generic escape hatch:

```rust
let raw: serde_json::Value =
    client.call("app.rocksky.feed.describeFeedGenerator").await?;
```

## Errors

All fallible methods return `Result<T, rocksky::Error>`. The `Error` enum
carries the full server response when the API returns a non-2xx:

```rust
use rocksky::Error;

match client.song().get("at://does-not-exist").await {
    Ok(song) => println!("{song:?}"),
    Err(e) if e.is_not_found() => eprintln!("not found"),
    Err(Error::Api { status, error, message, .. }) => {
        eprintln!("api error {status}: {error:?} / {message:?}");
    }
    Err(Error::MissingToken { method }) => {
        eprintln!("auth required for {method}");
    }
    Err(e) => return Err(e.into()),
}
```

Convenience predicates: `is_unauthorized()`, `is_forbidden()`,
`is_not_found()`, `is_rate_limited()`, `is_client_error()`, `is_server_error()`.

## Pipe-friendly CLI use

The included `search` example streams hits as one JSON object per line —
drop-in for `jq`:

```bash
cargo run --example search -- "kate bush" \
    | jq -c '{type: .type, title: (.title // .name)}'
```

## Examples

Runnable examples live in [`examples/`](examples/):

- `examples/quickstart.rs` — fetch a profile and recent scrobbles
- `examples/scrobble.rs` — submit a scrobble (needs `ROCKSKY_TOKEN`)
- `examples/wrapped.rs` — print a user's year-in-review JSON
- `examples/search.rs` — search + line-delimited JSON output
- `examples/follow_feed.rs` — page through the follow-graph feed

Run with:

```bash
cargo run --example quickstart -- tsiry-sandratraina.com
```

## Types

Public model types are derived from the [Rocksky lexicons](https://tangled.org/rocksky.app/rocksky/tree/main/apps/api/lexicons) and live in `rocksky::generated`. The hand-written `rocksky::models` module re-exports them under their historical SDK names and extends `Profile` / `ApiKey` with fields the lexicon does not yet model. Regenerate with `bun run lexgen:types` at the repo root.


## Testing your own code against the SDK

Provide your own `reqwest::Client` and point the SDK at a mock server.
The SDK's own test suite uses [`wiremock`](https://docs.rs/wiremock):

```rust
use wiremock::{Mock, ResponseTemplate};
use wiremock::matchers::{method, path};

#[tokio::test]
async fn it_works() {
    let server = wiremock::MockServer::start().await;
    Mock::given(method("GET"))
        .and(path("/xrpc/app.rocksky.feed.search"))
        .respond_with(ResponseTemplate::new(200)
            .set_body_json(serde_json::json!({"hits": []})))
        .mount(&server).await;

    let client = rocksky::Client::builder().base_url(server.uri()).build();
    let results = client.feed().search("kate bush").await.unwrap();
    assert!(results.hits.is_empty());
}
```

## Development

```bash
cargo build
cargo test
cargo doc --open
```

## License

[MIT](LICENSE) © Tsiry Sandratraina.