cryptohopper 0.1.0-alpha.2

Official Rust SDK for the Cryptohopper API. Async, typed errors, stdlib-on-top-of-reqwest transport, full coverage of all 18 public API domains.
Documentation
# Getting Started

## Install

```bash
cargo add cryptohopper
```

Or in your `Cargo.toml`:

```toml
[dependencies]
cryptohopper = "0.1.0-alpha.1"
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
```

Requires Rust 1.74 or newer (for `async fn` in traits, used internally) and a `tokio` runtime.

## First call

```rust
use cryptohopper::Client;

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let token = std::env::var("CRYPTOHOPPER_TOKEN")?;
    let client = Client::new(token)?;

    let me = client.user.get().await?;
    println!("Logged in as: {}", me["email"]);

    let ticker = client
        .exchange
        .ticker("binance", "BTC/USDT")
        .await?;
    println!("BTC/USDT: {}", ticker["last"]);
    Ok(())
}
```

`Client` is cheaply cloneable (`Arc` internally), so you can pass it to spawned tasks without wrapping it in another `Arc`.

## Getting a token

Every request (except a handful of public endpoints like `/exchange/ticker`) needs an OAuth2 bearer token. Create one via **Developer → Create App** on [cryptohopper.com](https://www.cryptohopper.com) and complete the consent flow. The token is a 40-character opaque string.

For local dev:

```bash
export CRYPTOHOPPER_TOKEN=<your-token>
```

For production, load from your secret store at startup. `dotenv` works for dev, but in CI use the runner's secret-injection mechanism.

## Idiomatic patterns

### Pattern matching on `ErrorCode`

The SDK returns a typed `cryptohopper::Error` whose `code` field is a `cryptohopper::ErrorCode` enum. Match on it directly — no string-comparison gymnastics:

```rust
use cryptohopper::{Client, ErrorCode};

match client.hoppers.get("999999").await {
    Ok(hopper) => println!("got: {hopper:?}"),
    Err(err) => match err.code {
        ErrorCode::NotFound => {
            // Expected; ignore.
        }
        ErrorCode::Unauthorized => {
            refresh_token().await?;
            // retry
        }
        ErrorCode::RateLimited => {
            // SDK already retried; back off harder
            if let Some(ms) = err.retry_after_ms {
                tokio::time::sleep(std::time::Duration::from_millis(ms)).await;
            }
        }
        ErrorCode::Forbidden => {
            eprintln!("blocked from IP: {:?}", err.ip_address);
        }
        ErrorCode::Other(ref code) => {
            // Server returned a code the SDK doesn't know about yet —
            // pass through cleanly.
            tracing::warn!("unknown cryptohopper code: {code}");
        }
        _ => return Err(err.into()),
    },
}
```

The `Other(String)` variant catches any new server-side codes the SDK predates. You don't need to bump SDK versions to handle new error types.

### `?` propagation with `anyhow` or thiserror

The SDK error implements `std::error::Error`, so `?` works naturally with `anyhow::Result` or any custom error type built with `thiserror`:

```rust
use anyhow::Result;

async fn list_open_positions(client: &cryptohopper::Client, hopper_id: &str) -> Result<Vec<serde_json::Value>> {
    let raw = client.hoppers.positions(hopper_id).await?;
    Ok(raw.as_array().cloned().unwrap_or_default())
}
```

For library code, define your own error type:

```rust
#[derive(thiserror::Error, Debug)]
pub enum MyError {
    #[error("cryptohopper: {0}")]
    Cryptohopper(#[from] cryptohopper::Error),

    #[error("missing field: {0}")]
    MissingField(&'static str),
}
```

`#[from]` auto-converts the SDK error.

### Customising the client

```rust
use std::time::Duration;
use cryptohopper::Client;

let client = Client::builder()
    .api_key(std::env::var("CRYPTOHOPPER_TOKEN")?)
    .app_key(std::env::var("CRYPTOHOPPER_APP_KEY").unwrap_or_default())
    .base_url("https://api.staging.cryptohopper.com/v1")
    .timeout(Duration::from_secs(60))
    .max_retries(5)
    .user_agent("my-app/1.0")
    .build()?;
```

The builder is the full-control entry point. `Client::new(api_key)` is a shortcut for `Client::builder().api_key(...).build()` when defaults are fine.

### Async + concurrency

`Client` is `Send + Sync + Clone`. Spawn it across tasks:

```rust
use futures::stream::{FuturesUnordered, StreamExt};

let mut tasks = FuturesUnordered::new();
for id in hopper_ids {
    let client = client.clone();
    tasks.push(tokio::spawn(async move {
        client.hoppers.get(&id).await
    }));
}

while let Some(res) = tasks.next().await {
    match res? {
        Ok(hopper) => process(hopper),
        Err(err) => tracing::warn!("hopper fetch failed: {err}"),
    }
}
```

See [Rate Limits](Rate-Limits.md) for guidance on capping concurrency at the API quota.

## Common pitfalls

**`Error: Cryptohopper(Error { code: ValidationError, ... })`** with `message: "api_key must not be empty"` — you passed an empty string. Most often: `std::env::var("CRYPTOHOPPER_TOKEN").unwrap_or_default()` returns `""` when unset. Use `?` to fail loudly:

```rust
let token = std::env::var("CRYPTOHOPPER_TOKEN")
    .map_err(|_| anyhow::anyhow!("CRYPTOHOPPER_TOKEN is not set"))?;
```

**`code: Unauthorized` on every call** — token is wrong, expired, or revoked. Visit the app page in the Cryptohopper dashboard to confirm.

**`code: Forbidden` on endpoints that used to work** — IP allowlisting on the OAuth app blocked your current IP. The error includes `ip_address`:

```rust
if let Err(err) = client.hoppers.list().await {
    if matches!(err.code, ErrorCode::Forbidden) {
        eprintln!("blocked from {:?}", err.ip_address);
    }
}
```

**`error[E0277]: ?` operator can only be applied to values that implement `Try`** — usually means you're inside a function that returns something other than `Result`. Either change the function signature to `Result<T, E>` or pattern-match on the SDK call's return.

**Hangs in tests** — your tests don't have a tokio runtime. Use `#[tokio::test]` instead of `#[test]`:

```rust
#[tokio::test]
async fn it_lists_hoppers() {
    let client = Client::new("test-token").unwrap();
    // ...
}
```

## Type signatures

Response shapes are returned as `serde_json::Value` because the Cryptohopper API hasn't been frozen into stable models yet. To layer typed parsing on top, use `serde::Deserialize`:

```rust
use serde::Deserialize;

#[derive(Deserialize)]
struct Hopper {
    id: u64,
    name: String,
    exchange: String,
    enabled: bool,
}

let raw = client.hoppers.get(&id).await?;
let hopper: Hopper = serde_json::from_value(raw)?;
```

Future SDK versions may ship typed response structs as a feature flag — file an issue if you'd benefit.

## Next steps

- [Authentication]Authentication.md — bearer flow, app keys, IP whitelisting, custom HTTP clients
- [Error Handling]Error-Handling.md — every variant, pattern-match recipes, retry wrappers
- [Rate Limits]Rate-Limits.md — auto-retry, customising back-off, concurrency caps