use std::fmt; use std::io::{self, BufRead, BufReader, Write}; use std::path::PathBuf; use std::time::Duration; use interprocess::local_socket::traits::Stream as _; use serde::de::DeserializeOwned; use crate::api::schema::{ ErrorResponse, Method, PingParams, Request, ResponseResult, SuccessResponse, }; use crate::ipc::LocalStream; /// API connection target resolved by clients at the process edge. #[derive(Debug, Clone, PartialEq, Eq)] pub enum ConnectionTarget { LocalSession(Option), SocketPath(PathBuf), } impl ConnectionTarget { fn socket_path(&self) -> PathBuf { match self { Self::LocalSession(None) => crate::api::socket_path(), Self::LocalSession(Some(name)) => crate::session::api_socket_path_for(Some(name)), Self::SocketPath(path) => path.clone(), } } } /// Reusable client for Herdr's newline-delimited JSON API. #[derive(Debug, Clone)] pub struct ApiClient { target: ConnectionTarget, } impl ApiClient { pub fn local() -> Self { Self::for_target(ConnectionTarget::LocalSession(None)) } pub fn for_target(target: ConnectionTarget) -> Self { Self { target } } pub fn socket_path(&self) -> PathBuf { self.target.socket_path() } pub fn request(&self, request: Request) -> Result { let value = self.request_value(&request)?; parse_response_value(value) } pub fn request_value(&self, request: &Request) -> Result { let mut stream = self.connect()?; write_request(&mut stream, request)?; let mut reader = BufReader::new(stream); read_json_line(&mut reader) } pub fn request_value_with_timeout( &self, request: &Request, timeout: Duration, ) -> Result { let mut stream = self.connect()?; set_timeout_best_effort(&stream, TimeoutKind::Send, timeout)?; set_timeout_best_effort(&stream, TimeoutKind::Recv, timeout)?; write_request(&mut stream, request)?; let mut reader = BufReader::new(stream); read_json_line(&mut reader) } pub fn status(&self) -> Result { let response = self.request(Request { id: "api-client:status".into(), method: Method::Ping(PingParams::default()), })?; match response.result { ResponseResult::Pong { version, protocol, capabilities, } => Ok(crate::api::RuntimeStatus { version: Some(version), protocol: Some(protocol), capabilities, }), result => Err(ApiClientError::UnexpectedResult(format!("{result:?}"))), } } fn connect(&self) -> io::Result { crate::ipc::connect_local_stream(&self.socket_path()) } } enum TimeoutKind { Send, Recv, } fn set_timeout_best_effort( stream: &LocalStream, kind: TimeoutKind, timeout: Duration, ) -> io::Result<()> { let result = match kind { TimeoutKind::Send => stream.set_send_timeout(Some(timeout)), TimeoutKind::Recv => stream.set_recv_timeout(Some(timeout)), }; match result { Ok(()) => Ok(()), #[cfg(windows)] Err(err) if err.kind() == io::ErrorKind::Unsupported => Ok(()), Err(err) => Err(err), } } #[derive(Debug)] pub enum ApiClientError { Io(io::Error), Json(serde_json::Error), ErrorResponse(ErrorResponse), EmptyResponse, UnexpectedResult(String), } impl fmt::Display for ApiClientError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Io(err) => write!(f, "{err}"), Self::Json(err) => write!(f, "{err}"), Self::ErrorResponse(response) => write!(f, "{}", response.error.message), Self::EmptyResponse => write!(f, "empty api response"), Self::UnexpectedResult(result) => write!(f, "unexpected api result: {result}"), } } } impl std::error::Error for ApiClientError {} impl From for ApiClientError { fn from(err: io::Error) -> Self { Self::Io(err) } } impl From for ApiClientError { fn from(err: serde_json::Error) -> Self { Self::Json(err) } } fn write_request(stream: &mut LocalStream, request: &Request) -> Result<(), ApiClientError> { stream.write_all(serde_json::to_string(request)?.as_bytes())?; stream.write_all(b"\n")?; stream.flush()?; Ok(()) } fn read_json_line( reader: &mut BufReader, ) -> Result { let mut line = String::new(); let read = reader.read_line(&mut line)?; if read == 0 || line.trim().is_empty() { return Err(ApiClientError::EmptyResponse); } serde_json::from_str(&line).map_err(ApiClientError::Json) } #[derive(serde::Deserialize)] #[serde(untagged)] enum WireResponse { Success(Box), Error(ErrorResponse), } pub(crate) fn parse_response_value( value: serde_json::Value, ) -> Result { match serde_json::from_value(value)? { WireResponse::Success(response) => Ok(*response), WireResponse::Error(response) => Err(ApiClientError::ErrorResponse(response)), } } #[cfg(test)] mod tests { use super::*; #[test] fn local_session_target_resolves_named_session_socket() { let client = ApiClient::for_target(ConnectionTarget::LocalSession(Some("work".into()))); assert!(client.socket_path().ends_with("sessions/work/herdr.sock")); } #[test] fn socket_path_target_uses_explicit_path() { let path = PathBuf::from("/tmp/herdr-test.sock"); let client = ApiClient::for_target(ConnectionTarget::SocketPath(path.clone())); assert_eq!(client.socket_path(), path); } }