rerout 0.5.0

Official Rust SDK for the Rerout branded-link API.
Documentation
# rerout

Official Rust SDK for the [Rerout](https://rerout.co) API.

Branded link infrastructure on Cloudflare. Create short links, render QR codes,
read analytics, and verify webhook signatures.

## Install

```bash
cargo add rerout
```

Or add it to `Cargo.toml`:

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

Every method is async and requires a [Tokio](https://tokio.rs) runtime. The
crate ships with `rustls` TLS and pulls in no OpenSSL dependency.

## Usage

```rust,no_run
use rerout::{CreateLinkInput, Rerout};

#[tokio::main]
async fn main() -> Result<(), rerout::ReroutError> {
    let rerout = Rerout::new(std::env::var("REROUT_API_KEY").unwrap())?;

    let link = rerout
        .links()
        .create(
            &CreateLinkInput::new("https://example.com/q4-sale")
                .with_domain_hostname("go.brand.com")
                .with_code("q4"),
        )
        .await?;

    println!("{}", link.short_url); // https://go.brand.com/q4

    let stats = rerout.project().stats(7).await?;
    println!(
        "Last 7 days: {} clicks, {} QR scans",
        stats.total_clicks, stats.qr_scans,
    );

    Ok(())
}
```

## API

### Construction

Build a client through the builder. Only the API key is required.

```rust,no_run
use std::time::Duration;
use rerout::Rerout;

# fn main() -> Result<(), rerout::ReroutError> {
// Defaults: production base URL, 30s timeout.
let rerout = Rerout::new("rrk_live_xxx")?;

// Or customise via the builder.
let rerout = Rerout::builder("rrk_live_xxx")
    .base_url("https://api.staging.rerout.co")? // trailing slashes trimmed
    .timeout(Duration::from_secs(15))
    .user_agent("my-app/1.0")
    .build()?;
# let _ = rerout;
# Ok(())
# }
```

### Links

```rust,no_run
# use rerout::{CreateLinkInput, ListLinksParams, Rerout, UpdateLinkInput};
# async fn run(rerout: Rerout) -> Result<(), rerout::ReroutError> {
let created = rerout.links().create(&CreateLinkInput::new("https://example.com")).await?;
let page = rerout.links().list(ListLinksParams { cursor: None, limit: Some(20) }).await?;
let one = rerout.links().get("q4").await?;
let patched = rerout.links()
    .update("q4", &UpdateLinkInput::new().set_is_active(false))
    .await?;
let removed = rerout.links().delete("q4").await?;
let stats = rerout.links().stats("q4", 30).await?;
# let _ = (created, page, one, patched, removed, stats);
# Ok(())
# }
```

`UpdateLinkInput` distinguishes "leave the field alone" from "clear it on the
server". Use the `set_*` builders to assign a value and the `clear_*` builders
to send an explicit JSON `null`:

```rust
use rerout::UpdateLinkInput;

let input = UpdateLinkInput::new()
    .set_target_url("https://example.com/new")
    .clear_expires_at(); // sends `expires_at: null`
```

An empty `UpdateLinkInput` is rejected client-side — `links().update` returns a
`bad_request` configuration error without hitting the API.

Each returned `Link` carries a read-only `tags` field — a `Vec<Tag>` where every
`Tag` has an `id`, `name`, and `color`. It is empty on `create` and populated by
`get`, `list`, and `update`. Manage the tags themselves via the `tags()`
namespace (below).

### Project

```rust,no_run
# use rerout::Rerout;
# async fn run(rerout: Rerout) -> Result<(), rerout::ReroutError> {
let stats = rerout.project().stats(30).await?;
let project = rerout.project().me().await?;
# let _ = (stats, project);
# Ok(())
# }
```

### QR codes

`qr().url(...)` is a pure builder — it does not call the API. `qr().svg(...)`
fetches the rendered SVG with the bearer token attached.

```rust,no_run
# use rerout::{QrEcc, QrOptions, QrRefresh, Rerout};
# async fn run(rerout: Rerout) -> Result<(), rerout::ReroutError> {
let url = rerout.qr().url(
    "q4",
    &QrOptions::new()
        .with_size(12)
        .with_ecc(QrEcc::H)
        .with_domain("go.brand.com")
        .with_refresh(QrRefresh::On),
)?;

let svg = rerout.qr().svg("q4", &QrOptions::new()).await?;
# let _ = (url, svg);
# Ok(())
# }
```

### Webhook management

Manage the endpoints that receive deliveries via `webhooks()`. This is the
API-key-authenticated surface under `/v1/projects/me/webhooks` — distinct from
the inbound signature verification below.

```rust,no_run
# use rerout::{CreateWebhookInput, Rerout};
# async fn run(rerout: Rerout) -> Result<(), rerout::ReroutError> {
let created = rerout
    .webhooks()
    .create(&CreateWebhookInput::new(
        "Order events",
        "https://hooks.brand.com/rerout",
        ["link.created", "link.clicked"],
    ))
    .await?;

// The signing secret is shown once — persist it now to verify deliveries.
println!("secret: {}", created.signing_secret);
let endpoint_id = created.endpoint.id;

let list = rerout.webhooks().list().await?;
println!("{} endpoint(s)", list.endpoints.len());

let removed = rerout.webhooks().delete(&endpoint_id).await?;
assert!(removed.deleted);
# Ok(())
# }
```

### Tags

Manage the project's tags via `tags()`. This is the API-key-authenticated
surface under `/v1/projects/me/tags` — the project is resolved from the key.

```rust,no_run
# use rerout::{CreateTagInput, Rerout, UpdateTagInput};
# async fn run(rerout: Rerout) -> Result<(), rerout::ReroutError> {
// List tags with their live link counts.
let list = rerout.tags().list().await?;
for summary in &list.tags {
    println!("{} ({} links)", summary.tag.name, summary.link_count);
}

// Create a tag — color is optional and defaults to `teal` server-side.
let tag = rerout
    .tags()
    .create(&CreateTagInput::new("Spring 2026").with_color("teal"))
    .await?;

// Update — omitted fields are left unchanged.
let renamed = rerout
    .tags()
    .update(&tag.id, &UpdateTagInput::new().with_name("Renamed"))
    .await?;

// Delete — also drops the tag's assignments from all links.
let removed = rerout.tags().delete(&renamed.id).await?;
assert!(removed.deleted);
# Ok(())
# }
```

### Webhook signature verification

Verify incoming webhook deliveries with the standalone helper in
`rerout::webhooks`. It performs a constant-time HMAC-SHA256 comparison and
rejects stale timestamps.

```rust
use rerout::webhooks::{verify_rerout_signature, DEFAULT_TOLERANCE_SECONDS};

# let raw_body = "{}";
# let signature_header = "t=1,v1=00";
# let secret = "whsec_xxx";
let ok = verify_rerout_signature(
    raw_body,
    signature_header,           // value of the `X-Rerout-Signature` header
    secret,                     // endpoint signing secret (`whsec_…`)
    DEFAULT_TOLERANCE_SECONDS,  // 300s window; pass 0 to disable the clock check
    None,                       // injectable clock — `None` reads the system time
);
if !ok {
    // reject the delivery
}
```

## Error handling

Every fallible method returns `Result<T, ReroutError>`. Match on the variant for
branching, or read `code()` for the stable string identifier:

```rust,no_run
# use rerout::{CreateLinkInput, Rerout, ReroutError};
# async fn run(rerout: Rerout) {
match rerout.links().create(&CreateLinkInput::new("http://insecure.example")).await {
    Ok(link) => println!("{}", link.short_url),
    Err(err) => {
        eprintln!("{} (HTTP {}): {}", err.code(), err.status(), err.message());
        if err.is_rate_limited() { /* back off and retry */ }
        if err.is_server_error() { /* transient — retry later */ }
        if let ReroutError::Api { .. } = err { /* server replied non-2xx */ }
    }
}
# }
```

Synthetic codes are used when the server did not return a JSON body:
`network_error`, `timeout`, `unexpected_response`, `decode_error`,
`unauthorized`, `forbidden`, `not_found`, `rate_limited`, `server_error`,
`client_error`.

## Local development

```bash
cargo fmt --all
cargo clippy --all-targets -- -D warnings
cargo test
cargo doc --no-deps
```

## License

MIT — see [LICENSE](../LICENSE).

## Links

- Repository: <https://github.com/ModestNerds-Co/rerout-sdks>
- API docs: <https://rerout.co>