diff options
Diffstat (limited to 'src/api/client.rs')
| -rw-r--r-- | src/api/client.rs | 245 |
1 files changed, 245 insertions, 0 deletions
diff --git a/src/api/client.rs b/src/api/client.rs new file mode 100644 index 00000000..7929f937 --- /dev/null +++ b/src/api/client.rs @@ -0,0 +1,245 @@ +use std::fmt; +use std::io::{self, BufRead, BufReader, Write}; +use std::os::unix::net::UnixStream; +use std::path::PathBuf; +use std::time::Duration; + +use serde::de::DeserializeOwned; + +use crate::api::schema::{ + ErrorResponse, EventsSubscribeParams, Method, PingParams, Request, ResponseResult, + SubscriptionEventEnvelope, SuccessResponse, +}; + +/// API connection target resolved by clients at the process edge. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ConnectionTarget { + LocalSession(Option<String>), + 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<SuccessResponse, ApiClientError> { + let value = self.request_value(&request)?; + parse_response_value(value) + } + + pub fn request_value(&self, request: &Request) -> Result<serde_json::Value, ApiClientError> { + 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<serde_json::Value, ApiClientError> { + let mut stream = self.connect()?; + stream.set_write_timeout(Some(timeout))?; + stream.set_read_timeout(Some(timeout))?; + write_request(&mut stream, request)?; + + let mut reader = BufReader::new(stream); + read_json_line(&mut reader) + } + + #[allow(dead_code)] // Kept as the typed subscription API; CLI wait paths use subscribe_value to preserve raw ack errors. + pub fn subscribe( + &self, + id: impl Into<String>, + params: EventsSubscribeParams, + read_timeout: Option<Duration>, + ) -> Result<(SuccessResponse, EventStream), ApiClientError> { + let request = Request { + id: id.into(), + method: Method::EventsSubscribe(params), + }; + let (ack, stream) = self.subscribe_value(&request, read_timeout)?; + Ok((parse_response_value(ack)?, stream)) + } + + pub fn subscribe_value( + &self, + request: &Request, + read_timeout: Option<Duration>, + ) -> Result<(serde_json::Value, EventStream), ApiClientError> { + let mut stream = self.connect()?; + write_request(&mut stream, request)?; + if let Some(timeout) = read_timeout { + stream.set_read_timeout(Some(timeout))?; + } + + let mut reader = BufReader::new(stream); + let ack = read_json_line(&mut reader)?; + Ok((ack, EventStream { reader })) + } + + pub fn status(&self) -> Result<crate::api::RuntimeStatus, ApiClientError> { + let response = self.request(Request { + id: "api-client:status".into(), + method: Method::Ping(PingParams::default()), + })?; + match response.result { + ResponseResult::Pong { version, protocol } => Ok(crate::api::RuntimeStatus { + version: Some(version), + protocol: Some(protocol), + }), + result => Err(ApiClientError::UnexpectedResult(format!("{result:?}"))), + } + } + + fn connect(&self) -> io::Result<UnixStream> { + UnixStream::connect(self.socket_path()) + } +} + +pub struct EventStream { + reader: BufReader<UnixStream>, +} + +impl EventStream { + pub fn next_value(&mut self) -> Result<Option<serde_json::Value>, ApiClientError> { + read_optional_json_line(&mut self.reader) + } + + pub fn next_event(&mut self) -> Result<Option<SubscriptionEventEnvelope>, ApiClientError> { + self.next_value()? + .map(serde_json::from_value) + .transpose() + .map_err(ApiClientError::Json) + } +} + +#[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<io::Error> for ApiClientError { + fn from(err: io::Error) -> Self { + Self::Io(err) + } +} + +impl From<serde_json::Error> for ApiClientError { + fn from(err: serde_json::Error) -> Self { + Self::Json(err) + } +} + +fn write_request(stream: &mut UnixStream, 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<T: DeserializeOwned>( + reader: &mut BufReader<UnixStream>, +) -> Result<T, ApiClientError> { + 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) +} + +fn read_optional_json_line<T: DeserializeOwned>( + reader: &mut BufReader<UnixStream>, +) -> Result<Option<T>, ApiClientError> { + let mut line = String::new(); + let read = reader.read_line(&mut line)?; + if read == 0 { + return Ok(None); + } + if line.trim().is_empty() { + return Err(ApiClientError::EmptyResponse); + } + serde_json::from_str(&line) + .map(Some) + .map_err(ApiClientError::Json) +} + +#[derive(serde::Deserialize)] +#[serde(untagged)] +enum WireResponse { + Success(Box<SuccessResponse>), + Error(ErrorResponse), +} + +pub(crate) fn parse_response_value( + value: serde_json::Value, +) -> Result<SuccessResponse, ApiClientError> { + 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); + } +} |
