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: Send> SdkResultExtReadingBody<ResponseValue<T>>
59    for Result<ResponseValue<T>, Error<types::AlienError>>
60{
61    fn into_sdk_error_reading_body(
62        self,
63    ) -> impl std::future::Future<Output = Result<ResponseValue<T>, AlienError<GenericError>>> + Send
64    {
65        async move {
66            match self {
67                Ok(response) => Ok(response),
68                Err(error) => Err(convert_typed_sdk_error_reading_body(error).await),
69            }
70        }
71    }
72}
73
74impl<T> SdkResultExt<ResponseValue<T>> for Result<ResponseValue<T>, Error<()>> {
75    fn into_sdk_error(self) -> Result<ResponseValue<T>, AlienError<GenericError>> {
76        self.map_err(convert_sdk_error)
77    }
78}
79
80/// Convert a progenitor SDK error to `AlienError`, reading the response body
81/// of error statuses so structured Alien errors returned by the manager
82/// (code, message, hint, retryable) survive the round-trip instead of
83/// collapsing into a generic "Unexpected response" error.
84///
85/// Async because reading the response body requires awaiting; falls back to
86/// [`convert_sdk_error`] semantics when the body is not an Alien error payload.
87pub async fn convert_sdk_error_reading_body(err: Error<()>) -> AlienError<GenericError> {
88    match err {
89        Error::UnexpectedResponse(response) => {
90            convert_unexpected_response_reading_body(response).await
91        }
92        other => convert_sdk_error(other),
93    }
94}
95
96async fn convert_typed_sdk_error_reading_body(
97    err: Error<types::AlienError>,
98) -> AlienError<GenericError> {
99    match err {
100        Error::ErrorResponse(response) => convert_typed_error_response(response),
101        Error::UnexpectedResponse(response) => {
102            convert_unexpected_response_reading_body(response).await
103        }
104        other => convert_sdk_error(other.into_untyped()),
105    }
106}
107
108fn convert_typed_error_response(
109    response: ResponseValue<types::AlienError>,
110) -> AlienError<GenericError> {
111    let status = response.status().as_u16();
112    let request_id = response
113        .headers()
114        .get("x-request-id")
115        .and_then(|value| value.to_str().ok())
116        .map(str::to_string);
117    let api_error = response.into_inner();
118    let message = String::from(api_error.message);
119    let source = api_error
120        .source
121        .and_then(|value| serde_json::from_value::<AlienError<GenericError>>(value).ok())
122        .map(Box::new);
123    let http_status_code = api_error
124        .http_status_code
125        .and_then(|value| u16::try_from(value).ok())
126        .filter(|value| (100..=599).contains(value))
127        .or(Some(status));
128
129    AlienError {
130        code: String::from(api_error.code),
131        message: message.clone(),
132        context: context_with_request_id(api_error.context, request_id.as_deref()),
133        hint: api_error.hint,
134        retryable: api_error.retryable,
135        internal: api_error.internal,
136        http_status_code,
137        source,
138        human_layer_presentation: HumanLayerPresentation::Normal,
139        error: Some(GenericError { message }),
140    }
141}
142
143async fn convert_unexpected_response_reading_body(
144    response: reqwest::Response,
145) -> AlienError<GenericError> {
146    let status = response.status().as_u16();
147    let canonical_reason = response
148        .status()
149        .canonical_reason()
150        .unwrap_or("Unknown")
151        .to_string();
152    let url = response.url().to_string();
153    let header_request_id = response
154        .headers()
155        .get("x-request-id")
156        .and_then(|value| value.to_str().ok())
157        .map(str::to_string);
158    let body = response.text().await.unwrap_or_default();
159
160    if let Ok(mut api_error) = serde_json::from_str::<AlienError<GenericError>>(&body) {
161        if api_error.http_status_code.is_none() {
162            api_error.http_status_code = Some(status);
163        }
164        let body_request_id = serde_json::from_str::<serde_json::Value>(&body)
165            .ok()
166            .and_then(|value| value.get("requestId")?.as_str().map(str::to_string));
167        api_error.context = context_with_request_id(
168            api_error.context,
169            header_request_id.as_deref().or(body_request_id.as_deref()),
170        );
171        return api_error;
172    }
173
174    AlienError {
175        code: "UNEXPECTED_RESPONSE".to_string(),
176        message: format!("Unexpected response: {} {}", status, canonical_reason),
177        context: Some(serde_json::json!({
178            "status": status,
179            "url": url,
180        })),
181        hint: None,
182        retryable: is_retryable_http_status(status),
183        internal: false,
184        http_status_code: Some(status),
185        source: None,
186        human_layer_presentation: HumanLayerPresentation::Normal,
187        error: Some(GenericError {
188            message: format!("Unexpected response status: {}", status),
189        }),
190    }
191}
192
193fn context_with_request_id(
194    context: Option<serde_json::Value>,
195    request_id: Option<&str>,
196) -> Option<serde_json::Value> {
197    let Some(request_id) = request_id else {
198        return context;
199    };
200
201    match context {
202        Some(serde_json::Value::Object(mut object)) => {
203            object
204                .entry("requestId")
205                .or_insert_with(|| serde_json::Value::String(request_id.to_string()));
206            Some(serde_json::Value::Object(object))
207        }
208        Some(value) => Some(serde_json::json!({
209            "requestId": request_id,
210            "details": value,
211        })),
212        None => Some(serde_json::json!({ "requestId": request_id })),
213    }
214}
215
216/// Returns whether an HTTP response represents a transient failure that a
217/// caller may safely retry.
218pub fn is_retryable_http_status(status: u16) -> bool {
219    matches!(status, 408 | 425 | 429) || (500..=599).contains(&status)
220}
221
222/// Convert a progenitor SDK error to AlienError, preserving all details.
223pub fn convert_sdk_error(err: Error<()>) -> AlienError<GenericError> {
224    match err {
225        Error::ErrorResponse(response) => {
226            let status = response.status().as_u16();
227            AlienError {
228                code: "UNEXPECTED_RESPONSE".to_string(),
229                message: format!(
230                    "Unexpected response: {} {}",
231                    status,
232                    response.status().canonical_reason().unwrap_or("Unknown")
233                ),
234                context: Some(serde_json::json!({
235                    "status": status,
236                })),
237                hint: None,
238                retryable: is_retryable_http_status(status),
239                internal: false,
240                http_status_code: Some(status),
241                source: None,
242                human_layer_presentation: HumanLayerPresentation::Normal,
243                error: Some(GenericError {
244                    message: format!("Unexpected response status: {}", status),
245                }),
246            }
247        }
248        Error::CommunicationError(reqwest_err) => {
249            let retryable =
250                reqwest_err.is_connect() || reqwest_err.is_timeout() || reqwest_err.is_request();
251            let message = reqwest_failure_message("HTTP request", &reqwest_err);
252
253            AlienError {
254                code: "COMMUNICATION_ERROR".to_string(),
255                message: message.clone(),
256                context: reqwest_failure_context(&reqwest_err),
257                hint: None,
258                retryable,
259                internal: false,
260                http_status_code: reqwest_err.status().map(|s| s.as_u16()),
261                source: build_reqwest_source(&reqwest_err),
262                human_layer_presentation: HumanLayerPresentation::Normal,
263                error: Some(GenericError { message }),
264            }
265        }
266        Error::InvalidRequest(msg) => AlienError {
267            code: "INVALID_REQUEST".to_string(),
268            message: format!("Invalid Request: {}", msg),
269            context: None,
270            hint: None,
271            retryable: false,
272            internal: false,
273            http_status_code: Some(400),
274            source: None,
275            human_layer_presentation: HumanLayerPresentation::Normal,
276            error: Some(GenericError {
277                message: format!("Invalid Request: {}", msg),
278            }),
279        },
280        Error::ResponseBodyError(reqwest_err) => {
281            let message = reqwest_failure_message("HTTP response body read", &reqwest_err);
282
283            AlienError {
284                code: "RESPONSE_BODY_ERROR".to_string(),
285                message: message.clone(),
286                context: reqwest_failure_context(&reqwest_err),
287                hint: None,
288                retryable: true,
289                internal: false,
290                http_status_code: reqwest_err.status().map(|s| s.as_u16()),
291                source: build_reqwest_source(&reqwest_err),
292                human_layer_presentation: HumanLayerPresentation::Normal,
293                error: Some(GenericError { message }),
294            }
295        }
296        Error::InvalidResponsePayload(bytes, json_err) => {
297            AlienError {
298                code: "INVALID_RESPONSE_PAYLOAD".to_string(),
299                message: format!("Failed to parse response: {}", json_err),
300                context: Some(serde_json::json!({
301                    "parseError": json_err.to_string(),
302                    // Manager responses can contain short-lived credentials.
303                    // Preserve enough metadata to diagnose truncation or
304                    // schema drift without copying response bytes into errors.
305                    "responseBodyLength": bytes.len(),
306                })),
307                hint: None,
308                retryable: false,
309                internal: false,
310                http_status_code: None,
311                source: Some(Box::new(AlienError::new(GenericError {
312                    message: json_err.to_string(),
313                }))),
314                human_layer_presentation: HumanLayerPresentation::Normal,
315                error: Some(GenericError {
316                    message: format!("Failed to parse response: {}", json_err),
317                }),
318            }
319        }
320        Error::InvalidUpgrade(reqwest_err) => {
321            let message = reqwest_failure_message("HTTP connection upgrade", &reqwest_err);
322
323            AlienError {
324                code: "INVALID_UPGRADE".to_string(),
325                message: message.clone(),
326                context: reqwest_failure_context(&reqwest_err),
327                hint: None,
328                retryable: false,
329                internal: false,
330                http_status_code: reqwest_err.status().map(|s| s.as_u16()),
331                source: build_reqwest_source(&reqwest_err),
332                human_layer_presentation: HumanLayerPresentation::Normal,
333                error: Some(GenericError { message }),
334            }
335        }
336        Error::UnexpectedResponse(response) => {
337            let status = response.status().as_u16();
338            AlienError {
339                code: "UNEXPECTED_RESPONSE".to_string(),
340                message: format!(
341                    "Unexpected response: {} {}",
342                    status,
343                    response.status().canonical_reason().unwrap_or("Unknown")
344                ),
345                context: Some(serde_json::json!({
346                    "status": status,
347                    "url": response.url().to_string(),
348                })),
349                hint: None,
350                retryable: is_retryable_http_status(status),
351                internal: false,
352                http_status_code: Some(status),
353                source: None,
354                human_layer_presentation: HumanLayerPresentation::Normal,
355                error: Some(GenericError {
356                    message: format!("Unexpected response status: {}", status),
357                }),
358            }
359        }
360        Error::Custom(msg) => AlienError {
361            code: "SDK_HOOK_ERROR".to_string(),
362            message: msg.clone(),
363            context: None,
364            hint: None,
365            retryable: false,
366            internal: false,
367            http_status_code: None,
368            source: None,
369            human_layer_presentation: HumanLayerPresentation::Normal,
370            error: Some(GenericError { message: msg }),
371        },
372    }
373}
374
375fn reqwest_failure_message(operation: &str, err: &reqwest::Error) -> String {
376    match err.url() {
377        Some(url) => format!("{operation} {} failed: {err}", url),
378        None => format!("{operation} failed: {err}"),
379    }
380}
381
382fn reqwest_failure_context(err: &reqwest::Error) -> Option<serde_json::Value> {
383    err.url().map(|url| {
384        serde_json::json!({
385            "url": url.to_string(),
386        })
387    })
388}
389
390fn build_reqwest_source(reqwest_err: &reqwest::Error) -> Option<Box<AlienError<GenericError>>> {
391    use std::error::Error as _;
392
393    reqwest_err.source().map(|source| {
394        Box::new(AlienError {
395            code: "GENERIC_ERROR".to_string(),
396            message: source.to_string(),
397            context: None,
398            hint: None,
399            retryable: false,
400            internal: false,
401            http_status_code: None,
402            source: None,
403            human_layer_presentation: HumanLayerPresentation::Transparent,
404            error: Some(GenericError {
405                message: source.to_string(),
406            }),
407        })
408    })
409}
410
411#[cfg(test)]
412mod tests {
413    use super::*;
414
415    fn unexpected_response<E>(status: u16, body: &str) -> Error<E> {
416        let response = http::Response::builder()
417            .status(status)
418            .body(body.to_string())
419            .expect("test response should build");
420        Error::UnexpectedResponse(reqwest::Response::from(response))
421    }
422
423    #[tokio::test]
424    async fn reading_body_preserves_structured_alien_errors() {
425        let body = serde_json::json!({
426            "code": "PUBLIC_SUBDOMAIN_REQUIRES_CUSTOM_DOMAIN",
427            "message": "Choosing a public subdomain requires a custom project domain",
428            "hint": "Configure a custom domain first",
429            "retryable": false,
430            "internal": false,
431            "httpStatusCode": 400,
432            "requestId": "req_body_123",
433        })
434        .to_string();
435
436        let error = convert_sdk_error_reading_body(unexpected_response(400, &body)).await;
437
438        assert_eq!(error.code, "PUBLIC_SUBDOMAIN_REQUIRES_CUSTOM_DOMAIN");
439        assert_eq!(
440            error.message,
441            "Choosing a public subdomain requires a custom project domain"
442        );
443        assert_eq!(error.http_status_code, Some(400));
444        assert_eq!(
445            error.hint.as_deref(),
446            Some("Configure a custom domain first")
447        );
448        assert_eq!(error.context.as_ref().unwrap()["requestId"], "req_body_123");
449        assert!(!error.retryable);
450        assert!(!error.internal);
451    }
452
453    #[tokio::test]
454    async fn typed_error_response_preserves_alien_error_and_request_id() {
455        let api_error = serde_json::from_value::<types::AlienError>(serde_json::json!({
456            "code": "FORBIDDEN",
457            "message": "Binding access denied",
458            "context": { "deploymentId": "dep_123" },
459            "hint": "Use the assigned manager",
460            "retryable": false,
461            "internal": false,
462            "httpStatusCode": 403,
463            "source": {
464                "code": "GENERIC_ERROR",
465                "message": "policy rejected request",
466                "retryable": false,
467                "internal": false
468            }
469        }))
470        .expect("typed API error should deserialize");
471        let mut headers = reqwest::header::HeaderMap::new();
472        headers.insert("x-request-id", "req_header_123".parse().unwrap());
473        let response = ResponseValue::new(api_error, reqwest::StatusCode::FORBIDDEN, headers);
474
475        let error = convert_typed_sdk_error_reading_body(Error::ErrorResponse(response)).await;
476
477        assert_eq!(error.code, "FORBIDDEN");
478        assert_eq!(error.message, "Binding access denied");
479        assert_eq!(error.http_status_code, Some(403));
480        assert_eq!(error.hint.as_deref(), Some("Use the assigned manager"));
481        assert_eq!(error.context.as_ref().unwrap()["deploymentId"], "dep_123");
482        assert_eq!(
483            error.context.as_ref().unwrap()["requestId"],
484            "req_header_123"
485        );
486        assert_eq!(error.source.as_ref().unwrap().code, "GENERIC_ERROR");
487        assert!(!error.retryable);
488        assert!(!error.internal);
489    }
490
491    #[tokio::test]
492    async fn reading_body_falls_back_to_generic_error_for_non_alien_payloads() {
493        let error =
494            convert_sdk_error_reading_body(unexpected_response(502, "<html>bad gateway</html>"))
495                .await;
496
497        assert_eq!(error.code, "UNEXPECTED_RESPONSE");
498        assert_eq!(error.message, "Unexpected response: 502 Bad Gateway");
499        assert_eq!(error.http_status_code, Some(502));
500        assert!(error.retryable);
501    }
502
503    #[tokio::test]
504    async fn reading_body_classifies_unstructured_rate_limits_as_retryable() {
505        let error = convert_sdk_error_reading_body(unexpected_response(429, "rate limited")).await;
506
507        assert_eq!(error.code, "UNEXPECTED_RESPONSE");
508        assert_eq!(error.http_status_code, Some(429));
509        assert!(error.retryable);
510    }
511
512    #[tokio::test]
513    async fn typed_endpoint_classifies_undocumented_rate_limits_as_retryable() {
514        let error = convert_typed_sdk_error_reading_body(unexpected_response::<types::AlienError>(
515            429,
516            "rate limited",
517        ))
518        .await;
519
520        assert_eq!(error.code, "UNEXPECTED_RESPONSE");
521        assert_eq!(error.http_status_code, Some(429));
522        assert!(error.retryable);
523    }
524
525    #[tokio::test]
526    async fn generated_typed_endpoint_preserves_malformed_server_error_status() {
527        use std::io::{Read, Write};
528
529        let listener = std::net::TcpListener::bind("127.0.0.1:0")
530            .expect("test server should bind to a loopback port");
531        let address = listener
532            .local_addr()
533            .expect("test server should have a local address");
534        let server = std::thread::spawn(move || {
535            let (mut stream, _) = listener
536                .accept()
537                .expect("test server should accept the SDK request");
538            let mut request = [0_u8; 4096];
539            stream
540                .read(&mut request)
541                .expect("test server should read the SDK request");
542            stream
543                .write_all(
544                    b"HTTP/1.1 500 Internal Server Error\r\ncontent-type: text/html\r\ncontent-length: 17\r\nconnection: close\r\n\r\nupstream exploded",
545                )
546                .expect("test server should return its malformed error body");
547        });
548
549        let sdk_error = Client::new(&format!("http://{address}"))
550            .resolve_binding()
551            .body(types::ResolveBindingRequest {
552                deployment_id: "dep_test".to_string(),
553                resource_id: "storage".to_string(),
554            })
555            .send()
556            .await
557            .expect_err("the generated SDK should return the server error");
558        server.join().expect("test server should stop cleanly");
559
560        assert!(matches!(
561            &sdk_error,
562            Error::UnexpectedResponse(response)
563                if response.status() == reqwest::StatusCode::INTERNAL_SERVER_ERROR
564        ));
565        let error = convert_typed_sdk_error_reading_body(sdk_error).await;
566
567        assert_eq!(error.code, "UNEXPECTED_RESPONSE");
568        assert_eq!(error.http_status_code, Some(500));
569        assert!(error.retryable);
570    }
571
572    #[test]
573    fn retryable_http_statuses_are_limited_to_transient_failures() {
574        for status in [408, 425, 429, 500, 502, 503, 504, 599] {
575            assert!(
576                is_retryable_http_status(status),
577                "status {status} should be retryable"
578            );
579        }
580        for status in [400, 401, 403, 404, 409, 422, 600] {
581            assert!(
582                !is_retryable_http_status(status),
583                "status {status} should not be retryable"
584            );
585        }
586    }
587
588    #[tokio::test]
589    async fn communication_error_includes_url_in_message_and_context() {
590        let reqwest_err = reqwest::Client::new()
591            .get("http://127.0.0.1:9/v1/initialize")
592            .send()
593            .await
594            .expect_err("localhost discard port should refuse the connection");
595
596        let error = super::convert_sdk_error(Error::CommunicationError(reqwest_err));
597
598        assert_eq!(error.code, "COMMUNICATION_ERROR");
599        assert!(error
600            .message
601            .starts_with("HTTP request http://127.0.0.1:9/v1/initialize failed:"));
602        assert_eq!(
603            error.context.as_ref().unwrap()["url"],
604            "http://127.0.0.1:9/v1/initialize"
605        );
606    }
607
608    #[test]
609    fn invalid_success_payload_never_copies_response_credentials_into_errors() {
610        let body = br#"{"accessToken":"sensitive-token","unexpected":true}"#.to_vec();
611        let parse_error = serde_json::from_slice::<serde_json::Value>(b"{")
612            .expect_err("fixture JSON should be invalid");
613        let error = super::convert_sdk_error(Error::InvalidResponsePayload(
614            body.clone().into(),
615            parse_error,
616        ));
617        let rendered = format!("{error:?}");
618
619        assert_eq!(error.code, "INVALID_RESPONSE_PAYLOAD");
620        assert_eq!(
621            error.context.as_ref().unwrap()["responseBodyLength"],
622            body.len()
623        );
624        assert!(!rendered.contains("sensitive-token"));
625        assert!(error
626            .context
627            .as_ref()
628            .unwrap()
629            .get("responseBody")
630            .is_none());
631    }
632}