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 = context_with_request_id(api_error.context, None);
72
73            AlienError {
74                code: api_error.code.to_string(),
75                message: api_error.message.to_string(),
76                context,
77                hint: None,
78                retryable: api_error.retryable,
79                internal: false, // API errors sent to clients are external by nature
80                http_status_code: Some(status),
81                source: api_error.source.and_then(parse_source_error),
82                human_layer_presentation: HumanLayerPresentation::Normal,
83                error: Some(GenericError {
84                    message: api_error.message.to_string(),
85                }),
86            }
87        }
88
89        // Network/connection error - typically retryable
90        Error::CommunicationError(reqwest_err) => {
91            let retryable =
92                reqwest_err.is_connect() || reqwest_err.is_timeout() || reqwest_err.is_request();
93            let message = reqwest_failure_message("HTTP request", &reqwest_err);
94
95            AlienError {
96                code: "COMMUNICATION_ERROR".to_string(),
97                message: message.clone(),
98                context: reqwest_failure_context(&reqwest_err),
99                hint: None,
100                retryable,
101                internal: false,
102                http_status_code: reqwest_err.status().map(|s| s.as_u16()),
103                source: build_reqwest_source(&reqwest_err),
104                human_layer_presentation: HumanLayerPresentation::Normal,
105                error: Some(GenericError { message }),
106            }
107        }
108
109        // Request validation failed (client-side, before sending)
110        Error::InvalidRequest(msg) => AlienError {
111            code: "INVALID_REQUEST".to_string(),
112            message: format!("Invalid Request: {}", msg),
113            context: None,
114            hint: None,
115            retryable: false,
116            internal: false,
117            http_status_code: Some(400),
118            source: None,
119            human_layer_presentation: HumanLayerPresentation::Normal,
120            error: Some(GenericError {
121                message: format!("Invalid Request: {}", msg),
122            }),
123        },
124
125        // Failed to read response body
126        Error::ResponseBodyError(reqwest_err) => {
127            let message = reqwest_failure_message("HTTP response body read", &reqwest_err);
128
129            AlienError {
130                code: "RESPONSE_BODY_ERROR".to_string(),
131                message: message.clone(),
132                context: reqwest_failure_context(&reqwest_err),
133                hint: None,
134                retryable: true, // Transient network issue
135                internal: false,
136                http_status_code: reqwest_err.status().map(|s| s.as_u16()),
137                source: build_reqwest_source(&reqwest_err),
138                human_layer_presentation: HumanLayerPresentation::Normal,
139                error: Some(GenericError { message }),
140            }
141        }
142
143        // Response body couldn't be parsed as expected type
144        // Include raw body in context for debugging
145        Error::InvalidResponsePayload(bytes, json_err) => {
146            let raw_body = String::from_utf8_lossy(&bytes);
147            let truncated = if raw_body.len() > 1000 {
148                format!(
149                    "{}...(truncated {} bytes)",
150                    &raw_body[..1000],
151                    raw_body.len() - 1000
152                )
153            } else {
154                raw_body.to_string()
155            };
156
157            AlienError {
158                code: "INVALID_RESPONSE_PAYLOAD".to_string(),
159                message: format!("Failed to parse response: {}", json_err),
160                context: Some(serde_json::json!({
161                    "parseError": json_err.to_string(),
162                    "responseBody": truncated,
163                })),
164                hint: None,
165                retryable: false,
166                internal: false,
167                http_status_code: None,
168                source: Some(Box::new(AlienError::new(GenericError {
169                    message: json_err.to_string(),
170                }))),
171                human_layer_presentation: HumanLayerPresentation::Normal,
172                error: Some(GenericError {
173                    message: format!("Failed to parse response: {}", json_err),
174                }),
175            }
176        }
177
178        // WebSocket upgrade error
179        Error::InvalidUpgrade(reqwest_err) => {
180            let message = reqwest_failure_message("HTTP connection upgrade", &reqwest_err);
181
182            AlienError {
183                code: "INVALID_UPGRADE".to_string(),
184                message: message.clone(),
185                context: reqwest_failure_context(&reqwest_err),
186                hint: None,
187                retryable: false,
188                internal: false,
189                http_status_code: reqwest_err.status().map(|s| s.as_u16()),
190                source: build_reqwest_source(&reqwest_err),
191                human_layer_presentation: HumanLayerPresentation::Normal,
192                error: Some(GenericError { message }),
193            }
194        }
195
196        // Response with status code not in OpenAPI spec
197        Error::UnexpectedResponse(response) => {
198            let status = response.status().as_u16();
199            AlienError {
200                code: "UNEXPECTED_RESPONSE".to_string(),
201                message: format!(
202                    "Unexpected response: {} {}",
203                    status,
204                    response.status().canonical_reason().unwrap_or("Unknown")
205                ),
206                context: Some(serde_json::json!({
207                    "status": status,
208                    "url": response.url().to_string(),
209                })),
210                hint: None,
211                retryable: status >= 500, // Server errors are typically retryable
212                internal: false,
213                http_status_code: Some(status),
214                source: None,
215                human_layer_presentation: HumanLayerPresentation::Normal,
216                error: Some(GenericError {
217                    message: format!("Unexpected response status: {}", status),
218                }),
219            }
220        }
221
222        // Custom hook error
223        Error::Custom(msg) => AlienError {
224            code: "SDK_HOOK_ERROR".to_string(),
225            message: msg.clone(),
226            context: None,
227            hint: None,
228            retryable: false,
229            internal: false,
230            http_status_code: None,
231            source: None,
232            human_layer_presentation: HumanLayerPresentation::Normal,
233            error: Some(GenericError { message: msg }),
234        },
235    }
236}
237
238fn context_with_request_id(
239    context: Option<serde_json::Value>,
240    request_id: Option<&str>,
241) -> Option<serde_json::Value> {
242    let Some(request_id) = request_id else {
243        return context;
244    };
245
246    match context {
247        Some(serde_json::Value::Object(mut object)) => {
248            object
249                .entry("requestId")
250                .or_insert_with(|| serde_json::Value::String(request_id.to_string()));
251            Some(serde_json::Value::Object(object))
252        }
253        Some(value) => Some(serde_json::json!({
254            "requestId": request_id,
255            "context": value,
256        })),
257        None => Some(serde_json::json!({ "requestId": request_id })),
258    }
259}
260
261fn reqwest_failure_message(operation: &str, err: &reqwest::Error) -> String {
262    match err.url() {
263        Some(url) => format!("{operation} {} failed: {err}", url),
264        None => format!("{operation} failed: {err}"),
265    }
266}
267
268fn reqwest_failure_context(err: &reqwest::Error) -> Option<serde_json::Value> {
269    err.url().map(|url| {
270        serde_json::json!({
271            "url": url.to_string(),
272        })
273    })
274}
275
276/// Build a source error chain from a reqwest error
277fn build_reqwest_source(err: &reqwest::Error) -> Option<Box<AlienError<GenericError>>> {
278    // Walk the error chain and build AlienError source chain
279    use std::error::Error;
280
281    let mut sources = Vec::new();
282    let mut current: Option<&(dyn Error + 'static)> = err.source();
283
284    while let Some(src) = current {
285        sources.push(src.to_string());
286        current = src.source();
287    }
288
289    if sources.is_empty() {
290        return None;
291    }
292
293    // Build chain from innermost to outermost
294    let mut result: Option<Box<AlienError<GenericError>>> = None;
295    for msg in sources.into_iter().rev() {
296        let error = AlienError {
297            code: "GENERIC_ERROR".to_string(),
298            message: msg.clone(),
299            context: None,
300            hint: None,
301            retryable: false,
302            internal: false,
303            http_status_code: None,
304            source: result,
305            human_layer_presentation: HumanLayerPresentation::Normal,
306            error: Some(GenericError { message: msg }),
307        };
308        result = Some(Box::new(error));
309    }
310
311    result
312}
313
314/// Try to parse a JSON value as a nested AlienError source chain.
315fn parse_source_error(value: serde_json::Value) -> Option<Box<AlienError<GenericError>>> {
316    let obj = value.as_object()?;
317
318    let code = obj
319        .get("code")
320        .and_then(|v| v.as_str())
321        .unwrap_or("NESTED_ERROR")
322        .to_string();
323
324    let message = obj
325        .get("message")
326        .and_then(|v| v.as_str())
327        .unwrap_or("Nested error")
328        .to_string();
329
330    let context = obj.get("context").cloned();
331    let retryable = obj
332        .get("retryable")
333        .and_then(|v| v.as_bool())
334        .unwrap_or(false);
335
336    // Recursively parse nested source
337    let nested_source = obj.get("source").cloned().and_then(parse_source_error);
338
339    Some(Box::new(AlienError {
340        code,
341        message: message.clone(),
342        context,
343        hint: None,
344        retryable,
345        internal: false,
346        http_status_code: None,
347        source: nested_source,
348        human_layer_presentation: HumanLayerPresentation::Normal,
349        error: Some(GenericError { message }),
350    }))
351}
352
353#[cfg(test)]
354mod tests {
355    use super::*;
356
357    #[test]
358    fn test_api_error_code_deref() {
359        // Verify generated types work as expected
360        let code = types::ApiErrorCode::try_from("TEST_ERROR").unwrap();
361        assert_eq!(code.as_str(), "TEST_ERROR");
362    }
363
364    #[test]
365    fn context_with_request_id_adds_request_id_to_empty_context() {
366        let context = super::context_with_request_id(None, Some("req_123")).unwrap();
367
368        assert_eq!(context["requestId"], "req_123");
369    }
370
371    #[test]
372    fn context_with_request_id_preserves_existing_context() {
373        let context = super::context_with_request_id(
374            Some(serde_json::json!({ "workspace": "demo" })),
375            Some("req_123"),
376        )
377        .unwrap();
378
379        assert_eq!(context["workspace"], "demo");
380        assert_eq!(context["requestId"], "req_123");
381    }
382
383    #[tokio::test]
384    async fn communication_error_includes_url_in_message_and_context() {
385        let reqwest_err = reqwest::Client::new()
386            .get("http://127.0.0.1:9/v1/whoami")
387            .send()
388            .await
389            .expect_err("localhost discard port should refuse the connection");
390
391        let error = super::convert_sdk_error(Error::CommunicationError(reqwest_err));
392
393        assert_eq!(error.code, "COMMUNICATION_ERROR");
394        assert!(error
395            .message
396            .starts_with("HTTP request http://127.0.0.1:9/v1/whoami failed:"));
397        assert_eq!(
398            error.context.as_ref().unwrap()["url"],
399            "http://127.0.0.1:9/v1/whoami"
400        );
401    }
402}