Skip to main content

adk_deploy/
client.rs

1use std::{fs, path::Path};
2
3use reqwest::multipart::{Form, Part};
4use reqwest::{Client, Method};
5use serde::de::DeserializeOwned;
6use url::Url;
7
8use crate::{
9    AgentDetail, AuthSessionResponse, DashboardResponse, DeployClientConfig, DeployError,
10    DeployResult, DeploymentHistoryResponse, DeploymentStatusResponse, LoginRequest, LoginResponse,
11    PushDeploymentRequest, PushDeploymentResponse, SecretListResponse, SecretSetRequest,
12};
13
14pub struct DeployClient {
15    http: Client,
16    config: DeployClientConfig,
17}
18
19impl DeployClient {
20    pub fn new(config: DeployClientConfig) -> Self {
21        Self { http: Client::new(), config }
22    }
23
24    pub fn config(&self) -> &DeployClientConfig {
25        &self.config
26    }
27
28    pub fn with_token(mut self, token: impl Into<String>) -> Self {
29        self.config.token = Some(token.into());
30        self
31    }
32
33    pub async fn login(&mut self, request: &LoginRequest) -> DeployResult<LoginResponse> {
34        self.login_with_options(request, true).await
35    }
36
37    pub async fn login_ephemeral(&mut self, request: &LoginRequest) -> DeployResult<LoginResponse> {
38        self.login_with_options(request, false).await
39    }
40
41    async fn login_with_options(
42        &mut self,
43        request: &LoginRequest,
44        persist: bool,
45    ) -> DeployResult<LoginResponse> {
46        let response: LoginResponse =
47            self.request(Method::POST, "/api/v1/auth/login", Some(request)).await?;
48        self.config.token = Some(response.token.clone());
49        self.config.workspace_id = Some(response.workspace_id.clone());
50        if persist {
51            self.config.save()?;
52        }
53        Ok(response)
54    }
55
56    pub async fn push_deployment(
57        &self,
58        request: &PushDeploymentRequest,
59    ) -> DeployResult<PushDeploymentResponse> {
60        let bundle_bytes = fs::read(&request.bundle_path)?;
61        let request_json = serde_json::to_string(request)
62            .map_err(|error| DeployError::Client { message: error.to_string() })?;
63        let file_name = Path::new(&request.bundle_path)
64            .file_name()
65            .and_then(|value| value.to_str())
66            .unwrap_or("bundle.tar.gz")
67            .to_string();
68        let form = Form::new()
69            .part(
70                "request",
71                Part::text(request_json)
72                    .mime_str("application/json")
73                    .map_err(|error| DeployError::Client { message: error.to_string() })?,
74            )
75            .part(
76                "bundle",
77                Part::bytes(bundle_bytes)
78                    .file_name(file_name)
79                    .mime_str("application/gzip")
80                    .map_err(|error| DeployError::Client { message: error.to_string() })?,
81            );
82        self.multipart_request(Method::POST, "/api/v1/deployments", form).await
83    }
84
85    pub async fn dashboard(&self) -> DeployResult<DashboardResponse> {
86        self.request::<(), DashboardResponse>(Method::GET, "/api/v1/dashboard", None).await
87    }
88
89    pub async fn auth_session(&self) -> DeployResult<AuthSessionResponse> {
90        self.request::<(), AuthSessionResponse>(Method::GET, "/api/v1/auth/session", None).await
91    }
92
93    pub async fn agent_detail(
94        &self,
95        agent_name: &str,
96        environment: &str,
97    ) -> DeployResult<AgentDetail> {
98        let path = build_url_with_query(
99            &format!("/api/v1/agents/{}", encode_path_segment(agent_name)),
100            &[("environment", environment)],
101        )?;
102        self.request::<(), AgentDetail>(Method::GET, &path, None).await
103    }
104
105    pub async fn status(
106        &self,
107        environment: &str,
108        agent_name: Option<&str>,
109    ) -> DeployResult<DeploymentStatusResponse> {
110        let mut query = vec![("environment", environment)];
111        if let Some(agent_name) = agent_name {
112            query.push(("agent", agent_name));
113        }
114        let path = build_url_with_query("/api/v1/deployments/status", &query)?;
115        self.request::<(), DeploymentStatusResponse>(Method::GET, &path, None).await
116    }
117
118    pub async fn history(
119        &self,
120        environment: &str,
121        agent_name: Option<&str>,
122    ) -> DeployResult<DeploymentHistoryResponse> {
123        let mut query = vec![("environment", environment)];
124        if let Some(agent_name) = agent_name {
125            query.push(("agent", agent_name));
126        }
127        let path = build_url_with_query("/api/v1/deployments/history", &query)?;
128        self.request::<(), DeploymentHistoryResponse>(Method::GET, &path, None).await
129    }
130
131    pub async fn rollback(&self, deployment_id: &str) -> DeployResult<DeploymentStatusResponse> {
132        let path = format!("/api/v1/deployments/{}/rollback", encode_path_segment(deployment_id));
133        self.request::<(), DeploymentStatusResponse>(Method::POST, &path, None).await
134    }
135
136    pub async fn promote(&self, deployment_id: &str) -> DeployResult<DeploymentStatusResponse> {
137        let path = format!("/api/v1/deployments/{}/promote", encode_path_segment(deployment_id));
138        self.request::<(), DeploymentStatusResponse>(Method::POST, &path, None).await
139    }
140
141    pub async fn set_secret(&self, request: &SecretSetRequest) -> DeployResult<()> {
142        let _: serde_json::Value =
143            self.request(Method::POST, "/api/v1/secrets", Some(request)).await?;
144        Ok(())
145    }
146
147    pub async fn list_secrets(&self, environment: &str) -> DeployResult<SecretListResponse> {
148        let path = build_url_with_query("/api/v1/secrets", &[("environment", environment)])?;
149        self.request::<(), SecretListResponse>(Method::GET, &path, None).await
150    }
151
152    pub async fn delete_secret(&self, environment: &str, key: &str) -> DeployResult<()> {
153        let path = build_url_with_query(
154            &format!("/api/v1/secrets/{}", encode_path_segment(key)),
155            &[("environment", environment)],
156        )?;
157        let _: serde_json::Value =
158            self.request::<(), serde_json::Value>(Method::DELETE, &path, None).await?;
159        Ok(())
160    }
161
162    async fn request<T, R>(&self, method: Method, path: &str, body: Option<&T>) -> DeployResult<R>
163    where
164        T: serde::Serialize,
165        R: DeserializeOwned,
166    {
167        let url = format!("{}{}", self.config.endpoint.trim_end_matches('/'), path);
168        let mut request = self.http.request(method, url);
169        if let Some(token) = &self.config.token {
170            request = request.bearer_auth(token);
171        }
172        if let Some(body) = body {
173            request = request.json(body);
174        }
175        let response = request.send().await?;
176        if !response.status().is_success() {
177            let status = response.status();
178            let body = response.text().await.unwrap_or_default();
179            let detail = body.trim();
180            return Err(DeployError::Client {
181                message: if detail.is_empty() {
182                    format!("request failed with status {status}")
183                } else {
184                    format!("request failed with status {status}: {detail}")
185                },
186            });
187        }
188        response
189            .json::<R>()
190            .await
191            .map_err(|error| DeployError::Client { message: error.to_string() })
192    }
193
194    async fn multipart_request<R>(&self, method: Method, path: &str, form: Form) -> DeployResult<R>
195    where
196        R: DeserializeOwned,
197    {
198        let url = format!("{}{}", self.config.endpoint.trim_end_matches('/'), path);
199        let mut request = self.http.request(method, url);
200        if let Some(token) = &self.config.token {
201            request = request.bearer_auth(token);
202        }
203        let response = request.multipart(form).send().await?;
204        if !response.status().is_success() {
205            let status = response.status();
206            let body = response.text().await.unwrap_or_default();
207            let detail = body.trim();
208            return Err(DeployError::Client {
209                message: if detail.is_empty() {
210                    format!("request failed with status {status}")
211                } else {
212                    format!("request failed with status {status}: {detail}")
213                },
214            });
215        }
216        response
217            .json::<R>()
218            .await
219            .map_err(|error| DeployError::Client { message: error.to_string() })
220    }
221}
222
223fn build_url_with_query(path: &str, query: &[(&str, &str)]) -> DeployResult<String> {
224    let mut url = Url::parse(&format!("https://local{path}"))
225        .map_err(|error| DeployError::Client { message: error.to_string() })?;
226    if !query.is_empty() {
227        let mut pairs = url.query_pairs_mut();
228        for (key, value) in query {
229            pairs.append_pair(key, value);
230        }
231    }
232    let mut value = url.path().to_string();
233    if let Some(query) = url.query() {
234        value.push('?');
235        value.push_str(query);
236    }
237    Ok(value)
238}
239
240fn encode_path_segment(value: &str) -> String {
241    url::form_urlencoded::byte_serialize(value.as_bytes()).collect()
242}
243
244#[cfg(test)]
245mod tests {
246    use super::{build_url_with_query, encode_path_segment};
247
248    #[test]
249    fn build_url_with_query_encodes_reserved_characters() {
250        let path = build_url_with_query(
251            "/api/v1/deployments/status",
252            &[("environment", "prod blue"), ("agent", "agent/one")],
253        )
254        .unwrap();
255
256        assert_eq!(path, "/api/v1/deployments/status?environment=prod+blue&agent=agent%2Fone");
257    }
258
259    #[test]
260    fn encode_path_segment_escapes_slashes_and_spaces() {
261        assert_eq!(encode_path_segment("prod/agent name"), "prod%2Fagent+name");
262    }
263}