Skip to main content

alien_platform_api/
lib.rs

1//! Alien Platform API
2//!
3//! Auto-generated from OpenAPI spec with custom error conversion support.
4//!
5//! ## Error Handling
6//!
7//! For SDK API calls, use `SdkResultExt::into_sdk_error()` instead of
8//! `.into_alien_error()` to preserve structured API error information:
9//!
10//! ```ignore
11//! use alien_platform_api::SdkResultExt;
12//!
13//! // ✅ Good: preserves API error code, message, retryable flag
14//! client.some_method().send().await.into_sdk_error().context(...)?
15//!
16//! // ❌ Bad: loses structured error information
17//! client.some_method().send().await.into_alien_error().context(...)?
18//! ```
19//!
20//! For non-SDK errors (serde, std, etc.), continue using `.into_alien_error()`.
21
22include!(concat!(env!("OUT_DIR"), "/codegen.rs"));
23
24use alien_error::{AlienError, GenericError, HumanLayerPresentation};
25
26/// Extension trait for converting SDK API results to `AlienError`.
27///
28/// This properly extracts error information from progenitor's error types,
29/// preserving API error details that would be lost with `.into_alien_error()`.
30///
31/// ## When to use
32///
33/// Use `into_sdk_error()` for SDK API calls:
34/// ```ignore
35/// client.sync_acquire().send().await.into_sdk_error().context(...)?
36/// ```
37///
38/// Continue using `into_alien_error()` for non-SDK errors (serde, std, etc.):
39/// ```ignore
40/// serde_json::to_value(&data).into_alien_error().context(...)?
41/// ```
42///
43/// ## What it preserves
44///
45/// When the API returns an error response, `into_sdk_error()` preserves:
46/// - `code`: The API error code (e.g., "DEPLOYMENT_NOT_FOUND")
47/// - `message`: The error message
48/// - `retryable`: Whether the operation can be retried
49/// - `context`: Additional error context as JSON
50/// - `source`: Nested error chain
51/// - HTTP status code
52pub trait SdkResultExt<T> {
53    /// Convert SDK result to `AlienError` result, preserving API error details.
54    fn into_sdk_error(self) -> Result<T, AlienError<GenericError>>;
55}
56
57impl<T> SdkResultExt<ResponseValue<T>> for Result<ResponseValue<T>, Error<types::ApiError>> {
58    fn into_sdk_error(self) -> Result<ResponseValue<T>, AlienError<GenericError>> {
59        self.map_err(convert_sdk_error)
60    }
61}
62
63/// Convert a progenitor SDK error to AlienError, preserving all details.
64pub fn convert_sdk_error(err: Error<types::ApiError>) -> AlienError<GenericError> {
65    match err {
66        // API returned a documented error response with ApiError body
67        // This is the main case where we gain value over .into_alien_error()
68        Error::ErrorResponse(response) => {
69            let status = response.status().as_u16();
70            let api_error = response.into_inner();
71            let context =
72                context_with_request_id(api_error.context, api_error.request_id.as_deref());
73
74            AlienError {
75                code: api_error.code.to_string(),
76                message: api_error.message.to_string(),
77                context,
78                hint: api_error.hint,
79                retryable: api_error.retryable,
80                internal: false, // API errors sent to clients are external by nature
81                http_status_code: Some(status),
82                source: api_error.source.and_then(parse_source_error),
83                human_layer_presentation: HumanLayerPresentation::Normal,
84                error: Some(GenericError {
85                    message: api_error.message.to_string(),
86                }),
87            }
88        }
89
90        // Network/connection error - typically retryable
91        Error::CommunicationError(reqwest_err) => {
92            let retryable =
93                reqwest_err.is_connect() || reqwest_err.is_timeout() || reqwest_err.is_request();
94            let message = reqwest_failure_message("HTTP request", &reqwest_err);
95
96            AlienError {
97                code: "COMMUNICATION_ERROR".to_string(),
98                message: message.clone(),
99                context: reqwest_failure_context(&reqwest_err),
100                hint: None,
101                retryable,
102                internal: false,
103                http_status_code: reqwest_err.status().map(|s| s.as_u16()),
104                source: build_reqwest_source(&reqwest_err),
105                human_layer_presentation: HumanLayerPresentation::Normal,
106                error: Some(GenericError { message }),
107            }
108        }
109
110        // Request validation failed (client-side, before sending)
111        Error::InvalidRequest(msg) => AlienError {
112            code: "INVALID_REQUEST".to_string(),
113            message: format!("Invalid Request: {}", msg),
114            context: None,
115            hint: None,
116            retryable: false,
117            internal: false,
118            http_status_code: Some(400),
119            source: None,
120            human_layer_presentation: HumanLayerPresentation::Normal,
121            error: Some(GenericError {
122                message: format!("Invalid Request: {}", msg),
123            }),
124        },
125
126        // Failed to read response body
127        Error::ResponseBodyError(reqwest_err) => {
128            let message = reqwest_failure_message("HTTP response body read", &reqwest_err);
129
130            AlienError {
131                code: "RESPONSE_BODY_ERROR".to_string(),
132                message: message.clone(),
133                context: reqwest_failure_context(&reqwest_err),
134                hint: None,
135                retryable: true, // Transient network issue
136                internal: false,
137                http_status_code: reqwest_err.status().map(|s| s.as_u16()),
138                source: build_reqwest_source(&reqwest_err),
139                human_layer_presentation: HumanLayerPresentation::Normal,
140                error: Some(GenericError { message }),
141            }
142        }
143
144        // Response body couldn't be parsed as expected type
145        // Include raw body in context for debugging
146        Error::InvalidResponsePayload(bytes, json_err) => {
147            let raw_body = String::from_utf8_lossy(&bytes);
148            let truncated = if raw_body.len() > 1000 {
149                format!(
150                    "{}...(truncated {} bytes)",
151                    &raw_body[..1000],
152                    raw_body.len() - 1000
153                )
154            } else {
155                raw_body.to_string()
156            };
157
158            AlienError {
159                code: "INVALID_RESPONSE_PAYLOAD".to_string(),
160                message: format!("Failed to parse response: {}", json_err),
161                context: Some(serde_json::json!({
162                    "parseError": json_err.to_string(),
163                    "responseBody": truncated,
164                })),
165                hint: None,
166                retryable: false,
167                internal: false,
168                http_status_code: None,
169                source: Some(Box::new(AlienError::new(GenericError {
170                    message: json_err.to_string(),
171                }))),
172                human_layer_presentation: HumanLayerPresentation::Normal,
173                error: Some(GenericError {
174                    message: format!("Failed to parse response: {}", json_err),
175                }),
176            }
177        }
178
179        // WebSocket upgrade error
180        Error::InvalidUpgrade(reqwest_err) => {
181            let message = reqwest_failure_message("HTTP connection upgrade", &reqwest_err);
182
183            AlienError {
184                code: "INVALID_UPGRADE".to_string(),
185                message: message.clone(),
186                context: reqwest_failure_context(&reqwest_err),
187                hint: None,
188                retryable: false,
189                internal: false,
190                http_status_code: reqwest_err.status().map(|s| s.as_u16()),
191                source: build_reqwest_source(&reqwest_err),
192                human_layer_presentation: HumanLayerPresentation::Normal,
193                error: Some(GenericError { message }),
194            }
195        }
196
197        // Response with status code not in OpenAPI spec
198        Error::UnexpectedResponse(response) => {
199            let status = response.status().as_u16();
200            AlienError {
201                code: "UNEXPECTED_RESPONSE".to_string(),
202                message: format!(
203                    "Unexpected response: {} {}",
204                    status,
205                    response.status().canonical_reason().unwrap_or("Unknown")
206                ),
207                context: Some(serde_json::json!({
208                    "status": status,
209                    "url": response.url().to_string(),
210                })),
211                hint: None,
212                retryable: status >= 500, // Server errors are typically retryable
213                internal: false,
214                http_status_code: Some(status),
215                source: None,
216                human_layer_presentation: HumanLayerPresentation::Normal,
217                error: Some(GenericError {
218                    message: format!("Unexpected response status: {}", status),
219                }),
220            }
221        }
222
223        // Custom hook error
224        Error::Custom(msg) => AlienError {
225            code: "SDK_HOOK_ERROR".to_string(),
226            message: msg.clone(),
227            context: None,
228            hint: None,
229            retryable: false,
230            internal: false,
231            http_status_code: None,
232            source: None,
233            human_layer_presentation: HumanLayerPresentation::Normal,
234            error: Some(GenericError { message: msg }),
235        },
236    }
237}
238
239fn context_with_request_id(
240    context: Option<serde_json::Value>,
241    request_id: Option<&str>,
242) -> Option<serde_json::Value> {
243    let Some(request_id) = request_id else {
244        return context;
245    };
246
247    match context {
248        Some(serde_json::Value::Object(mut object)) => {
249            object
250                .entry("requestId")
251                .or_insert_with(|| serde_json::Value::String(request_id.to_string()));
252            Some(serde_json::Value::Object(object))
253        }
254        Some(value) => Some(serde_json::json!({
255            "requestId": request_id,
256            "context": value,
257        })),
258        None => Some(serde_json::json!({ "requestId": request_id })),
259    }
260}
261
262fn reqwest_failure_message(operation: &str, err: &reqwest::Error) -> String {
263    match err.url() {
264        Some(url) => format!("{operation} {} failed: {err}", url),
265        None => format!("{operation} failed: {err}"),
266    }
267}
268
269fn reqwest_failure_context(err: &reqwest::Error) -> Option<serde_json::Value> {
270    err.url().map(|url| {
271        serde_json::json!({
272            "url": url.to_string(),
273        })
274    })
275}
276
277/// Build a source error chain from a reqwest error
278fn build_reqwest_source(err: &reqwest::Error) -> Option<Box<AlienError<GenericError>>> {
279    // Walk the error chain and build AlienError source chain
280    use std::error::Error;
281
282    let mut sources = Vec::new();
283    let mut current: Option<&(dyn Error + 'static)> = err.source();
284
285    while let Some(src) = current {
286        sources.push(src.to_string());
287        current = src.source();
288    }
289
290    if sources.is_empty() {
291        return None;
292    }
293
294    // Build chain from innermost to outermost
295    let mut result: Option<Box<AlienError<GenericError>>> = None;
296    for msg in sources.into_iter().rev() {
297        let error = AlienError {
298            code: "GENERIC_ERROR".to_string(),
299            message: msg.clone(),
300            context: None,
301            hint: None,
302            retryable: false,
303            internal: false,
304            http_status_code: None,
305            source: result,
306            human_layer_presentation: HumanLayerPresentation::Normal,
307            error: Some(GenericError { message: msg }),
308        };
309        result = Some(Box::new(error));
310    }
311
312    result
313}
314
315/// Try to parse a JSON value as a nested AlienError source chain.
316fn parse_source_error(value: serde_json::Value) -> Option<Box<AlienError<GenericError>>> {
317    let obj = value.as_object()?;
318
319    let code = obj
320        .get("code")
321        .and_then(|v| v.as_str())
322        .unwrap_or("NESTED_ERROR")
323        .to_string();
324
325    let message = obj
326        .get("message")
327        .and_then(|v| v.as_str())
328        .unwrap_or("Nested error")
329        .to_string();
330
331    let context = obj.get("context").cloned();
332    let retryable = obj
333        .get("retryable")
334        .and_then(|v| v.as_bool())
335        .unwrap_or(false);
336
337    // Recursively parse nested source
338    let nested_source = obj.get("source").cloned().and_then(parse_source_error);
339
340    Some(Box::new(AlienError {
341        code,
342        message: message.clone(),
343        context,
344        hint: None,
345        retryable,
346        internal: false,
347        http_status_code: None,
348        source: nested_source,
349        human_layer_presentation: HumanLayerPresentation::Normal,
350        error: Some(GenericError { message }),
351    }))
352}
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357
358    #[test]
359    fn reconcile_state_preserves_runtime_update_metadata() {
360        let state = serde_json::json!({
361            "status": "updating",
362            "platform": "machines",
363            "protocolVersion": 1,
364            "runtimeMetadata": {
365                "pendingPreparedStack": {
366                    "id": "updated-stack",
367                    "resources": {}
368                },
369                "setupUpdateAuthorization": {
370                    "nonce": "nonce-1",
371                    "baselineFrozenDigest": "before",
372                    "targetFrozenDigest": "after",
373                    "releaseId": "rel_123",
374                    "setupTarget": "aws",
375                    "setupFingerprint": "fingerprint",
376                    "setupFingerprintVersion": 1
377                }
378            }
379        });
380
381        let sdk_state: types::SyncReconcileRequestState =
382            serde_json::from_value(state).expect("deployment state should deserialize");
383        let serialized =
384            serde_json::to_value(sdk_state).expect("deployment state should serialize");
385
386        assert_eq!(
387            serialized["runtimeMetadata"]["pendingPreparedStack"]["id"],
388            "updated-stack"
389        );
390        assert_eq!(
391            serialized["runtimeMetadata"]["setupUpdateAuthorization"]["nonce"],
392            "nonce-1"
393        );
394    }
395
396    #[test]
397    fn test_api_error_code_deref() {
398        // Verify generated types work as expected
399        let code = types::ApiErrorCode::try_from("TEST_ERROR").unwrap();
400        assert_eq!(code.as_str(), "TEST_ERROR");
401    }
402
403    #[test]
404    fn context_with_request_id_adds_request_id_to_empty_context() {
405        let context = super::context_with_request_id(None, Some("req_123")).unwrap();
406
407        assert_eq!(context["requestId"], "req_123");
408    }
409
410    #[test]
411    fn context_with_request_id_preserves_existing_context() {
412        let context = super::context_with_request_id(
413            Some(serde_json::json!({ "workspace": "demo" })),
414            Some("req_123"),
415        )
416        .unwrap();
417
418        assert_eq!(context["workspace"], "demo");
419        assert_eq!(context["requestId"], "req_123");
420    }
421
422    #[tokio::test]
423    async fn documented_api_error_preserves_hint_and_request_id() {
424        let response = http::Response::builder()
425            .status(409)
426            .body(
427                serde_json::json!({
428                    "code": "DEPLOYMENT_OPERATION_NOT_ALLOWED",
429                    "message": "The deployment cannot be redeployed from this state",
430                    "hint": "Retry the desired release or pin a different release",
431                    "requestId": "req_recovery_123",
432                    "retryable": false,
433                    "internal": false
434                })
435                .to_string(),
436            )
437            .expect("test response should build");
438        let response = reqwest::Response::from(response);
439        let response = ResponseValue::from_response::<types::ApiError>(response)
440            .await
441            .expect("API error body should deserialize");
442
443        let error = convert_sdk_error(Error::ErrorResponse(response));
444
445        assert_eq!(
446            error.hint.as_deref(),
447            Some("Retry the desired release or pin a different release")
448        );
449        assert_eq!(
450            error.context.as_ref().unwrap()["requestId"],
451            "req_recovery_123"
452        );
453    }
454
455    #[tokio::test]
456    async fn communication_error_includes_url_in_message_and_context() {
457        let reqwest_err = reqwest::Client::new()
458            .get("http://127.0.0.1:9/v1/whoami")
459            .send()
460            .await
461            .expect_err("localhost discard port should refuse the connection");
462
463        let error = super::convert_sdk_error(Error::CommunicationError(reqwest_err));
464
465        assert_eq!(error.code, "COMMUNICATION_ERROR");
466        assert!(error
467            .message
468            .starts_with("HTTP request http://127.0.0.1:9/v1/whoami failed:"));
469        assert_eq!(
470            error.context.as_ref().unwrap()["url"],
471            "http://127.0.0.1:9/v1/whoami"
472        );
473    }
474}