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
35/// Async counterpart for operations whose OpenAPI schema does not describe
36/// every error status. Progenitor leaves those response bodies unread, so an
37/// async adapter is required to preserve the structured Alien error payload.
38pub trait SdkResultExtReadingBody<T> {
39    fn into_sdk_error_reading_body(
40        self,
41    ) -> impl std::future::Future<Output = Result<T, AlienError<GenericError>>> + Send;
42}
43
44impl<T: Send> SdkResultExtReadingBody<ResponseValue<T>> for Result<ResponseValue<T>, Error<()>> {
45    fn into_sdk_error_reading_body(
46        self,
47    ) -> impl std::future::Future<Output = Result<ResponseValue<T>, AlienError<GenericError>>> + Send
48    {
49        async move {
50            match self {
51                Ok(response) => Ok(response),
52                Err(error) => Err(convert_sdk_error_reading_body(error).await),
53            }
54        }
55    }
56}
57
58impl<T> SdkResultExt<ResponseValue<T>> for Result<ResponseValue<T>, Error<()>> {
59    fn into_sdk_error(self) -> Result<ResponseValue<T>, AlienError<GenericError>> {
60        self.map_err(convert_sdk_error)
61    }
62}
63
64/// Convert a progenitor SDK error to `AlienError`, reading the response body
65/// of error statuses so structured Alien errors returned by the manager
66/// (code, message, hint, retryable) survive the round-trip instead of
67/// collapsing into a generic "Unexpected response" error.
68///
69/// Async because reading the response body requires awaiting; falls back to
70/// [`convert_sdk_error`] semantics when the body is not an Alien error payload.
71pub async fn convert_sdk_error_reading_body(err: Error<()>) -> AlienError<GenericError> {
72    match err {
73        Error::UnexpectedResponse(response) => {
74            let status = response.status().as_u16();
75            let canonical_reason = response
76                .status()
77                .canonical_reason()
78                .unwrap_or("Unknown")
79                .to_string();
80            let url = response.url().to_string();
81            let header_request_id = response
82                .headers()
83                .get("x-request-id")
84                .and_then(|value| value.to_str().ok())
85                .map(str::to_string);
86            let body = response.text().await.unwrap_or_default();
87
88            if let Ok(mut api_error) = serde_json::from_str::<AlienError<GenericError>>(&body) {
89                if api_error.http_status_code.is_none() {
90                    api_error.http_status_code = Some(status);
91                }
92                let body_request_id = serde_json::from_str::<serde_json::Value>(&body)
93                    .ok()
94                    .and_then(|value| value.get("requestId")?.as_str().map(str::to_string));
95                api_error.context = context_with_request_id(
96                    api_error.context,
97                    header_request_id.as_deref().or(body_request_id.as_deref()),
98                );
99                return api_error;
100            }
101
102            AlienError {
103                code: "UNEXPECTED_RESPONSE".to_string(),
104                message: format!("Unexpected response: {} {}", status, canonical_reason),
105                context: Some(serde_json::json!({
106                    "status": status,
107                    "url": url,
108                })),
109                hint: None,
110                retryable: status >= 500,
111                internal: false,
112                http_status_code: Some(status),
113                source: None,
114                human_layer_presentation: HumanLayerPresentation::Normal,
115                error: Some(GenericError {
116                    message: format!("Unexpected response status: {}", status),
117                }),
118            }
119        }
120        other => convert_sdk_error(other),
121    }
122}
123
124fn context_with_request_id(
125    context: Option<serde_json::Value>,
126    request_id: Option<&str>,
127) -> Option<serde_json::Value> {
128    let Some(request_id) = request_id else {
129        return context;
130    };
131
132    match context {
133        Some(serde_json::Value::Object(mut object)) => {
134            object
135                .entry("requestId")
136                .or_insert_with(|| serde_json::Value::String(request_id.to_string()));
137            Some(serde_json::Value::Object(object))
138        }
139        Some(value) => Some(serde_json::json!({
140            "requestId": request_id,
141            "details": value,
142        })),
143        None => Some(serde_json::json!({ "requestId": request_id })),
144    }
145}
146
147/// Convert a progenitor SDK error to AlienError, preserving all details.
148pub fn convert_sdk_error(err: Error<()>) -> AlienError<GenericError> {
149    match err {
150        Error::ErrorResponse(response) => {
151            let status = response.status().as_u16();
152            AlienError {
153                code: "UNEXPECTED_RESPONSE".to_string(),
154                message: format!(
155                    "Unexpected response: {} {}",
156                    status,
157                    response.status().canonical_reason().unwrap_or("Unknown")
158                ),
159                context: Some(serde_json::json!({
160                    "status": status,
161                })),
162                hint: None,
163                retryable: status >= 500,
164                internal: false,
165                http_status_code: Some(status),
166                source: None,
167                human_layer_presentation: HumanLayerPresentation::Normal,
168                error: Some(GenericError {
169                    message: format!("Unexpected response status: {}", status),
170                }),
171            }
172        }
173        Error::CommunicationError(reqwest_err) => {
174            let retryable =
175                reqwest_err.is_connect() || reqwest_err.is_timeout() || reqwest_err.is_request();
176            let message = reqwest_failure_message("HTTP request", &reqwest_err);
177
178            AlienError {
179                code: "COMMUNICATION_ERROR".to_string(),
180                message: message.clone(),
181                context: reqwest_failure_context(&reqwest_err),
182                hint: None,
183                retryable,
184                internal: false,
185                http_status_code: reqwest_err.status().map(|s| s.as_u16()),
186                source: build_reqwest_source(&reqwest_err),
187                human_layer_presentation: HumanLayerPresentation::Normal,
188                error: Some(GenericError { message }),
189            }
190        }
191        Error::InvalidRequest(msg) => AlienError {
192            code: "INVALID_REQUEST".to_string(),
193            message: format!("Invalid Request: {}", msg),
194            context: None,
195            hint: None,
196            retryable: false,
197            internal: false,
198            http_status_code: Some(400),
199            source: None,
200            human_layer_presentation: HumanLayerPresentation::Normal,
201            error: Some(GenericError {
202                message: format!("Invalid Request: {}", msg),
203            }),
204        },
205        Error::ResponseBodyError(reqwest_err) => {
206            let message = reqwest_failure_message("HTTP response body read", &reqwest_err);
207
208            AlienError {
209                code: "RESPONSE_BODY_ERROR".to_string(),
210                message: message.clone(),
211                context: reqwest_failure_context(&reqwest_err),
212                hint: None,
213                retryable: true,
214                internal: false,
215                http_status_code: reqwest_err.status().map(|s| s.as_u16()),
216                source: build_reqwest_source(&reqwest_err),
217                human_layer_presentation: HumanLayerPresentation::Normal,
218                error: Some(GenericError { message }),
219            }
220        }
221        Error::InvalidResponsePayload(bytes, json_err) => {
222            let raw_body = String::from_utf8_lossy(&bytes);
223            let truncated = if raw_body.len() > 1000 {
224                format!(
225                    "{}...(truncated {} bytes)",
226                    &raw_body[..1000],
227                    raw_body.len() - 1000
228                )
229            } else {
230                raw_body.to_string()
231            };
232
233            AlienError {
234                code: "INVALID_RESPONSE_PAYLOAD".to_string(),
235                message: format!("Failed to parse response: {}", json_err),
236                context: Some(serde_json::json!({
237                    "parseError": json_err.to_string(),
238                    "responseBody": truncated,
239                })),
240                hint: None,
241                retryable: false,
242                internal: false,
243                http_status_code: None,
244                source: Some(Box::new(AlienError::new(GenericError {
245                    message: json_err.to_string(),
246                }))),
247                human_layer_presentation: HumanLayerPresentation::Normal,
248                error: Some(GenericError {
249                    message: format!("Failed to parse response: {}", json_err),
250                }),
251            }
252        }
253        Error::InvalidUpgrade(reqwest_err) => {
254            let message = reqwest_failure_message("HTTP connection upgrade", &reqwest_err);
255
256            AlienError {
257                code: "INVALID_UPGRADE".to_string(),
258                message: message.clone(),
259                context: reqwest_failure_context(&reqwest_err),
260                hint: None,
261                retryable: false,
262                internal: false,
263                http_status_code: reqwest_err.status().map(|s| s.as_u16()),
264                source: build_reqwest_source(&reqwest_err),
265                human_layer_presentation: HumanLayerPresentation::Normal,
266                error: Some(GenericError { message }),
267            }
268        }
269        Error::UnexpectedResponse(response) => {
270            let status = response.status().as_u16();
271            AlienError {
272                code: "UNEXPECTED_RESPONSE".to_string(),
273                message: format!(
274                    "Unexpected response: {} {}",
275                    status,
276                    response.status().canonical_reason().unwrap_or("Unknown")
277                ),
278                context: Some(serde_json::json!({
279                    "status": status,
280                    "url": response.url().to_string(),
281                })),
282                hint: None,
283                retryable: status >= 500,
284                internal: false,
285                http_status_code: Some(status),
286                source: None,
287                human_layer_presentation: HumanLayerPresentation::Normal,
288                error: Some(GenericError {
289                    message: format!("Unexpected response status: {}", status),
290                }),
291            }
292        }
293        Error::Custom(msg) => AlienError {
294            code: "SDK_HOOK_ERROR".to_string(),
295            message: msg.clone(),
296            context: None,
297            hint: None,
298            retryable: false,
299            internal: false,
300            http_status_code: None,
301            source: None,
302            human_layer_presentation: HumanLayerPresentation::Normal,
303            error: Some(GenericError { message: msg }),
304        },
305    }
306}
307
308fn reqwest_failure_message(operation: &str, err: &reqwest::Error) -> String {
309    match err.url() {
310        Some(url) => format!("{operation} {} failed: {err}", url),
311        None => format!("{operation} failed: {err}"),
312    }
313}
314
315fn reqwest_failure_context(err: &reqwest::Error) -> Option<serde_json::Value> {
316    err.url().map(|url| {
317        serde_json::json!({
318            "url": url.to_string(),
319        })
320    })
321}
322
323fn build_reqwest_source(reqwest_err: &reqwest::Error) -> Option<Box<AlienError<GenericError>>> {
324    use std::error::Error as _;
325
326    reqwest_err.source().map(|source| {
327        Box::new(AlienError {
328            code: "GENERIC_ERROR".to_string(),
329            message: source.to_string(),
330            context: None,
331            hint: None,
332            retryable: false,
333            internal: false,
334            http_status_code: None,
335            source: None,
336            human_layer_presentation: HumanLayerPresentation::Transparent,
337            error: Some(GenericError {
338                message: source.to_string(),
339            }),
340        })
341    })
342}
343
344#[cfg(test)]
345mod tests {
346    use super::*;
347
348    fn unexpected_response(status: u16, body: &str) -> Error<()> {
349        let response = http::Response::builder()
350            .status(status)
351            .body(body.to_string())
352            .expect("test response should build");
353        Error::UnexpectedResponse(reqwest::Response::from(response))
354    }
355
356    #[tokio::test]
357    async fn reading_body_preserves_structured_alien_errors() {
358        let body = serde_json::json!({
359            "code": "PUBLIC_SUBDOMAIN_REQUIRES_CUSTOM_DOMAIN",
360            "message": "Choosing a public subdomain requires a custom project domain",
361            "hint": "Configure a custom domain first",
362            "retryable": false,
363            "internal": false,
364            "httpStatusCode": 400,
365            "requestId": "req_body_123",
366        })
367        .to_string();
368
369        let error = convert_sdk_error_reading_body(unexpected_response(400, &body)).await;
370
371        assert_eq!(error.code, "PUBLIC_SUBDOMAIN_REQUIRES_CUSTOM_DOMAIN");
372        assert_eq!(
373            error.message,
374            "Choosing a public subdomain requires a custom project domain"
375        );
376        assert_eq!(error.http_status_code, Some(400));
377        assert_eq!(
378            error.hint.as_deref(),
379            Some("Configure a custom domain first")
380        );
381        assert_eq!(error.context.as_ref().unwrap()["requestId"], "req_body_123");
382        assert!(!error.retryable);
383        assert!(!error.internal);
384    }
385
386    #[tokio::test]
387    async fn reading_body_falls_back_to_generic_error_for_non_alien_payloads() {
388        let error =
389            convert_sdk_error_reading_body(unexpected_response(502, "<html>bad gateway</html>"))
390                .await;
391
392        assert_eq!(error.code, "UNEXPECTED_RESPONSE");
393        assert_eq!(error.message, "Unexpected response: 502 Bad Gateway");
394        assert_eq!(error.http_status_code, Some(502));
395        assert!(error.retryable);
396    }
397
398    #[tokio::test]
399    async fn communication_error_includes_url_in_message_and_context() {
400        let reqwest_err = reqwest::Client::new()
401            .get("http://127.0.0.1:9/v1/initialize")
402            .send()
403            .await
404            .expect_err("localhost discard port should refuse the connection");
405
406        let error = super::convert_sdk_error(Error::CommunicationError(reqwest_err));
407
408        assert_eq!(error.code, "COMMUNICATION_ERROR");
409        assert!(error
410            .message
411            .starts_with("HTTP request http://127.0.0.1:9/v1/initialize failed:"));
412        assert_eq!(
413            error.context.as_ref().unwrap()["url"],
414            "http://127.0.0.1:9/v1/initialize"
415        );
416    }
417}