//! Thin HTTP client wrapper around `reqwest` that persists the //! `maestro_session` cookie issued by the registry's cookie middleware. use std::path::PathBuf; use std::sync::{Arc, Mutex}; use reqwest::header::{HeaderMap, HeaderValue, COOKIE, SET_COOKIE}; use reqwest::{Method, Response, StatusCode}; use serde::de::DeserializeOwned; use serde::Serialize; use crate::config::{CliConfig, ConfigError}; const COOKIE_NAME: &str = "maestro_session"; #[derive(Debug, thiserror::Error)] pub enum HttpError { #[error("http: {0}")] Reqwest(#[from] reqwest::Error), #[error("server returned {status}: {body}")] ServerError { status: StatusCode, body: String }, #[error("decode: {0}")] Json(#[from] serde_json::Error), #[error("config: {0}")] Config(#[from] ConfigError), #[error("invalid header: {0}")] InvalidHeader(#[from] reqwest::header::InvalidHeaderValue), } /// HTTP client bound to a single registry. Persists the auth cookie back to /// disk when the registry sets one (first-contact bootstrap from /// `crates/maestro-registry/src/auth.rs`). #[derive(Debug)] pub struct RegistryClient { base: String, inner: reqwest::Client, /// Cookie value (the JWT body), shared with the config-persister. cookie: Arc>>, /// Where to write the config back when the cookie changes. config_path: PathBuf, } impl RegistryClient { pub fn new(base: String, cookie: Option, config_path: PathBuf) -> Self { let inner = reqwest::Client::builder() .user_agent(concat!("maestro-cli/", env!("CARGO_PKG_VERSION"))) .build() .expect("build reqwest client"); Self { base: base.trim_end_matches('/').to_string(), inner, cookie: Arc::new(Mutex::new(cookie)), config_path, } } pub fn base(&self) -> &str { &self.base } /// Snapshot of the current cookie (mostly for tests). pub fn current_cookie(&self) -> Option { self.cookie.lock().unwrap().clone() } /// Send a request, attaching the persisted cookie. If the response sets a /// new cookie (first contact, or a rotation), persist it back to disk. async fn send( &self, method: Method, path: &str, body: Option, ) -> Result { let url = format!("{}{}", self.base, path); let mut headers = HeaderMap::new(); if let Some(c) = self.cookie.lock().unwrap().as_ref() { headers.insert( COOKIE, HeaderValue::from_str(&format!("{COOKIE_NAME}={c}"))?, ); } let mut req = self.inner.request(method, &url).headers(headers); if let Some(b) = body { req = req.json(&b); } let resp = req.send().await?; self.capture_cookie(resp.headers())?; Ok(resp) } fn capture_cookie(&self, headers: &HeaderMap) -> Result<(), HttpError> { let Some(raw) = headers.get(SET_COOKIE).and_then(|h| h.to_str().ok()) else { return Ok(()); }; let Some(val) = extract_cookie_value(raw, COOKIE_NAME) else { return Ok(()); }; let mut guard = self.cookie.lock().unwrap(); if guard.as_deref() == Some(val) { return Ok(()); } *guard = Some(val.to_string()); // Persist; on failure, log but don't abort the request. if let Err(e) = CliConfig::save_cookie(&self.config_path, val) { tracing::warn!(error = %e, path = %self.config_path.display(), "failed to persist cookie"); } Ok(()) } pub async fn get_json(&self, path: &str) -> Result { let resp = self.send(Method::GET, path, None).await?; ok_or_body(resp) .await? .json::() .await .map_err(Into::into) } pub async fn post_json( &self, path: &str, body: &B, ) -> Result { let v = serde_json::to_value(body)?; let resp = self.send(Method::POST, path, Some(v)).await?; ok_or_body(resp) .await? .json::() .await .map_err(Into::into) } pub async fn delete(&self, path: &str) -> Result<(), HttpError> { let resp = self.send(Method::DELETE, path, None).await?; ok_or_body(resp).await?; Ok(()) } } async fn ok_or_body(resp: Response) -> Result { let status = resp.status(); if status.is_success() { return Ok(resp); } let body = resp.text().await.unwrap_or_default(); Err(HttpError::ServerError { status, body }) } /// Extract `name=value` from a `Set-Cookie:` value. Ignores attributes. pub fn extract_cookie_value<'a>(raw: &'a str, name: &str) -> Option<&'a str> { let first = raw.split(';').next()?.trim(); let (k, v) = first.split_once('=')?; (k.trim() == name).then(|| v.trim()) } #[cfg(test)] mod tests { use super::*; #[test] fn extracts_named_cookie_value() { let raw = "maestro_session=abc.def.ghi; HttpOnly; SameSite=Strict; Path=/; Max-Age=2592000"; assert_eq!( extract_cookie_value(raw, "maestro_session"), Some("abc.def.ghi") ); } #[test] fn returns_none_for_other_cookie_name() { let raw = "other=1; HttpOnly"; assert_eq!(extract_cookie_value(raw, "maestro_session"), None); } }