use reqwest::StatusCode;
use thiserror::Error;
use super::wire::{AgentLogs, AgentStatus, ApiErrorBody, DeployRequest, ListQuery};
use super::{DeployedAgent, ListFilter};
use crate::registry::AgentId;
#[derive(Debug, Error)]
pub enum ControlPlaneClientError {
#[error("could not reach the control plane at {url}: {source}")]
Unreachable {
url: String,
#[source]
source: reqwest::Error,
},
#[error(
"the control plane rejected the token \
(pass --token, or set A2A_CONTROL_PLANE_TOKEN)"
)]
Unauthorized,
#[error("no agent '{0}' is deployed (see `a2a ps`)")]
NotFound(AgentId),
#[error("{0}")]
Unsupported(String),
#[error("control plane returned {status}: {message}")]
Api {
status: StatusCode,
message: String,
},
#[error("could not read the control plane's response: {0}")]
Malformed(#[source] reqwest::Error),
}
#[derive(Debug, Clone)]
pub struct ControlPlaneClient {
base_url: String,
token: Option<String>,
http: reqwest::Client,
}
impl ControlPlaneClient {
pub fn new(base_url: impl Into<String>) -> Self {
let base_url = base_url.into().trim_end_matches('/').to_string();
Self {
base_url,
token: None,
http: reqwest::Client::new(),
}
}
pub fn with_token(mut self, token: impl Into<String>) -> Self {
self.token = Some(token.into());
self
}
pub fn with_optional_token(self, token: Option<impl Into<String>>) -> Self {
match token {
Some(token) => self.with_token(token),
None => self,
}
}
pub fn base_url(&self) -> &str {
&self.base_url
}
pub async fn deploy(
&self,
config_toml: impl Into<String>,
) -> Result<DeployedAgent, ControlPlaneClientError> {
let request = self.http.post(self.url("/agents")).json(&DeployRequest {
config_toml: config_toml.into(),
});
self.send(request, None).await
}
pub async fn list(
&self,
filter: ListFilter,
) -> Result<Vec<DeployedAgent>, ControlPlaneClientError> {
let request = self.http.get(self.url("/agents")).query(&ListQuery {
all: filter == ListFilter::All,
});
self.send(request, None).await
}
pub async fn status(&self, id: &AgentId) -> Result<AgentStatus, ControlPlaneClientError> {
let request = self.http.get(self.url(&format!("/agents/{id}")));
self.send(request, Some(id)).await
}
pub async fn logs(
&self,
id: &AgentId,
tail: Option<usize>,
) -> Result<AgentLogs, ControlPlaneClientError> {
let mut request = self.http.get(self.url(&format!("/agents/{id}/logs")));
if let Some(tail) = tail {
request = request.query(&[("tail", tail)]);
}
self.send(request, Some(id)).await
}
pub async fn undeploy(&self, id: &AgentId) -> Result<(), ControlPlaneClientError> {
let request = self.http.delete(self.url(&format!("/agents/{id}")));
let response = self.execute(request).await?;
self.check(response, Some(id)).await.map(drop)
}
fn url(&self, path: &str) -> String {
format!("{}{path}", self.base_url)
}
async fn send<T: serde::de::DeserializeOwned>(
&self,
request: reqwest::RequestBuilder,
id: Option<&AgentId>,
) -> Result<T, ControlPlaneClientError> {
let response = self.execute(request).await?;
let response = self.check(response, id).await?;
response
.json()
.await
.map_err(ControlPlaneClientError::Malformed)
}
async fn execute(
&self,
request: reqwest::RequestBuilder,
) -> Result<reqwest::Response, ControlPlaneClientError> {
let request = match &self.token {
Some(token) => request.bearer_auth(token),
None => request,
};
request
.send()
.await
.map_err(|source| ControlPlaneClientError::Unreachable {
url: self.base_url.clone(),
source,
})
}
async fn check(
&self,
response: reqwest::Response,
id: Option<&AgentId>,
) -> Result<reqwest::Response, ControlPlaneClientError> {
let status = response.status();
if status.is_success() {
return Ok(response);
}
let message = response
.json::<ApiErrorBody>()
.await
.map(|body| body.error)
.unwrap_or_else(|_| status.to_string());
Err(match (status, id) {
(StatusCode::UNAUTHORIZED, _) => ControlPlaneClientError::Unauthorized,
(StatusCode::NOT_FOUND, Some(id)) => ControlPlaneClientError::NotFound(id.clone()),
(StatusCode::NOT_IMPLEMENTED, _) => ControlPlaneClientError::Unsupported(message),
_ => ControlPlaneClientError::Api { status, message },
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn urls_are_built_without_doubling_the_separator() {
let client = ControlPlaneClient::new("http://127.0.0.1:9090/");
assert_eq!(client.base_url(), "http://127.0.0.1:9090");
assert_eq!(client.url("/agents"), "http://127.0.0.1:9090/agents");
assert_eq!(
client.url("/agents/weather-agent/logs"),
"http://127.0.0.1:9090/agents/weather-agent/logs"
);
}
#[test]
fn an_absent_token_leaves_the_client_unauthenticated() {
let client = ControlPlaneClient::new("http://x").with_optional_token(None::<String>);
assert!(client.token.is_none());
let client = ControlPlaneClient::new("http://x").with_optional_token(Some("t"));
assert_eq!(client.token.as_deref(), Some("t"));
}
}