better-fetch 0.2.0

Typed HTTP client layer on top of reqwest — inspired by @better-fetch/fetch
Documentation
# better-fetch

Typed HTTP client layer on top of [reqwest](https://docs.rs/reqwest), inspired by
[@better-fetch/fetch](https://better-fetch.vercel.app/docs). Independent Rust implementation.

## Installation

Pick one crate name (same library):

```toml
[dependencies]
better-fetch = "0.2"
serde = { version = "1", features = ["derive"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
tokio-util = "0.7"
```

Aliases on crates.io: [`typed-fetch`](https://crates.io/crates/typed-fetch), [`api-fetch`](https://crates.io/crates/api-fetch) — `pub use better_fetch::*`.

Optional features:

```toml
better-fetch = { version = "0.2", features = ["tower", "validate", "multipart"] }
```

## Quick start

```rust
use better_fetch::{Client, Result};
use serde::Deserialize;

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Todo {
    user_id: u64,
    id: u64,
    title: String,
    completed: bool,
}

#[tokio::main]
async fn main() -> Result<()> {
    let client = Client::new("https://jsonplaceholder.typicode.com")?;

    // send() returns Response (any status); json() fails on non-2xx
    let todo: Todo = client
        .get("/todos/:id")
        .param("id", 1)
        .send()
        .await?
        .json()
        .await?;

    // Or in one step:
    let todo: Todo = client.get("/todos/:id").param("id", 1).send_json().await?;

    println!("{todo:#?}");
    Ok(())
}
```

## Highlights

- **Builder API**`Client::builder()`, per-request `.timeout()`, `.retry()`, `.auth()`, headers, JSON body.
- **Retries** — linear, exponential, or `count`; `Retry-After`, jitter, custom `should_retry`; default retry on 408/429/502/503/504.
- **Hooks & plugins** — compose client and plugin hooks; optional `LoggerPlugin` (requires a `tracing` subscriber in your app).
- **Errors**`Result` + `?`; `Error::api_json()` to parse JSON error bodies from APIs.
- **Typed endpoints**`Endpoint` trait + `client.call::<E>()``EndpointRequestBuilder` with typed `send_json()`.
- **Testing** — inject `ClientBuilder::backend(Arc<dyn HttpBackend>)`.
- **Cancellation**`CancellationToken` per request; cooperative abort during requests and retry backoff.
- **Throw mode**`throw_on_error(true)` makes `send()` return `Err` on non-2xx (like upstream `throw: true`).
- **Form & multipart**`.form([...])` for url-encoded bodies; `.multipart(form)` with feature `multipart`.

### Request options

| Method | Description |
|--------|-------------|
| `.param` / `.params` / `.params_iter` | Path template `:id` substitution |
| `.query` / `.queries` | Query string (stable insertion order via `IndexMap`) |
| `.query_json` | Serialize a value into a query param (feature `json`) |
| `.json` / `.body` | Request body |
| `.form` | `application/x-www-form-urlencoded` body |
| `.multipart` | Multipart form (feature `multipart`) |
| `.timeout` / `.retry` | Per-request overrides |
| `.auth` / `.bearer_token` | Per-request auth |
| `.cancellation_token` | Cancel in-flight request + retry sleeps |
| `.throw_on_error` | `send()` returns `Err` on non-2xx when `true` |
| `.send` / `.send_json` | Execute request |

### Cancellation

```rust
use better_fetch::{CancellationToken, Client, Result};
use std::time::Duration;

#[tokio::main]
async fn main() -> Result<()> {
    let token = CancellationToken::new();
    let client = Client::new("https://httpbin.org")?;

    let handle = tokio::spawn({
        let token = token.clone();
        let client = client.clone();
        async move {
            client
                .get("/delay/10")
                .cancellation_token(token)
                .send()
                .await
        }
    });

    tokio::time::sleep(Duration::from_millis(100)).await;
    token.cancel();

    assert!(handle.await.unwrap().unwrap_err().is_cancelled());
    Ok(())
}
```

### Throw on HTTP error

```rust
// Default: Ok(Response) even for 404
let response = client.get("/missing").send().await?;

// Like upstream throw: true
let err = client
    .get("/missing")
    .throw_on_error(true)
    .send()
    .await
    .unwrap_err();
```

### Typed endpoint

```rust
use better_fetch::{Client, Endpoint, Result};
use http::Method;
use serde::Deserialize;

struct GetTodo;
impl Endpoint for GetTodo {
    const METHOD: Method = Method::GET;
    const PATH: &'static str = "/todos/:id";
    type Response = Todo;
    type Params = ();
    type Query = ();
}

#[derive(Deserialize)]
struct Todo { id: u64, title: String }

async fn example(client: &Client) -> Result<()> {
    let todo = client
        .call::<GetTodo>()
        .param("id", 1)
        .send_json()
        .await?;
    Ok(())
}
```

### Form and multipart

```rust
// URL-encoded form
client
    .post("/login")
    .form([("user", "alice"), ("pass", "secret")])
    .send()
    .await?;

// Multipart (feature "multipart")
let form = better_fetch::multipart::Form::new().text("file", "hello");
client.post("/upload").multipart(form).send().await?;
```

Note: **automatic retry is not supported** with `.multipart()` (the body cannot be replayed). Use `.form`, JSON, or raw bytes if you need retries.

### Client builder

`ClientBuilder::build()` requires `.base_url(...)` — otherwise `Error::MissingBaseUrl`.

```rust
use better_fetch::ClientBuilder;

let client = ClientBuilder::new()
    .base_url("https://api.example.com")?
    .retry(RetryPolicy::exponential(3, Duration::from_secs(1), Duration::from_secs(30)))
    .build()?;
```

### Concurrency limits

`ClientBuilder::max_in_flight` uses a tokio semaphore in the core client (counts retries as in-flight work). The `tower` feature’s `ConcurrencyLimitLayer` is a separate transport-level cap. Use **one** of these at a given limit unless you intentionally want two stacked caps (e.g. app-wide budget + per-host transport limit).

### Plugins

`Plugin::init` receives `PreparedRequest` with `url`, `path`, `method`, and `headers` (after auth, before lifecycle hooks). Use it to rewrite URLs or inspect auth headers.

## Features

| Feature | Description |
|---------|-------------|
| `reqwest`, `json`, `rustls-tls` (default) | Async client, JSON, TLS |
| `native-tls` | Platform TLS |
| `blocking`, `cookies` | Passed through to reqwest |
| `multipart` | `RequestBuilder::multipart` + `better_fetch::multipart` re-export |
| `schema` / `openapi` | `schemars` registry, strict routes, and OpenAPI 3.0 export |
| `tower` / `tower-http` | Tower `Service` transport stack (`better_fetch::tower`) |
| `validate` | Response validation with `garde` (`send_json_validated`) |
| `macros` | Reserved `better-fetch-macros` crate |

See [CHANGELOG.md](CHANGELOG.md) for release notes.

## Examples

```bash
cargo run -p better-fetch --example basic
cargo run -p better-fetch --example multipart --features multipart
cargo run -p better-fetch --example retry
cargo test -p better-fetch
cargo test -p better-fetch --features default,validate,tower,multipart
```

## Crates in this repository

| crates.io | Role |
|-----------|------|
| [better-fetch]https://crates.io/crates/better-fetch | Main library |
| [typed-fetch]https://crates.io/crates/typed-fetch | Re-export alias |
| [api-fetch]https://crates.io/crates/api-fetch | Re-export alias |
| [better-fetch-macros]https://crates.io/crates/better-fetch-macros | Proc macros (reserved) |

## License

MIT — see [LICENSE](LICENSE). Upstream inspiration: [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).