From 29099add9da56aa72301268736a6e1390557ce85 Mon Sep 17 00:00:00 2001 From: Valentin Haudiquet Date: Fri, 23 Oct 2026 21:47:14 +0800 Subject: [PATCH] feat(tools): the shared client plumbing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A client tool is three moves: read the connection from the environment, make one HTTP call carrying the bearer and the contract header, and print either the answer as one JSON line or the one error shape — then exit with the code that says which happened. Common is those three moves, so no client reimplements them and all clients fail identically: `Config::from_env` (`RHEMA_URL`, `RHEMA_TOKEN`), the `Client` call with the contract envelope, `print`/`fail` and the `ToolError` exit codes. The HTTP client is `ureq`, blocking, no TLS: the contract speaks to a loopback or otherwise trusted daemon, and a remote TLS endpoint is a stated non-goal in v1 — a thin client stays thin. --- tools/common/Cargo.toml | 20 +++++ tools/common/src/lib.rs | 162 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 182 insertions(+) create mode 100644 tools/common/Cargo.toml create mode 100644 tools/common/src/lib.rs diff --git a/tools/common/Cargo.toml b/tools/common/Cargo.toml new file mode 100644 index 0000000..19888de --- /dev/null +++ b/tools/common/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "rhema-tools-common" +description = "Shared plumbing for the rhema claw's thin client tools: connection config, the HTTP calls, the one error shape, the exit codes" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +authors.workspace = true + +[dependencies] +rhema-tools-contract = { path = "../contract" } +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true +ureq.workspace = true + +[lints.rust] +unsafe_code = "forbid" +missing_docs = "deny" diff --git a/tools/common/src/lib.rs b/tools/common/src/lib.rs new file mode 100644 index 0000000..6093c3c --- /dev/null +++ b/tools/common/src/lib.rs @@ -0,0 +1,162 @@ +//! Shared plumbing for the claw's thin client tools. A client is three +//! moves: read the connection from the environment, make one HTTP call +//! carrying the contract header, and print either the answer as one +//! JSON line or the one error shape — then exit with the code that +//! says which happened. This crate is those three moves, so no client +//! reimplements them and all clients fail identically. + +use std::io::Write; + +use rhema_tools_contract::{ApiError, CONTRACT_HEADER, CONTRACT_VERSION}; +use serde::Serialize; +use serde_json::Value; + +/// The default daemon address: the claw's default listen port, spoken +/// over loopback. `RHEMA_URL` overrides. +pub const DEFAULT_BASE_URL: &str = "http://127.0.0.1:7799"; + +/// Where the connection comes from: `RHEMA_URL` (the daemon's base +/// address, defaulting to [`DEFAULT_BASE_URL`]) and `RHEMA_TOKEN` (the +/// scoped agent token the daemon provisions). No token means the +/// daemon's surface is open; the client still sends it as a header +/// when present. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Config { + /// The daemon's base address, no trailing slash. + pub base_url: String, + /// The agent token, when the daemon issued one. + pub token: Option, +} + +impl Config { + /// Reads the environment: `RHEMA_URL`, `RHEMA_TOKEN`. + pub fn from_env() -> Self { + let base_url = std::env::var("RHEMA_URL") + .ok() + .filter(|url| !url.is_empty()) + .unwrap_or_else(|| DEFAULT_BASE_URL.to_owned()); + let token = std::env::var("RHEMA_TOKEN").ok().filter(|t| !t.is_empty()); + Self { base_url, token } + } +} + +/// How a client call failed. The variant is the exit code's meaning: +/// usage problems never reach here (clap owns them, exit 1), the +/// daemon being gone is 2, being refused is 3, and a daemon-side +/// rejection is 1 like any other failure — the printed error line +/// carries the nuance. +#[derive(Debug, thiserror::Error)] +pub enum ToolError { + /// The daemon could not be reached at all. + #[error("cannot reach the daemon at {0}: check RHEMA_URL and that the claw is running")] + Unreachable(String), + /// The daemon refused the credential or the contract version. + #[error("the daemon refused the call: {0}")] + Denied(String), + /// The daemon answered with an error. + #[error("{0}")] + Api(String), +} + +impl ToolError { + /// The process exit code for this failure: 2 unreachable, 3 + /// denied, 1 everything else. + pub fn exit_code(&self) -> i32 { + match self { + ToolError::Unreachable(_) => 2, + ToolError::Denied(_) => 3, + ToolError::Api(_) => 1, + } + } +} + +/// A configured client over the contract: one struct per binary, one +/// call per invocation. +pub struct Client { + config: Config, + agent: ureq::Agent, +} + +impl Client { + /// A client on the environment's connection. + pub fn from_env() -> Self { + Self::new(Config::from_env()) + } + + /// A client on an explicit connection — tests mostly. + pub fn new(config: Config) -> Self { + Self { + config, + agent: ureq::AgentBuilder::new().build(), + } + } + + /// Performs one contract call and answers the daemon's JSON. Every + /// failure mode prints through [`fail`]; nothing here writes to + /// stderr, the error shape is stdout's last line by contract. + pub fn call( + &self, + method: &str, + path: &str, + body: Option<&impl Serialize>, + ) -> Result { + let url = format!("{}{path}", self.config.base_url); + let mut request = self + .agent + .request(method, &url) + .set(CONTRACT_HEADER, &CONTRACT_VERSION.to_string()); + if let Some(token) = &self.config.token { + request = request.set("Authorization", &format!("Bearer {token}")); + } + let response = match body { + Some(body) => request.send_json(body), + None => request.call(), + }; + let response = match response { + Ok(response) => response, + Err(ureq::Error::Status(_, response)) => { + return Err(Self::error_response(response)); + } + Err(_) => { + return Err(ToolError::Unreachable(self.config.base_url.clone())); + } + }; + let status = response.status(); + if !(200..300).contains(&status) { + return Err(Self::error_response(response)); + } + serde_json::from_reader(response.into_reader()) + .map_err(|err| ToolError::Api(format!("the daemon's answer does not parse: {err}"))) + } + + /// Reads a non-2xx answer into the error it speaks: the one shape, + /// with the status class picking the variant. + fn error_response(response: ureq::Response) -> ToolError { + let status = response.status(); + let message = serde_json::from_reader::<_, ApiError>(response.into_reader()) + .map(|err| err.error) + .unwrap_or_else(|_| format!("HTTP {status}")); + match status { + 401 | 403 | 409 => ToolError::Denied(message), + _ => ToolError::Api(message), + } + } +} + +/// Prints one answer as one JSON line — the contract's stdout. +pub fn print(value: &Value) { + let line = serde_json::to_string(value).expect("a JSON value prints"); + let mut out = std::io::stdout().lock(); + let _ = writeln!(out, "{line}"); +} + +/// Prints the error shape and exits with the variant's code. The one +/// exit path every client shares. +pub fn fail(error: ToolError) -> ! { + let line = serde_json::to_string(&ApiError { + error: error.to_string(), + }) + .expect("an error shape prints"); + println!("{line}"); + std::process::exit(error.exit_code()); +}