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}