Skip to main content

alien_manager_api/
lib.rs

1//! Alien Manager API
2//!
3//! Auto-generated from OpenAPI spec using Progenitor.
4//! Provides a type-safe Rust client for the alien-manager API.
5//!
6//! ## Usage
7//!
8//! ```ignore
9//! use alien_manager_api::Client;
10//!
11//! let client = Client::new("http://localhost:8080");
12//!
13//! // Create a deployment
14//! let response = client
15//!     .create_deployment()
16//!     .body(&CreateDeploymentRequest {
17//!         name: "my-deployment".into(),
18//!         platform: Platform::Aws,
19//!         ..Default::default()
20//!     })
21//!     .send()
22//!     .await?;
23//! ```
24
25include!(concat!(env!("OUT_DIR"), "/codegen.rs"));
26
27use alien_error::{AlienError, GenericError, HumanLayerPresentation};
28
29/// Extension trait for converting manager SDK results to `AlienError`.
30pub trait SdkResultExt<T> {
31    /// Convert SDK result to `AlienError` result, preserving API error details.
32    fn into_sdk_error(self) -> Result<T, AlienError<GenericError>>;
33}
34
35impl<T> SdkResultExt<ResponseValue<T>> for Result<ResponseValue<T>, Error<()>> {
36    fn into_sdk_error(self) -> Result<ResponseValue<T>, AlienError<GenericError>> {
37        self.map_err(convert_sdk_error)
38    }
39}
40
41/// Convert a progenitor SDK error to `AlienError`, reading the response body
42/// of error statuses so structured Alien errors returned by the manager
43/// (code, message, hint, retryable) survive the round-trip instead of
44/// collapsing into a generic "Unexpected response" error.
45///
46/// Async because reading the response body requires awaiting; falls back to
47/// [`convert_sdk_error`] semantics when the body is not an Alien error payload.
48pub async fn convert_sdk_error_reading_body(err: Error<()>) -> AlienError<GenericError> {
49    match err {
50        Error::UnexpectedResponse(response) => {
51            let status = response.status().as_u16();
52            let canonical_reason = response
53                .status()
54                .canonical_reason()
55                .unwrap_or("Unknown")
56                .to_string();
57            let url = response.url().to_string();
58            let body = response.text().await.unwrap_or_default();
59
60            if let Ok(mut api_error) = serde_json::from_str::<AlienError<GenericError>>(&body) {
61                if api_error.http_status_code.is_none() {
62                    api_error.http_status_code = Some(status);
63                }
64                return api_error;
65            }
66
67            AlienError {
68                code: "UNEXPECTED_RESPONSE".to_string(),
69                message: format!("Unexpected response: {} {}", status, canonical_reason),
70                context: Some(serde_json::json!({
71                    "status": status,
72                    "url": url,
73                })),
74                hint: None,
75                retryable: status >= 500,
76                internal: false,
77                http_status_code: Some(status),
78                source: None,
79                human_layer_presentation: HumanLayerPresentation::Normal,
80                error: Some(GenericError {
81                    message: format!("Unexpected response status: {}", status),
82                }),
83            }
84        }
85        other => convert_sdk_error(other),
86    }
87}
88
89/// Convert a progenitor SDK error to AlienError, preserving all details.
90pub fn convert_sdk_error(err: Error<()>) -> AlienError<GenericError> {
91    match err {
92        Error::ErrorResponse(response) => {
93            let status = response.status().as_u16();
94            AlienError {
95                code: "UNEXPECTED_RESPONSE".to_string(),
96                message: format!(
97                    "Unexpected response: {} {}",
98                    status,
99                    response.status().canonical_reason().unwrap_or("Unknown")
100                ),
101                context: Some(serde_json::json!({
102                    "status": status,
103                })),
104                hint: None,
105                retryable: status >= 500,
106                internal: false,
107                http_status_code: Some(status),
108                source: None,
109                human_layer_presentation: HumanLayerPresentation::Normal,
110                error: Some(GenericError {
111                    message: format!("Unexpected response status: {}", status),
112                }),
113            }
114        }
115        Error::CommunicationError(reqwest_err) => {
116            let retryable =
117                reqwest_err.is_connect() || reqwest_err.is_timeout() || reqwest_err.is_request();
118            let message = reqwest_failure_message("HTTP request", &reqwest_err);
119
120            AlienError {
121                code: "COMMUNICATION_ERROR".to_string(),
122                message: message.clone(),
123                context: reqwest_failure_context(&reqwest_err),
124                hint: None,
125                retryable,
126                internal: false,
127                http_status_code: reqwest_err.status().map(|s| s.as_u16()),
128                source: build_reqwest_source(&reqwest_err),
129                human_layer_presentation: HumanLayerPresentation::Normal,
130                error: Some(GenericError { message }),
131            }
132        }
133        Error::InvalidRequest(msg) => AlienError {
134            code: "INVALID_REQUEST".to_string(),
135            message: format!("Invalid Request: {}", msg),
136            context: None,
137            hint: None,
138            retryable: false,
139            internal: false,
140            http_status_code: Some(400),
141            source: None,
142            human_layer_presentation: HumanLayerPresentation::Normal,
143            error: Some(GenericError {
144                message: format!("Invalid Request: {}", msg),
145            }),
146        },
147        Error::ResponseBodyError(reqwest_err) => {
148            let message = reqwest_failure_message("HTTP response body read", &reqwest_err);
149
150            AlienError {
151                code: "RESPONSE_BODY_ERROR".to_string(),
152                message: message.clone(),
153                context: reqwest_failure_context(&reqwest_err),
154                hint: None,
155                retryable: true,
156                internal: false,
157                http_status_code: reqwest_err.status().map(|s| s.as_u16()),
158                source: build_reqwest_source(&reqwest_err),
159                human_layer_presentation: HumanLayerPresentation::Normal,
160                error: Some(GenericError { message }),
161            }
162        }
163        Error::InvalidResponsePayload(bytes, json_err) => {
164            let raw_body = String::from_utf8_lossy(&bytes);
165            let truncated = if raw_body.len() > 1000 {
166                format!(
167                    "{}...(truncated {} bytes)",
168                    &raw_body[..1000],
169                    raw_body.len() - 1000
170                )
171            } else {
172                raw_body.to_string()
173            };
174
175            AlienError {
176                code: "INVALID_RESPONSE_PAYLOAD".to_string(),
177                message: format!("Failed to parse response: {}", json_err),
178                context: Some(serde_json::json!({
179                    "parseError": json_err.to_string(),
180                    "responseBody": truncated,
181                })),
182                hint: None,
183                retryable: false,
184                internal: false,
185                http_status_code: None,
186                source: Some(Box::new(AlienError::new(GenericError {
187                    message: json_err.to_string(),
188                }))),
189                human_layer_presentation: HumanLayerPresentation::Normal,
190                error: Some(GenericError {
191                    message: format!("Failed to parse response: {}", json_err),
192                }),
193            }
194        }
195        Error::InvalidUpgrade(reqwest_err) => {
196            let message = reqwest_failure_message("HTTP connection upgrade", &reqwest_err);
197
198            AlienError {
199                code: "INVALID_UPGRADE".to_string(),
200                message: message.clone(),
201                context: reqwest_failure_context(&reqwest_err),
202                hint: None,
203                retryable: false,
204                internal: false,
205                http_status_code: reqwest_err.status().map(|s| s.as_u16()),
206                source: build_reqwest_source(&reqwest_err),
207                human_layer_presentation: HumanLayerPresentation::Normal,
208                error: Some(GenericError { message }),
209            }
210        }
211        Error::UnexpectedResponse(response) => {
212            let status = response.status().as_u16();
213            AlienError {
214                code: "UNEXPECTED_RESPONSE".to_string(),
215                message: format!(
216                    "Unexpected response: {} {}",
217                    status,
218                    response.status().canonical_reason().unwrap_or("Unknown")
219                ),
220                context: Some(serde_json::json!({
221                    "status": status,
222                    "url": response.url().to_string(),
223                })),
224                hint: None,
225                retryable: status >= 500,
226                internal: false,
227                http_status_code: Some(status),
228                source: None,
229                human_layer_presentation: HumanLayerPresentation::Normal,
230                error: Some(GenericError {
231                    message: format!("Unexpected response status: {}", status),
232                }),
233            }
234        }
235        Error::Custom(msg) => AlienError {
236            code: "SDK_HOOK_ERROR".to_string(),
237            message: msg.clone(),
238            context: None,
239            hint: None,
240            retryable: false,
241            internal: false,
242            http_status_code: None,
243            source: None,
244            human_layer_presentation: HumanLayerPresentation::Normal,
245            error: Some(GenericError { message: msg }),
246        },
247    }
248}
249
250fn reqwest_failure_message(operation: &str, err: &reqwest::Error) -> String {
251    match err.url() {
252        Some(url) => format!("{operation} {} failed: {err}", url),
253        None => format!("{operation} failed: {err}"),
254    }
255}
256
257fn reqwest_failure_context(err: &reqwest::Error) -> Option<serde_json::Value> {
258    err.url().map(|url| {
259        serde_json::json!({
260            "url": url.to_string(),
261        })
262    })
263}
264
265fn build_reqwest_source(reqwest_err: &reqwest::Error) -> Option<Box<AlienError<GenericError>>> {
266    use std::error::Error as _;
267
268    reqwest_err.source().map(|source| {
269        Box::new(AlienError {
270            code: "GENERIC_ERROR".to_string(),
271            message: source.to_string(),
272            context: None,
273            hint: None,
274            retryable: false,
275            internal: false,
276            http_status_code: None,
277            source: None,
278            human_layer_presentation: HumanLayerPresentation::Transparent,
279            error: Some(GenericError {
280                message: source.to_string(),
281            }),
282        })
283    })
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289
290    fn unexpected_response(status: u16, body: &str) -> Error<()> {
291        let response = http::Response::builder()
292            .status(status)
293            .body(body.to_string())
294            .expect("test response should build");
295        Error::UnexpectedResponse(reqwest::Response::from(response))
296    }
297
298    #[tokio::test]
299    async fn reading_body_preserves_structured_alien_errors() {
300        let body = serde_json::json!({
301            "code": "PUBLIC_SUBDOMAIN_REQUIRES_CUSTOM_DOMAIN",
302            "message": "Choosing a public subdomain requires a custom project domain",
303            "retryable": false,
304            "internal": false,
305            "httpStatusCode": 400,
306        })
307        .to_string();
308
309        let error = convert_sdk_error_reading_body(unexpected_response(400, &body)).await;
310
311        assert_eq!(error.code, "PUBLIC_SUBDOMAIN_REQUIRES_CUSTOM_DOMAIN");
312        assert_eq!(
313            error.message,
314            "Choosing a public subdomain requires a custom project domain"
315        );
316        assert_eq!(error.http_status_code, Some(400));
317        assert!(!error.retryable);
318        assert!(!error.internal);
319    }
320
321    #[tokio::test]
322    async fn reading_body_falls_back_to_generic_error_for_non_alien_payloads() {
323        let error =
324            convert_sdk_error_reading_body(unexpected_response(502, "<html>bad gateway</html>"))
325                .await;
326
327        assert_eq!(error.code, "UNEXPECTED_RESPONSE");
328        assert_eq!(error.message, "Unexpected response: 502 Bad Gateway");
329        assert_eq!(error.http_status_code, Some(502));
330        assert!(error.retryable);
331    }
332
333    #[tokio::test]
334    async fn communication_error_includes_url_in_message_and_context() {
335        let reqwest_err = reqwest::Client::new()
336            .get("http://127.0.0.1:9/v1/initialize")
337            .send()
338            .await
339            .expect_err("localhost discard port should refuse the connection");
340
341        let error = super::convert_sdk_error(Error::CommunicationError(reqwest_err));
342
343        assert_eq!(error.code, "COMMUNICATION_ERROR");
344        assert!(error
345            .message
346            .starts_with("HTTP request http://127.0.0.1:9/v1/initialize failed:"));
347        assert_eq!(
348            error.context.as_ref().unwrap()["url"],
349            "http://127.0.0.1:9/v1/initialize"
350        );
351    }
352}