clients/rust: initial Rust SDK for clawdforge
Async client over reqwest+tokio with builder-pattern Client, serde RunRequest/RunResult/FileToken/AppToken types, thiserror Error enum, streaming multipart upload via tokio::fs::File, and 14 wiremock-backed integration tests covering healthz, run-success-json, run-success-text, run-502, run-with-files, file-upload, token mint/list/revoke, auth failure, missing-token short-circuit, transport timeout, and builder validation. Doc-tested. cargo test, cargo clippy --all-targets -D warnings, and cargo build --examples all clean.
This commit is contained in:
parent
b1d6e3f697
commit
062d405a9e
9 changed files with 1262 additions and 0 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -13,3 +13,7 @@ venv/
|
||||||
.vscode/
|
.vscode/
|
||||||
data/
|
data/
|
||||||
runs/
|
runs/
|
||||||
|
|
||||||
|
# Rust
|
||||||
|
clients/rust/target/
|
||||||
|
clients/rust/Cargo.lock
|
||||||
|
|
|
||||||
29
clients/rust/Cargo.toml
Normal file
29
clients/rust/Cargo.toml
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
[package]
|
||||||
|
name = "clawdforge"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
license = "MIT"
|
||||||
|
description = "Async Rust client for the clawdforge HTTP service (a LAN bearer-token-gated wrapper around `claude -p`)."
|
||||||
|
repository = "https://gitea.sulkta.com/Sulkta-Coop/clawdforge"
|
||||||
|
readme = "README.md"
|
||||||
|
keywords = ["claude", "http-client", "sdk", "async"]
|
||||||
|
categories = ["api-bindings", "asynchronous"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "stream", "rustls-tls"] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
thiserror = "1"
|
||||||
|
tokio = { version = "1", features = ["fs", "io-util"] }
|
||||||
|
tokio-util = { version = "0.7", features = ["io"] }
|
||||||
|
url = "2"
|
||||||
|
bytes = "1"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tokio = { version = "1", features = ["macros", "rt-multi-thread", "fs", "io-util", "time"] }
|
||||||
|
wiremock = "0.6"
|
||||||
|
tempfile = "3"
|
||||||
|
|
||||||
|
[[example]]
|
||||||
|
name = "basic"
|
||||||
|
path = "examples/basic.rs"
|
||||||
179
clients/rust/README.md
Normal file
179
clients/rust/README.md
Normal file
|
|
@ -0,0 +1,179 @@
|
||||||
|
# clawdforge — Rust client
|
||||||
|
|
||||||
|
Async Rust SDK for [clawdforge], a small LAN-only HTTP service that wraps
|
||||||
|
`claude -p` subprocess calls behind a bearer-token-gated REST API.
|
||||||
|
|
||||||
|
[clawdforge]: https://gitea.sulkta.com/Sulkta-Coop/clawdforge
|
||||||
|
|
||||||
|
- Tokio + reqwest under the hood
|
||||||
|
- `serde` + `serde_json` types
|
||||||
|
- Streaming multipart upload (`tokio::fs::File`, no full-file buffer)
|
||||||
|
- Builder pattern for configuration
|
||||||
|
- Typed `RunResult::as_json::<T>()` and `as_text()` helpers over a
|
||||||
|
`serde_json::Value` payload
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
This crate is not on crates.io. Pull it directly from the upstream git host:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo add clawdforge --git https://gitea.sulkta.com/Sulkta-Coop/clawdforge --rev <pin>
|
||||||
|
```
|
||||||
|
|
||||||
|
Or pin manually in `Cargo.toml`:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[dependencies]
|
||||||
|
clawdforge = { git = "https://gitea.sulkta.com/Sulkta-Coop/clawdforge", rev = "<pin>" }
|
||||||
|
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
|
||||||
|
```
|
||||||
|
|
||||||
|
For an in-repo workspace consumer, point at the `clients/rust/` path:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
clawdforge = { path = "../clawdforge/clients/rust" }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quickstart
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use clawdforge::{Client, RunRequest};
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let client = Client::builder()
|
||||||
|
.base_url("http://localhost:8800")
|
||||||
|
.token("cf_xxxxxxxxxxxxxxxx")
|
||||||
|
.build()?;
|
||||||
|
|
||||||
|
// Liveness — does not require a token, but sends one if configured.
|
||||||
|
let h = client.healthz().await?;
|
||||||
|
println!("claude present: {} version: {:?}", h.claude_present, h.claude_version);
|
||||||
|
|
||||||
|
// Run a prompt. `result` is a serde_json::Value — narrow via .as_json::<T>().
|
||||||
|
let r = client.run(RunRequest {
|
||||||
|
prompt: "Reply with JSON: {\"hello\":\"world\"}".into(),
|
||||||
|
model: Some("sonnet".into()),
|
||||||
|
timeout_secs: Some(30),
|
||||||
|
..Default::default()
|
||||||
|
}).await?;
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct Hello { hello: String }
|
||||||
|
let typed: Hello = r.as_json()?;
|
||||||
|
println!("{}", typed.hello);
|
||||||
|
|
||||||
|
// Upload a file, then attach it to a follow-up run.
|
||||||
|
let ft = client.upload_file("./recipe.png", Some(3600)).await?;
|
||||||
|
let r2 = client.run(RunRequest {
|
||||||
|
prompt: "extract recipe data".into(),
|
||||||
|
files: Some(vec![ft.file_token]),
|
||||||
|
..Default::default()
|
||||||
|
}).await?;
|
||||||
|
println!("{:?}", r2.result);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Public API
|
||||||
|
|
||||||
|
### `Client::builder()`
|
||||||
|
|
||||||
|
Builder for the HTTP client.
|
||||||
|
|
||||||
|
| Method | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `.base_url(url)` | Required. e.g. `"http://localhost:8800"`. |
|
||||||
|
| `.token(t)` | App bearer for `/run`, `/files`. |
|
||||||
|
| `.admin_token(t)` | Admin bearer for `/admin/*`. |
|
||||||
|
| `.timeout(Duration)` | Per-request timeout (default 120 s). |
|
||||||
|
| `.user_agent(s)` | Override `User-Agent` header. |
|
||||||
|
| `.danger_accept_invalid_certs(bool)` | Skip TLS verify (off by default). |
|
||||||
|
| `.build()` | Returns `Result<Client, Error>`. |
|
||||||
|
|
||||||
|
### `Client` async methods
|
||||||
|
|
||||||
|
| Method | Endpoint | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `healthz()` | `GET /healthz` | Returns `Healthz`. |
|
||||||
|
| `run(RunRequest)` | `POST /run` | Returns `RunResult`. 502 surfaces as `Error::Api`. |
|
||||||
|
| `upload_file(path, ttl_secs)` | `POST /files` | Streams from disk; returns `FileToken`. |
|
||||||
|
| `create_token(TokenCreateRequest)` | `POST /admin/tokens` | Admin only. Returns `AppToken`. |
|
||||||
|
| `list_tokens()` | `GET /admin/tokens` | Admin only. Returns `TokenList`. |
|
||||||
|
| `revoke_token(name)` | `DELETE /admin/tokens/{name}` | Admin only. |
|
||||||
|
|
||||||
|
### `RunResult` helpers
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let r = client.run(req).await?;
|
||||||
|
|
||||||
|
// Try a typed shape.
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct Recipe { name: String, qty: u32 }
|
||||||
|
let recipe: Recipe = r.as_json()?;
|
||||||
|
|
||||||
|
// Or fall back to a string when the model declined to emit JSON.
|
||||||
|
if let Some(text) = r.as_text() {
|
||||||
|
println!("{text}");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`r.result` itself is `serde_json::Value` if you need to branch on shape.
|
||||||
|
|
||||||
|
### Error model
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub enum Error {
|
||||||
|
Auth(String), // missing/invalid bearer, 401, 403
|
||||||
|
Api { status: u16, body: String }, // any other non-2xx
|
||||||
|
Transport(reqwest::Error), // connect, TLS, read, request timeout
|
||||||
|
Json(serde_json::Error), // decode failure on a 2xx body
|
||||||
|
Io(std::io::Error), // local file open in upload_file
|
||||||
|
Timeout(String), // explicit deadline (reserved)
|
||||||
|
Config(String), // builder misconfiguration
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
A 502 from `/run` lands in `Error::Api { status: 502, body }` — the body is
|
||||||
|
the JSON failure envelope. Recover the structured form with:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let parsed: clawdforge::RunFailure = serde_json::from_str(&body)?;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Wire format
|
||||||
|
|
||||||
|
clawdforge speaks **snake_case JSON** end-to-end. The structs in this crate
|
||||||
|
match that without `#[serde(rename_all = "camelCase")]`. If a future endpoint
|
||||||
|
exposes camelCase, prefer per-field `#[serde(rename = "...")]` over a blanket
|
||||||
|
container attribute so both styles can coexist.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
```sh
|
||||||
|
CLAWDFORGE_URL=http://localhost:8800 \
|
||||||
|
CLAWDFORGE_TOKEN=cf_xxxx \
|
||||||
|
cargo run --example basic
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional file demo:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
CLAWDFORGE_DEMO_FILE=./some.png cargo run --example basic
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo build --release
|
||||||
|
cargo test --all
|
||||||
|
cargo clippy --all-targets -- -D warnings
|
||||||
|
cargo build --examples
|
||||||
|
```
|
||||||
|
|
||||||
|
Tests use [`wiremock`](https://docs.rs/wiremock) — no live clawdforge needed.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT.
|
||||||
78
clients/rust/examples/basic.rs
Normal file
78
clients/rust/examples/basic.rs
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
//! End-to-end usage example.
|
||||||
|
//!
|
||||||
|
//! Run against a live clawdforge:
|
||||||
|
//!
|
||||||
|
//! ```sh
|
||||||
|
//! CLAWDFORGE_URL=http://localhost:8800 \
|
||||||
|
//! CLAWDFORGE_TOKEN=cf_xxxxxxxxxxxxxxxx \
|
||||||
|
//! cargo run --example basic
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
use clawdforge::{Client, RunRequest};
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct Hello {
|
||||||
|
hello: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let url = std::env::var("CLAWDFORGE_URL")
|
||||||
|
.unwrap_or_else(|_| "http://localhost:8800".to_string());
|
||||||
|
let token = std::env::var("CLAWDFORGE_TOKEN").map_err(|_| {
|
||||||
|
"set CLAWDFORGE_TOKEN to a cf_... bearer minted via /admin/tokens"
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let client = Client::builder()
|
||||||
|
.base_url(url)
|
||||||
|
.token(token)
|
||||||
|
.build()?;
|
||||||
|
|
||||||
|
// 1) liveness
|
||||||
|
let h = client.healthz().await?;
|
||||||
|
println!(
|
||||||
|
"healthz: ok={} claude_present={} version={:?}",
|
||||||
|
h.ok, h.claude_present, h.claude_version
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2) JSON-shaped run
|
||||||
|
let r = client
|
||||||
|
.run(RunRequest {
|
||||||
|
prompt: r#"Reply with JSON: {"hello": "world"}"#.into(),
|
||||||
|
model: Some("sonnet".into()),
|
||||||
|
timeout_secs: Some(30),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
println!("duration_ms={} stop_reason={:?}", r.duration_ms, r.stop_reason);
|
||||||
|
|
||||||
|
match r.as_json::<Hello>() {
|
||||||
|
Ok(v) => println!("parsed: hello = {}", v.hello),
|
||||||
|
Err(_) => match r.as_text() {
|
||||||
|
Some(t) => println!("text reply: {t}"),
|
||||||
|
None => println!("unparseable reply: {:?}", r.result),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) optional file upload — only if a path is given.
|
||||||
|
if let Ok(path) = std::env::var("CLAWDFORGE_DEMO_FILE") {
|
||||||
|
let ft = client.upload_file(&path, Some(3600)).await?;
|
||||||
|
println!(
|
||||||
|
"uploaded {} bytes -> {} (ttl {}s)",
|
||||||
|
ft.size, ft.file_token, ft.ttl_secs
|
||||||
|
);
|
||||||
|
|
||||||
|
let r2 = client
|
||||||
|
.run(RunRequest {
|
||||||
|
prompt: "Describe the attached file in one sentence.".into(),
|
||||||
|
files: Some(vec![ft.file_token]),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
println!("file-run reply: {:?}", r2.result);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
323
clients/rust/src/client.rs
Normal file
323
clients/rust/src/client.rs
Normal file
|
|
@ -0,0 +1,323 @@
|
||||||
|
//! HTTP client for clawdforge.
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION};
|
||||||
|
use reqwest::multipart::{Form, Part};
|
||||||
|
use reqwest::{Body, Method, Response, StatusCode};
|
||||||
|
use serde::de::DeserializeOwned;
|
||||||
|
use tokio_util::io::ReaderStream;
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
|
use crate::error::Error;
|
||||||
|
use crate::types::{
|
||||||
|
AppToken, FileToken, Healthz, RunRequest, RunResult, TokenCreateRequest, TokenList,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Default request timeout if neither the builder nor the per-call helper sets
|
||||||
|
/// one. 120 s leaves headroom over the server's default 60 s `claude` timeout
|
||||||
|
/// without making `/healthz` callers wait forever on a dead host.
|
||||||
|
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(120);
|
||||||
|
|
||||||
|
/// Async client for the clawdforge HTTP API.
|
||||||
|
///
|
||||||
|
/// Construct via [`Client::builder`]. `Client` is cheap to clone — internally
|
||||||
|
/// it wraps an `Arc`-backed `reqwest::Client`.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Client {
|
||||||
|
inner: reqwest::Client,
|
||||||
|
base: Url,
|
||||||
|
/// App-level bearer (used for `/run`, `/files`, `/healthz`). Optional so
|
||||||
|
/// admin-only callers don't have to mint a worthless app token.
|
||||||
|
app_token: Option<String>,
|
||||||
|
/// Admin bootstrap token (used for `/admin/*`). Optional.
|
||||||
|
admin_token: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builder for [`Client`].
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct ClientBuilder {
|
||||||
|
base_url: Option<String>,
|
||||||
|
app_token: Option<String>,
|
||||||
|
admin_token: Option<String>,
|
||||||
|
timeout: Option<Duration>,
|
||||||
|
user_agent: Option<String>,
|
||||||
|
danger_accept_invalid_certs: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Client {
|
||||||
|
/// Start building a client.
|
||||||
|
pub fn builder() -> ClientBuilder {
|
||||||
|
ClientBuilder::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Base URL the client was configured with (trailing slash trimmed).
|
||||||
|
pub fn base_url(&self) -> &str {
|
||||||
|
self.base.as_str().trim_end_matches('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- public API --------------------------------------------------------
|
||||||
|
|
||||||
|
/// `GET /healthz`. Does not require an app token, but the server still
|
||||||
|
/// enforces the global IP allowlist.
|
||||||
|
pub async fn healthz(&self) -> Result<Healthz, Error> {
|
||||||
|
let url = self.url("/healthz")?;
|
||||||
|
let req = self.inner.request(Method::GET, url);
|
||||||
|
// healthz works without a token, but if we have one, send it — the
|
||||||
|
// server treats it as a no-op.
|
||||||
|
let req = match &self.app_token {
|
||||||
|
Some(t) => req.bearer_auth(t),
|
||||||
|
None => req,
|
||||||
|
};
|
||||||
|
let resp = req.send().await?;
|
||||||
|
json_or_error(resp).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /run`. Returns the parsed [`RunResult`] on success. On HTTP 502
|
||||||
|
/// the body is surfaced as [`Error::Api`] with `status = 502` and the
|
||||||
|
/// failure JSON in `body` — see [`crate::types::RunFailure`] for the
|
||||||
|
/// structured form.
|
||||||
|
pub async fn run(&self, body: RunRequest) -> Result<RunResult, Error> {
|
||||||
|
let token = self
|
||||||
|
.app_token
|
||||||
|
.as_deref()
|
||||||
|
.ok_or_else(|| Error::Auth("no app token configured".into()))?;
|
||||||
|
let url = self.url("/run")?;
|
||||||
|
let resp = self
|
||||||
|
.inner
|
||||||
|
.post(url)
|
||||||
|
.bearer_auth(token)
|
||||||
|
.json(&body)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
json_or_error(resp).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /files`. Streams the file from disk via `tokio::fs::File`; large
|
||||||
|
/// uploads do not buffer fully in memory.
|
||||||
|
///
|
||||||
|
/// `ttl_secs` defaults to the server's 3600 if `None`. Server clamps to
|
||||||
|
/// `60..=86400`.
|
||||||
|
pub async fn upload_file(
|
||||||
|
&self,
|
||||||
|
path: impl AsRef<Path>,
|
||||||
|
ttl_secs: Option<u32>,
|
||||||
|
) -> Result<FileToken, Error> {
|
||||||
|
let token = self
|
||||||
|
.app_token
|
||||||
|
.as_deref()
|
||||||
|
.ok_or_else(|| Error::Auth("no app token configured".into()))?;
|
||||||
|
let path = path.as_ref();
|
||||||
|
let file_name = path
|
||||||
|
.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.unwrap_or("upload")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let file = tokio::fs::File::open(path).await?;
|
||||||
|
let len = file.metadata().await?.len();
|
||||||
|
|
||||||
|
let stream = ReaderStream::new(file);
|
||||||
|
let body = Body::wrap_stream(stream);
|
||||||
|
|
||||||
|
let part = Part::stream_with_length(body, len)
|
||||||
|
.file_name(file_name)
|
||||||
|
.mime_str("application/octet-stream")
|
||||||
|
.map_err(|e| Error::Config(format!("invalid mime: {e}")))?;
|
||||||
|
|
||||||
|
let mut form = Form::new().part("file", part);
|
||||||
|
if let Some(t) = ttl_secs {
|
||||||
|
form = form.text("ttl_secs", t.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let url = self.url("/files")?;
|
||||||
|
let resp = self
|
||||||
|
.inner
|
||||||
|
.post(url)
|
||||||
|
.bearer_auth(token)
|
||||||
|
.multipart(form)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
json_or_error(resp).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /admin/tokens`. Requires an admin token on the client.
|
||||||
|
pub async fn create_token(
|
||||||
|
&self,
|
||||||
|
body: TokenCreateRequest,
|
||||||
|
) -> Result<AppToken, Error> {
|
||||||
|
let token = self.require_admin()?;
|
||||||
|
let url = self.url("/admin/tokens")?;
|
||||||
|
let resp = self
|
||||||
|
.inner
|
||||||
|
.post(url)
|
||||||
|
.bearer_auth(token)
|
||||||
|
.json(&body)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
json_or_error(resp).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /admin/tokens`. Requires an admin token on the client.
|
||||||
|
pub async fn list_tokens(&self) -> Result<TokenList, Error> {
|
||||||
|
let token = self.require_admin()?;
|
||||||
|
let url = self.url("/admin/tokens")?;
|
||||||
|
let resp = self
|
||||||
|
.inner
|
||||||
|
.get(url)
|
||||||
|
.bearer_auth(token)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
json_or_error(resp).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `DELETE /admin/tokens/{name}`. Requires an admin token on the client.
|
||||||
|
/// Returns `Ok(())` on success, [`Error::Api`] with status 404 if the
|
||||||
|
/// token does not exist.
|
||||||
|
pub async fn revoke_token(&self, name: &str) -> Result<(), Error> {
|
||||||
|
let token = self.require_admin()?;
|
||||||
|
let url = self.url(&format!("/admin/tokens/{name}"))?;
|
||||||
|
let resp = self
|
||||||
|
.inner
|
||||||
|
.delete(url)
|
||||||
|
.bearer_auth(token)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
let _: serde_json::Value = json_or_error(resp).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- internal ----------------------------------------------------------
|
||||||
|
|
||||||
|
fn require_admin(&self) -> Result<&str, Error> {
|
||||||
|
self.admin_token
|
||||||
|
.as_deref()
|
||||||
|
.ok_or_else(|| Error::Auth("no admin token configured".into()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn url(&self, path: &str) -> Result<Url, Error> {
|
||||||
|
let trimmed = path.strip_prefix('/').unwrap_or(path);
|
||||||
|
self.base
|
||||||
|
.join(trimmed)
|
||||||
|
.map_err(|e| Error::Config(format!("bad path {path:?}: {e}")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ClientBuilder {
|
||||||
|
/// Set the base URL (e.g. `http://localhost:8800`). Required.
|
||||||
|
pub fn base_url(mut self, url: impl Into<String>) -> Self {
|
||||||
|
self.base_url = Some(url.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the app bearer token used for `/run`, `/files`, and `/healthz`.
|
||||||
|
pub fn token(mut self, token: impl Into<String>) -> Self {
|
||||||
|
self.app_token = Some(token.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the admin bootstrap token used for `/admin/*`. May be set
|
||||||
|
/// alongside [`Self::token`].
|
||||||
|
pub fn admin_token(mut self, token: impl Into<String>) -> Self {
|
||||||
|
self.admin_token = Some(token.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-request timeout for the underlying `reqwest::Client`. Defaults to
|
||||||
|
/// 120 s.
|
||||||
|
pub fn timeout(mut self, timeout: Duration) -> Self {
|
||||||
|
self.timeout = Some(timeout);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Override the `User-Agent` header. Defaults to
|
||||||
|
/// `clawdforge-rs/<crate-version>`.
|
||||||
|
pub fn user_agent(mut self, ua: impl Into<String>) -> Self {
|
||||||
|
self.user_agent = Some(ua.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Skip TLS certificate verification. Off by default. Only useful against
|
||||||
|
/// self-signed local deployments.
|
||||||
|
pub fn danger_accept_invalid_certs(mut self, enable: bool) -> Self {
|
||||||
|
self.danger_accept_invalid_certs = enable;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Finalize. Errors if `base_url` is missing or unparseable.
|
||||||
|
pub fn build(self) -> Result<Client, Error> {
|
||||||
|
let base_raw = self
|
||||||
|
.base_url
|
||||||
|
.ok_or_else(|| Error::Config("base_url is required".into()))?;
|
||||||
|
// Ensure trailing slash so `Url::join` treats the base as a directory.
|
||||||
|
let base_str = if base_raw.ends_with('/') {
|
||||||
|
base_raw
|
||||||
|
} else {
|
||||||
|
format!("{base_raw}/")
|
||||||
|
};
|
||||||
|
let base =
|
||||||
|
Url::parse(&base_str).map_err(|e| Error::Config(format!("invalid base_url: {e}")))?;
|
||||||
|
if !matches!(base.scheme(), "http" | "https") {
|
||||||
|
return Err(Error::Config(format!(
|
||||||
|
"unsupported scheme: {}",
|
||||||
|
base.scheme()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let ua = self
|
||||||
|
.user_agent
|
||||||
|
.unwrap_or_else(|| format!("clawdforge-rs/{}", env!("CARGO_PKG_VERSION")));
|
||||||
|
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(
|
||||||
|
reqwest::header::ACCEPT,
|
||||||
|
HeaderValue::from_static("application/json"),
|
||||||
|
);
|
||||||
|
// We don't preset Authorization here — per-call helpers do it because
|
||||||
|
// the right token depends on which endpoint is being hit.
|
||||||
|
let _ = AUTHORIZATION; // silence unused-import lint in some configs
|
||||||
|
|
||||||
|
let inner = reqwest::Client::builder()
|
||||||
|
.timeout(self.timeout.unwrap_or(DEFAULT_TIMEOUT))
|
||||||
|
.user_agent(ua)
|
||||||
|
.default_headers(headers)
|
||||||
|
.danger_accept_invalid_certs(self.danger_accept_invalid_certs)
|
||||||
|
.build()?;
|
||||||
|
|
||||||
|
Ok(Client {
|
||||||
|
inner,
|
||||||
|
base,
|
||||||
|
app_token: self.app_token,
|
||||||
|
admin_token: self.admin_token,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode `T` from a successful 2xx response, otherwise lift to [`Error`].
|
||||||
|
async fn json_or_error<T: DeserializeOwned>(resp: Response) -> Result<T, Error> {
|
||||||
|
let status = resp.status();
|
||||||
|
if status.is_success() {
|
||||||
|
let bytes = resp.bytes().await?;
|
||||||
|
return Ok(serde_json::from_slice::<T>(&bytes)?);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-2xx: capture body lossily then translate.
|
||||||
|
let body = resp.text().await.unwrap_or_default();
|
||||||
|
match status {
|
||||||
|
StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => Err(Error::Auth(format!(
|
||||||
|
"{}: {}",
|
||||||
|
status.as_u16(),
|
||||||
|
truncate(&body, 500)
|
||||||
|
))),
|
||||||
|
_ => Err(Error::api(status.as_u16(), body)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn truncate(s: &str, max: usize) -> String {
|
||||||
|
if s.len() <= max {
|
||||||
|
s.to_string()
|
||||||
|
} else {
|
||||||
|
format!("{}…", &s[..max])
|
||||||
|
}
|
||||||
|
}
|
||||||
58
clients/rust/src/error.rs
Normal file
58
clients/rust/src/error.rs
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
//! Error types for the clawdforge client.
|
||||||
|
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
/// All errors surfaced by the client.
|
||||||
|
///
|
||||||
|
/// Variants are deliberately coarse — the underlying transport / serde errors
|
||||||
|
/// are preserved as `source()` for callers who want to dig in.
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum Error {
|
||||||
|
/// 401/403 from the server, or a missing/empty bearer token configured on
|
||||||
|
/// the client.
|
||||||
|
#[error("authentication failed: {0}")]
|
||||||
|
Auth(String),
|
||||||
|
|
||||||
|
/// Any non-2xx response that wasn't an auth error. The response body is
|
||||||
|
/// captured as a UTF-8 string (lossy if the server returned binary).
|
||||||
|
#[error("api error: status={status} body={body}")]
|
||||||
|
Api {
|
||||||
|
/// HTTP status code.
|
||||||
|
status: u16,
|
||||||
|
/// Response body (best-effort UTF-8).
|
||||||
|
body: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Request never completed: DNS, connect, TLS, body-read, etc.
|
||||||
|
#[error("transport error: {0}")]
|
||||||
|
Transport(#[from] reqwest::Error),
|
||||||
|
|
||||||
|
/// JSON decode failed on a successful HTTP response.
|
||||||
|
#[error("json error: {0}")]
|
||||||
|
Json(#[from] serde_json::Error),
|
||||||
|
|
||||||
|
/// Local I/O error — currently only emitted by `upload_file` when opening
|
||||||
|
/// the source file.
|
||||||
|
#[error("io error: {0}")]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
|
||||||
|
/// The configured request timeout elapsed before the server replied.
|
||||||
|
/// `reqwest` also surfaces timeouts via [`Error::Transport`]; this variant
|
||||||
|
/// is reserved for explicit deadlines in the client itself.
|
||||||
|
#[error("timeout: {0}")]
|
||||||
|
Timeout(String),
|
||||||
|
|
||||||
|
/// Misconfigured client (e.g. invalid base URL).
|
||||||
|
#[error("invalid configuration: {0}")]
|
||||||
|
Config(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Error {
|
||||||
|
/// Build an [`Error::Api`] from a status code and body string.
|
||||||
|
pub(crate) fn api(status: u16, body: impl Into<String>) -> Self {
|
||||||
|
Self::Api {
|
||||||
|
status,
|
||||||
|
body: body.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
56
clients/rust/src/lib.rs
Normal file
56
clients/rust/src/lib.rs
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
//! Async Rust client for the [clawdforge] HTTP service.
|
||||||
|
//!
|
||||||
|
//! clawdforge is a small LAN-only service that wraps `claude -p` subprocess
|
||||||
|
//! calls behind a bearer-token-gated REST API. This crate is a thin,
|
||||||
|
//! ergonomic Rust SDK for it.
|
||||||
|
//!
|
||||||
|
//! # Quickstart
|
||||||
|
//!
|
||||||
|
//! ```no_run
|
||||||
|
//! use clawdforge::{Client, RunRequest};
|
||||||
|
//!
|
||||||
|
//! # async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
//! let client = Client::builder()
|
||||||
|
//! .base_url("http://localhost:8800")
|
||||||
|
//! .token("cf_xxxxxxxxxxxxxxxx")
|
||||||
|
//! .build()?;
|
||||||
|
//!
|
||||||
|
//! let h = client.healthz().await?;
|
||||||
|
//! println!("claude present: {}", h.claude_present);
|
||||||
|
//!
|
||||||
|
//! let r = client.run(RunRequest {
|
||||||
|
//! prompt: "Reply with JSON: {\"hello\":\"world\"}".into(),
|
||||||
|
//! model: Some("sonnet".into()),
|
||||||
|
//! ..Default::default()
|
||||||
|
//! }).await?;
|
||||||
|
//!
|
||||||
|
//! #[derive(serde::Deserialize)]
|
||||||
|
//! struct Hello { hello: String }
|
||||||
|
//! let typed: Hello = r.as_json()?;
|
||||||
|
//! println!("{}", typed.hello);
|
||||||
|
//! # Ok(()) }
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! [clawdforge]: https://gitea.sulkta.com/Sulkta-Coop/clawdforge
|
||||||
|
//!
|
||||||
|
//! # Field naming
|
||||||
|
//!
|
||||||
|
//! The clawdforge wire format is snake_case end-to-end (Python / Pydantic
|
||||||
|
//! conventions), so structs in [`crate::types`] do **not** carry
|
||||||
|
//! `#[serde(rename_all = "camelCase")]`. If a future endpoint exposes
|
||||||
|
//! camelCase, prefer per-field `#[serde(rename = "...")]` over a blanket
|
||||||
|
//! container attribute.
|
||||||
|
|
||||||
|
#![deny(rust_2018_idioms)]
|
||||||
|
#![warn(missing_docs)]
|
||||||
|
|
||||||
|
mod client;
|
||||||
|
mod error;
|
||||||
|
pub mod types;
|
||||||
|
|
||||||
|
pub use client::{Client, ClientBuilder};
|
||||||
|
pub use error::Error;
|
||||||
|
pub use types::{
|
||||||
|
AppToken, AppTokenInfo, FileToken, Healthz, RunFailure, RunRequest, RunResult,
|
||||||
|
TokenCreateRequest, TokenList,
|
||||||
|
};
|
||||||
173
clients/rust/src/types.rs
Normal file
173
clients/rust/src/types.rs
Normal file
|
|
@ -0,0 +1,173 @@
|
||||||
|
//! Wire types matching the clawdforge HTTP API.
|
||||||
|
//!
|
||||||
|
//! Field naming note: clawdforge uses snake_case on the wire (matches Python /
|
||||||
|
//! Pydantic conventions), so these structs use plain `#[derive(Serialize,
|
||||||
|
//! Deserialize)]` without `rename_all`. If a future endpoint surfaces
|
||||||
|
//! camelCase, opt in per-field with `#[serde(rename = "...")]`.
|
||||||
|
|
||||||
|
use serde::{de::DeserializeOwned, Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::error::Error;
|
||||||
|
|
||||||
|
/// `GET /healthz` response body.
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
pub struct Healthz {
|
||||||
|
/// Always `true` if the server replied.
|
||||||
|
pub ok: bool,
|
||||||
|
/// Whether the `claude` binary was discovered on `PATH`.
|
||||||
|
pub claude_present: bool,
|
||||||
|
/// First line of `claude --version` (or `null` if not present).
|
||||||
|
pub claude_version: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request body for `POST /run`.
|
||||||
|
///
|
||||||
|
/// All optional fields default to `None` — use `..Default::default()` to fill
|
||||||
|
/// the rest:
|
||||||
|
///
|
||||||
|
/// ```no_run
|
||||||
|
/// use clawdforge::RunRequest;
|
||||||
|
/// let r = RunRequest {
|
||||||
|
/// prompt: "say hi".into(),
|
||||||
|
/// model: Some("sonnet".into()),
|
||||||
|
/// ..Default::default()
|
||||||
|
/// };
|
||||||
|
/// ```
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
pub struct RunRequest {
|
||||||
|
/// Prompt text. Must be non-empty server-side.
|
||||||
|
pub prompt: String,
|
||||||
|
|
||||||
|
/// Model alias passed to `claude -p --model`. `None` falls back to the
|
||||||
|
/// server-side default (typically `sonnet`).
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub model: Option<String>,
|
||||||
|
|
||||||
|
/// System prompt appended via `claude -p --append-system-prompt`.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub system: Option<String>,
|
||||||
|
|
||||||
|
/// File tokens previously returned from [`Client::upload_file`].
|
||||||
|
///
|
||||||
|
/// [`Client::upload_file`]: crate::Client::upload_file
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub files: Option<Vec<String>>,
|
||||||
|
|
||||||
|
/// Subprocess timeout in seconds. Server clamps to `5..=600`.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub timeout_secs: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Successful response body from `POST /run`.
|
||||||
|
///
|
||||||
|
/// `result` is intentionally a [`serde_json::Value`] — clawdforge auto-parses
|
||||||
|
/// the `claude` reply as JSON when possible and falls back to a raw string
|
||||||
|
/// otherwise. Use [`RunResult::as_json`] to deserialize into a typed struct
|
||||||
|
/// or [`RunResult::as_text`] when you expect a string.
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
pub struct RunResult {
|
||||||
|
/// Always `true` for a 200 response (the server returns 502 on failure).
|
||||||
|
pub ok: bool,
|
||||||
|
/// Parsed claude output. JSON object/array/number/bool when the model
|
||||||
|
/// emitted JSON; string otherwise.
|
||||||
|
pub result: serde_json::Value,
|
||||||
|
/// Wall-clock duration of the subprocess.
|
||||||
|
pub duration_ms: u64,
|
||||||
|
/// `claude` stop reason, e.g. `"end_turn"`. Sometimes `None` on edge cases.
|
||||||
|
pub stop_reason: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RunResult {
|
||||||
|
/// Deserialize `result` as `T`. Fails with [`Error::Json`] if the server
|
||||||
|
/// returned a string or a JSON shape that doesn't match `T`.
|
||||||
|
pub fn as_json<T: DeserializeOwned>(&self) -> Result<T, Error> {
|
||||||
|
Ok(serde_json::from_value(self.result.clone())?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Borrow `result` as a string slice if it was a JSON string.
|
||||||
|
/// Returns `None` for objects, arrays, numbers, etc.
|
||||||
|
pub fn as_text(&self) -> Option<&str> {
|
||||||
|
self.result.as_str()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Failure body from `POST /run` (HTTP 502).
|
||||||
|
///
|
||||||
|
/// Surfaced inside [`Error::Api`] via the `body` field as JSON text. Provided
|
||||||
|
/// here so callers can `serde_json::from_str::<RunFailure>(&body)` to recover
|
||||||
|
/// structured data.
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
pub struct RunFailure {
|
||||||
|
/// Always `false` for a failure body.
|
||||||
|
pub ok: bool,
|
||||||
|
/// Short error label set by the runner (e.g. `"timeout"`, `"non_zero_exit"`).
|
||||||
|
pub error: Option<String>,
|
||||||
|
/// Last 4 KB of `claude` stderr, when available.
|
||||||
|
pub stderr: Option<String>,
|
||||||
|
/// Wall-clock duration of the (failed) subprocess.
|
||||||
|
pub duration_ms: u64,
|
||||||
|
/// `claude` stop reason if the failure produced one.
|
||||||
|
pub stop_reason: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Response body from `POST /files`.
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
pub struct FileToken {
|
||||||
|
/// Opaque token, prefix `ff_`. Pass to [`RunRequest::files`].
|
||||||
|
pub file_token: String,
|
||||||
|
/// TTL the server registered (clamped to 60..=86400).
|
||||||
|
pub ttl_secs: u32,
|
||||||
|
/// Bytes written to the staging dir.
|
||||||
|
pub size: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Response body from `POST /admin/tokens`.
|
||||||
|
///
|
||||||
|
/// `token` is the plaintext bearer — only returned at creation time.
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
pub struct AppToken {
|
||||||
|
/// App / consumer name.
|
||||||
|
pub name: String,
|
||||||
|
/// Plaintext bearer (`cf_...`). Save it now; the server stores only the
|
||||||
|
/// SHA-256.
|
||||||
|
pub token: String,
|
||||||
|
/// CIDRs the token is restricted to. Empty = unrestricted (still subject
|
||||||
|
/// to the global allowlist).
|
||||||
|
pub ip_cidrs: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request body for `POST /admin/tokens`.
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
pub struct TokenCreateRequest {
|
||||||
|
/// `[a-z0-9][a-z0-9_-]{0,63}` — server enforces.
|
||||||
|
pub name: String,
|
||||||
|
/// Optional CIDR allowlist for this token.
|
||||||
|
#[serde(default)]
|
||||||
|
pub ip_cidrs: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One row of `GET /admin/tokens`.
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
pub struct AppTokenInfo {
|
||||||
|
/// App / consumer name.
|
||||||
|
pub name: String,
|
||||||
|
/// CIDRs this token is restricted to. Empty = no per-token CIDR restriction.
|
||||||
|
#[serde(default)]
|
||||||
|
pub ip_cidrs: Vec<String>,
|
||||||
|
/// Unix epoch seconds (server-controlled field shape — extra fields are
|
||||||
|
/// captured by `extra`).
|
||||||
|
#[serde(default)]
|
||||||
|
pub created_at: Option<i64>,
|
||||||
|
|
||||||
|
/// Catch-all for any future fields the server adds.
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub extra: serde_json::Map<String, serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Response body from `GET /admin/tokens`.
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
pub struct TokenList {
|
||||||
|
/// All registered app tokens (hashes only — plaintext is shown once at
|
||||||
|
/// creation).
|
||||||
|
pub tokens: Vec<AppTokenInfo>,
|
||||||
|
}
|
||||||
362
clients/rust/tests/client.rs
Normal file
362
clients/rust/tests/client.rs
Normal file
|
|
@ -0,0 +1,362 @@
|
||||||
|
//! Integration tests against an in-process wiremock server.
|
||||||
|
|
||||||
|
use std::io::Write;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use clawdforge::{Client, Error, RunRequest, TokenCreateRequest};
|
||||||
|
use serde_json::json;
|
||||||
|
use wiremock::matchers::{body_json, body_string_contains, header, method, path, path_regex};
|
||||||
|
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||||
|
|
||||||
|
fn make_client(server: &MockServer) -> Client {
|
||||||
|
Client::builder()
|
||||||
|
.base_url(server.uri())
|
||||||
|
.token("cf_test_token")
|
||||||
|
.admin_token("admin_test_token")
|
||||||
|
.timeout(Duration::from_secs(5))
|
||||||
|
.build()
|
||||||
|
.expect("client builds")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn healthz_returns_payload() {
|
||||||
|
let server = MockServer::start().await;
|
||||||
|
Mock::given(method("GET"))
|
||||||
|
.and(path("/healthz"))
|
||||||
|
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||||
|
"ok": true,
|
||||||
|
"claude_present": true,
|
||||||
|
"claude_version": "claude 1.2.3"
|
||||||
|
})))
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let c = make_client(&server);
|
||||||
|
let h = c.healthz().await.unwrap();
|
||||||
|
assert!(h.ok);
|
||||||
|
assert!(h.claude_present);
|
||||||
|
assert_eq!(h.claude_version.as_deref(), Some("claude 1.2.3"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn run_success_with_json_result() {
|
||||||
|
let server = MockServer::start().await;
|
||||||
|
Mock::given(method("POST"))
|
||||||
|
.and(path("/run"))
|
||||||
|
.and(header("authorization", "Bearer cf_test_token"))
|
||||||
|
.and(body_json(json!({
|
||||||
|
"prompt": "give me json",
|
||||||
|
"model": "sonnet"
|
||||||
|
})))
|
||||||
|
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||||
|
"ok": true,
|
||||||
|
"result": {"hello": "world", "n": 42},
|
||||||
|
"duration_ms": 1234,
|
||||||
|
"stop_reason": "end_turn"
|
||||||
|
})))
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let c = make_client(&server);
|
||||||
|
let r = c
|
||||||
|
.run(RunRequest {
|
||||||
|
prompt: "give me json".into(),
|
||||||
|
model: Some("sonnet".into()),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(r.ok);
|
||||||
|
assert_eq!(r.duration_ms, 1234);
|
||||||
|
assert_eq!(r.stop_reason.as_deref(), Some("end_turn"));
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct Reply {
|
||||||
|
hello: String,
|
||||||
|
n: i32,
|
||||||
|
}
|
||||||
|
let parsed: Reply = r.as_json().unwrap();
|
||||||
|
assert_eq!(parsed.hello, "world");
|
||||||
|
assert_eq!(parsed.n, 42);
|
||||||
|
assert!(r.as_text().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn run_success_with_text_result() {
|
||||||
|
let server = MockServer::start().await;
|
||||||
|
Mock::given(method("POST"))
|
||||||
|
.and(path("/run"))
|
||||||
|
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||||
|
"ok": true,
|
||||||
|
"result": "plain string reply",
|
||||||
|
"duration_ms": 50,
|
||||||
|
"stop_reason": "end_turn"
|
||||||
|
})))
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let c = make_client(&server);
|
||||||
|
let r = c
|
||||||
|
.run(RunRequest {
|
||||||
|
prompt: "say hi".into(),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(r.as_text(), Some("plain string reply"));
|
||||||
|
let json_attempt: Result<serde_json::Map<String, serde_json::Value>, _> = r.as_json();
|
||||||
|
assert!(json_attempt.is_err(), "string should not deserialize as map");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn run_502_surfaces_api_error() {
|
||||||
|
let server = MockServer::start().await;
|
||||||
|
Mock::given(method("POST"))
|
||||||
|
.and(path("/run"))
|
||||||
|
.respond_with(ResponseTemplate::new(502).set_body_json(json!({
|
||||||
|
"ok": false,
|
||||||
|
"error": "claude exited 1",
|
||||||
|
"stderr": "boom",
|
||||||
|
"duration_ms": 10,
|
||||||
|
"stop_reason": null
|
||||||
|
})))
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let c = make_client(&server);
|
||||||
|
let err = c
|
||||||
|
.run(RunRequest {
|
||||||
|
prompt: "fail".into(),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect_err("should fail");
|
||||||
|
|
||||||
|
match err {
|
||||||
|
Error::Api { status, body } => {
|
||||||
|
assert_eq!(status, 502);
|
||||||
|
assert!(body.contains("claude exited 1"), "body was {body}");
|
||||||
|
// Demonstrate caller-side recovery via RunFailure.
|
||||||
|
let parsed: clawdforge::RunFailure =
|
||||||
|
serde_json::from_str(&body).expect("body is RunFailure JSON");
|
||||||
|
assert!(!parsed.ok);
|
||||||
|
assert_eq!(parsed.error.as_deref(), Some("claude exited 1"));
|
||||||
|
}
|
||||||
|
other => panic!("unexpected error variant: {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn run_with_files_passes_through() {
|
||||||
|
let server = MockServer::start().await;
|
||||||
|
Mock::given(method("POST"))
|
||||||
|
.and(path("/run"))
|
||||||
|
.and(body_json(json!({
|
||||||
|
"prompt": "use the file",
|
||||||
|
"files": ["ff_abc", "ff_def"]
|
||||||
|
})))
|
||||||
|
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||||
|
"ok": true,
|
||||||
|
"result": "saw 2 files",
|
||||||
|
"duration_ms": 100,
|
||||||
|
"stop_reason": "end_turn"
|
||||||
|
})))
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let c = make_client(&server);
|
||||||
|
let r = c
|
||||||
|
.run(RunRequest {
|
||||||
|
prompt: "use the file".into(),
|
||||||
|
files: Some(vec!["ff_abc".into(), "ff_def".into()]),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(r.as_text(), Some("saw 2 files"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn upload_file_streams_multipart() {
|
||||||
|
let server = MockServer::start().await;
|
||||||
|
|
||||||
|
// wiremock can't easily decode multipart, so we fingerprint the bytes:
|
||||||
|
// the file's contents (as a UTF-8 substring) and the form field names.
|
||||||
|
Mock::given(method("POST"))
|
||||||
|
.and(path("/files"))
|
||||||
|
.and(header("authorization", "Bearer cf_test_token"))
|
||||||
|
.and(body_string_contains("hello-from-rust-test"))
|
||||||
|
.and(body_string_contains("name=\"file\""))
|
||||||
|
.and(body_string_contains("name=\"ttl_secs\""))
|
||||||
|
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||||
|
"file_token": "ff_xyz",
|
||||||
|
"ttl_secs": 1800,
|
||||||
|
"size": 20
|
||||||
|
})))
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let mut tmp = tempfile::NamedTempFile::new().unwrap();
|
||||||
|
write!(tmp, "hello-from-rust-test").unwrap();
|
||||||
|
tmp.flush().unwrap();
|
||||||
|
|
||||||
|
let c = make_client(&server);
|
||||||
|
let ft = c.upload_file(tmp.path(), Some(1800)).await.unwrap();
|
||||||
|
assert_eq!(ft.file_token, "ff_xyz");
|
||||||
|
assert_eq!(ft.ttl_secs, 1800);
|
||||||
|
assert_eq!(ft.size, 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn admin_create_token() {
|
||||||
|
let server = MockServer::start().await;
|
||||||
|
Mock::given(method("POST"))
|
||||||
|
.and(path("/admin/tokens"))
|
||||||
|
.and(header("authorization", "Bearer admin_test_token"))
|
||||||
|
.and(body_json(json!({
|
||||||
|
"name": "cauldron",
|
||||||
|
"ip_cidrs": ["172.24.0.0/16"]
|
||||||
|
})))
|
||||||
|
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||||
|
"name": "cauldron",
|
||||||
|
"token": "cf_brandnew",
|
||||||
|
"ip_cidrs": ["172.24.0.0/16"]
|
||||||
|
})))
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let c = make_client(&server);
|
||||||
|
let t = c
|
||||||
|
.create_token(TokenCreateRequest {
|
||||||
|
name: "cauldron".into(),
|
||||||
|
ip_cidrs: vec!["172.24.0.0/16".into()],
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(t.name, "cauldron");
|
||||||
|
assert_eq!(t.token, "cf_brandnew");
|
||||||
|
assert_eq!(t.ip_cidrs, vec!["172.24.0.0/16".to_string()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn admin_list_tokens() {
|
||||||
|
let server = MockServer::start().await;
|
||||||
|
Mock::given(method("GET"))
|
||||||
|
.and(path("/admin/tokens"))
|
||||||
|
.and(header("authorization", "Bearer admin_test_token"))
|
||||||
|
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||||
|
"tokens": [
|
||||||
|
{"name": "cauldron", "ip_cidrs": ["172.24.0.0/16"], "created_at": 1700000000},
|
||||||
|
{"name": "petalparse", "ip_cidrs": [], "created_at": 1700000100, "last_seen": 1700001000}
|
||||||
|
]
|
||||||
|
})))
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let c = make_client(&server);
|
||||||
|
let list = c.list_tokens().await.unwrap();
|
||||||
|
assert_eq!(list.tokens.len(), 2);
|
||||||
|
assert_eq!(list.tokens[0].name, "cauldron");
|
||||||
|
// unknown server-added field captured by `extra`.
|
||||||
|
assert!(list.tokens[1].extra.contains_key("last_seen"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn admin_revoke_token() {
|
||||||
|
let server = MockServer::start().await;
|
||||||
|
Mock::given(method("DELETE"))
|
||||||
|
.and(path_regex(r"^/admin/tokens/.+"))
|
||||||
|
.and(header("authorization", "Bearer admin_test_token"))
|
||||||
|
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true})))
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let c = make_client(&server);
|
||||||
|
c.revoke_token("cauldron").await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn unauthorized_response_maps_to_auth_error() {
|
||||||
|
let server = MockServer::start().await;
|
||||||
|
Mock::given(method("POST"))
|
||||||
|
.and(path("/run"))
|
||||||
|
.respond_with(ResponseTemplate::new(401).set_body_string("missing token"))
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let c = make_client(&server);
|
||||||
|
let err = c
|
||||||
|
.run(RunRequest {
|
||||||
|
prompt: "nope".into(),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect_err("should fail");
|
||||||
|
matches!(err, Error::Auth(_)).then_some(()).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn missing_app_token_short_circuits_run() {
|
||||||
|
// Build a client without an app token but with admin set.
|
||||||
|
let server = MockServer::start().await;
|
||||||
|
let c = Client::builder()
|
||||||
|
.base_url(server.uri())
|
||||||
|
.admin_token("admin_only")
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let err = c
|
||||||
|
.run(RunRequest {
|
||||||
|
prompt: "x".into(),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect_err("should fail without app token");
|
||||||
|
match err {
|
||||||
|
Error::Auth(msg) => assert!(msg.contains("no app token")),
|
||||||
|
other => panic!("unexpected: {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn transport_timeout_surfaces_as_transport_error() {
|
||||||
|
let server = MockServer::start().await;
|
||||||
|
Mock::given(method("GET"))
|
||||||
|
.and(path("/healthz"))
|
||||||
|
.respond_with(
|
||||||
|
ResponseTemplate::new(200)
|
||||||
|
.set_delay(Duration::from_millis(2_000))
|
||||||
|
.set_body_json(json!({
|
||||||
|
"ok": true,
|
||||||
|
"claude_present": true,
|
||||||
|
"claude_version": "x"
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let c = Client::builder()
|
||||||
|
.base_url(server.uri())
|
||||||
|
.token("cf_x")
|
||||||
|
.timeout(Duration::from_millis(150))
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
let err = c.healthz().await.expect_err("should time out");
|
||||||
|
assert!(matches!(err, Error::Transport(_)), "got {err:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn builder_rejects_missing_base_url() {
|
||||||
|
let err = Client::builder().build().expect_err("should fail");
|
||||||
|
assert!(matches!(err, Error::Config(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn builder_rejects_bad_scheme() {
|
||||||
|
let err = Client::builder()
|
||||||
|
.base_url("ftp://nope")
|
||||||
|
.build()
|
||||||
|
.expect_err("should fail");
|
||||||
|
assert!(matches!(err, Error::Config(_)));
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue