From 062d405a9e043fb2cd02a4d8fd647620976de58e Mon Sep 17 00:00:00 2001 From: Kayos Date: Tue, 28 Apr 2026 22:35:00 -0700 Subject: [PATCH] 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. --- .gitignore | 4 + clients/rust/Cargo.toml | 29 +++ clients/rust/README.md | 179 ++++++++++++++++ clients/rust/examples/basic.rs | 78 +++++++ clients/rust/src/client.rs | 323 +++++++++++++++++++++++++++++ clients/rust/src/error.rs | 58 ++++++ clients/rust/src/lib.rs | 56 +++++ clients/rust/src/types.rs | 173 ++++++++++++++++ clients/rust/tests/client.rs | 362 +++++++++++++++++++++++++++++++++ 9 files changed, 1262 insertions(+) create mode 100644 clients/rust/Cargo.toml create mode 100644 clients/rust/README.md create mode 100644 clients/rust/examples/basic.rs create mode 100644 clients/rust/src/client.rs create mode 100644 clients/rust/src/error.rs create mode 100644 clients/rust/src/lib.rs create mode 100644 clients/rust/src/types.rs create mode 100644 clients/rust/tests/client.rs diff --git a/.gitignore b/.gitignore index e2c9e93..26906c7 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,7 @@ venv/ .vscode/ data/ runs/ + +# Rust +clients/rust/target/ +clients/rust/Cargo.lock diff --git a/clients/rust/Cargo.toml b/clients/rust/Cargo.toml new file mode 100644 index 0000000..e693461 --- /dev/null +++ b/clients/rust/Cargo.toml @@ -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" diff --git a/clients/rust/README.md b/clients/rust/README.md new file mode 100644 index 0000000..296ba07 --- /dev/null +++ b/clients/rust/README.md @@ -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::()` 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 +``` + +Or pin manually in `Cargo.toml`: + +```toml +[dependencies] +clawdforge = { git = "https://gitea.sulkta.com/Sulkta-Coop/clawdforge", rev = "" } +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> { + 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::(). + 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` 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. diff --git a/clients/rust/examples/basic.rs b/clients/rust/examples/basic.rs new file mode 100644 index 0000000..83107ff --- /dev/null +++ b/clients/rust/examples/basic.rs @@ -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> { + 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::() { + 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(()) +} diff --git a/clients/rust/src/client.rs b/clients/rust/src/client.rs new file mode 100644 index 0000000..2d93624 --- /dev/null +++ b/clients/rust/src/client.rs @@ -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, + /// Admin bootstrap token (used for `/admin/*`). Optional. + admin_token: Option, +} + +/// Builder for [`Client`]. +#[derive(Debug, Default)] +pub struct ClientBuilder { + base_url: Option, + app_token: Option, + admin_token: Option, + timeout: Option, + user_agent: Option, + 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 { + 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 { + 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, + ttl_secs: Option, + ) -> Result { + 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 { + 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 { + 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 { + 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) -> 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) -> 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) -> 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/`. + pub fn user_agent(mut self, ua: impl Into) -> 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 { + 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(resp: Response) -> Result { + let status = resp.status(); + if status.is_success() { + let bytes = resp.bytes().await?; + return Ok(serde_json::from_slice::(&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]) + } +} diff --git a/clients/rust/src/error.rs b/clients/rust/src/error.rs new file mode 100644 index 0000000..6cb0e66 --- /dev/null +++ b/clients/rust/src/error.rs @@ -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) -> Self { + Self::Api { + status, + body: body.into(), + } + } +} diff --git a/clients/rust/src/lib.rs b/clients/rust/src/lib.rs new file mode 100644 index 0000000..352550b --- /dev/null +++ b/clients/rust/src/lib.rs @@ -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> { +//! 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, +}; diff --git a/clients/rust/src/types.rs b/clients/rust/src/types.rs new file mode 100644 index 0000000..fde026c --- /dev/null +++ b/clients/rust/src/types.rs @@ -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, +} + +/// 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, + + /// System prompt appended via `claude -p --append-system-prompt`. + #[serde(skip_serializing_if = "Option::is_none")] + pub system: Option, + + /// 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>, + + /// Subprocess timeout in seconds. Server clamps to `5..=600`. + #[serde(skip_serializing_if = "Option::is_none")] + pub timeout_secs: Option, +} + +/// 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, +} + +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(&self) -> Result { + 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::(&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, + /// Last 4 KB of `claude` stderr, when available. + pub stderr: Option, + /// Wall-clock duration of the (failed) subprocess. + pub duration_ms: u64, + /// `claude` stop reason if the failure produced one. + pub stop_reason: Option, +} + +/// 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, +} + +/// 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, +} + +/// 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, + /// Unix epoch seconds (server-controlled field shape — extra fields are + /// captured by `extra`). + #[serde(default)] + pub created_at: Option, + + /// Catch-all for any future fields the server adds. + #[serde(flatten)] + pub extra: serde_json::Map, +} + +/// 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, +} diff --git a/clients/rust/tests/client.rs b/clients/rust/tests/client.rs new file mode 100644 index 0000000..637319d --- /dev/null +++ b/clients/rust/tests/client.rs @@ -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, _> = 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(_))); +}