alien-platform-api 1.13.1

Auto-generated Rust SDK for the Alien Platform API
Documentation
//! Alien Platform API
//!
//! Auto-generated from OpenAPI spec with custom error conversion support.
//!
//! ## Error Handling
//!
//! For SDK API calls, use `SdkResultExt::into_sdk_error()` instead of
//! `.into_alien_error()` to preserve structured API error information:
//!
//! ```ignore
//! use alien_platform_api::SdkResultExt;
//!
//! // ✅ Good: preserves API error code, message, retryable flag
//! client.some_method().send().await.into_sdk_error().context(...)?
//!
//! // ❌ Bad: loses structured error information
//! client.some_method().send().await.into_alien_error().context(...)?
//! ```
//!
//! For non-SDK errors (serde, std, etc.), continue using `.into_alien_error()`.

include!(concat!(env!("OUT_DIR"), "/codegen.rs"));

use alien_error::{AlienError, GenericError, HumanLayerPresentation};

/// Extension trait for converting SDK API results to `AlienError`.
///
/// This properly extracts error information from progenitor's error types,
/// preserving API error details that would be lost with `.into_alien_error()`.
///
/// ## When to use
///
/// Use `into_sdk_error()` for SDK API calls:
/// ```ignore
/// client.sync_acquire().send().await.into_sdk_error().context(...)?
/// ```
///
/// Continue using `into_alien_error()` for non-SDK errors (serde, std, etc.):
/// ```ignore
/// serde_json::to_value(&data).into_alien_error().context(...)?
/// ```
///
/// ## What it preserves
///
/// When the API returns an error response, `into_sdk_error()` preserves:
/// - `code`: The API error code (e.g., "DEPLOYMENT_NOT_FOUND")
/// - `message`: The error message
/// - `retryable`: Whether the operation can be retried
/// - `context`: Additional error context as JSON
/// - `source`: Nested error chain
/// - HTTP status code
pub trait SdkResultExt<T> {
    /// Convert SDK result to `AlienError` result, preserving API error details.
    fn into_sdk_error(self) -> Result<T, AlienError<GenericError>>;
}

impl<T> SdkResultExt<ResponseValue<T>> for Result<ResponseValue<T>, Error<types::ApiError>> {
    fn into_sdk_error(self) -> Result<ResponseValue<T>, AlienError<GenericError>> {
        self.map_err(convert_sdk_error)
    }
}

/// Convert a progenitor SDK error to AlienError, preserving all details.
pub fn convert_sdk_error(err: Error<types::ApiError>) -> AlienError<GenericError> {
    match err {
        // API returned a documented error response with ApiError body
        // This is the main case where we gain value over .into_alien_error()
        Error::ErrorResponse(response) => {
            let status = response.status().as_u16();
            let api_error = response.into_inner();
            let context = context_with_request_id(api_error.context, None);

            AlienError {
                code: api_error.code.to_string(),
                message: api_error.message.to_string(),
                context,
                hint: None,
                retryable: api_error.retryable,
                internal: false, // API errors sent to clients are external by nature
                http_status_code: Some(status),
                source: api_error.source.and_then(parse_source_error),
                human_layer_presentation: HumanLayerPresentation::Normal,
                error: Some(GenericError {
                    message: api_error.message.to_string(),
                }),
            }
        }

        // Network/connection error - typically retryable
        Error::CommunicationError(reqwest_err) => {
            let retryable =
                reqwest_err.is_connect() || reqwest_err.is_timeout() || reqwest_err.is_request();
            let message = reqwest_failure_message("HTTP request", &reqwest_err);

            AlienError {
                code: "COMMUNICATION_ERROR".to_string(),
                message: message.clone(),
                context: reqwest_failure_context(&reqwest_err),
                hint: None,
                retryable,
                internal: false,
                http_status_code: reqwest_err.status().map(|s| s.as_u16()),
                source: build_reqwest_source(&reqwest_err),
                human_layer_presentation: HumanLayerPresentation::Normal,
                error: Some(GenericError { message }),
            }
        }

        // Request validation failed (client-side, before sending)
        Error::InvalidRequest(msg) => AlienError {
            code: "INVALID_REQUEST".to_string(),
            message: format!("Invalid Request: {}", msg),
            context: None,
            hint: None,
            retryable: false,
            internal: false,
            http_status_code: Some(400),
            source: None,
            human_layer_presentation: HumanLayerPresentation::Normal,
            error: Some(GenericError {
                message: format!("Invalid Request: {}", msg),
            }),
        },

        // Failed to read response body
        Error::ResponseBodyError(reqwest_err) => {
            let message = reqwest_failure_message("HTTP response body read", &reqwest_err);

            AlienError {
                code: "RESPONSE_BODY_ERROR".to_string(),
                message: message.clone(),
                context: reqwest_failure_context(&reqwest_err),
                hint: None,
                retryable: true, // Transient network issue
                internal: false,
                http_status_code: reqwest_err.status().map(|s| s.as_u16()),
                source: build_reqwest_source(&reqwest_err),
                human_layer_presentation: HumanLayerPresentation::Normal,
                error: Some(GenericError { message }),
            }
        }

        // Response body couldn't be parsed as expected type
        // Include raw body in context for debugging
        Error::InvalidResponsePayload(bytes, json_err) => {
            let raw_body = String::from_utf8_lossy(&bytes);
            let truncated = if raw_body.len() > 1000 {
                format!(
                    "{}...(truncated {} bytes)",
                    &raw_body[..1000],
                    raw_body.len() - 1000
                )
            } else {
                raw_body.to_string()
            };

            AlienError {
                code: "INVALID_RESPONSE_PAYLOAD".to_string(),
                message: format!("Failed to parse response: {}", json_err),
                context: Some(serde_json::json!({
                    "parseError": json_err.to_string(),
                    "responseBody": truncated,
                })),
                hint: None,
                retryable: false,
                internal: false,
                http_status_code: None,
                source: Some(Box::new(AlienError::new(GenericError {
                    message: json_err.to_string(),
                }))),
                human_layer_presentation: HumanLayerPresentation::Normal,
                error: Some(GenericError {
                    message: format!("Failed to parse response: {}", json_err),
                }),
            }
        }

        // WebSocket upgrade error
        Error::InvalidUpgrade(reqwest_err) => {
            let message = reqwest_failure_message("HTTP connection upgrade", &reqwest_err);

            AlienError {
                code: "INVALID_UPGRADE".to_string(),
                message: message.clone(),
                context: reqwest_failure_context(&reqwest_err),
                hint: None,
                retryable: false,
                internal: false,
                http_status_code: reqwest_err.status().map(|s| s.as_u16()),
                source: build_reqwest_source(&reqwest_err),
                human_layer_presentation: HumanLayerPresentation::Normal,
                error: Some(GenericError { message }),
            }
        }

        // Response with status code not in OpenAPI spec
        Error::UnexpectedResponse(response) => {
            let status = response.status().as_u16();
            AlienError {
                code: "UNEXPECTED_RESPONSE".to_string(),
                message: format!(
                    "Unexpected response: {} {}",
                    status,
                    response.status().canonical_reason().unwrap_or("Unknown")
                ),
                context: Some(serde_json::json!({
                    "status": status,
                    "url": response.url().to_string(),
                })),
                hint: None,
                retryable: status >= 500, // Server errors are typically retryable
                internal: false,
                http_status_code: Some(status),
                source: None,
                human_layer_presentation: HumanLayerPresentation::Normal,
                error: Some(GenericError {
                    message: format!("Unexpected response status: {}", status),
                }),
            }
        }

        // Custom hook error
        Error::Custom(msg) => AlienError {
            code: "SDK_HOOK_ERROR".to_string(),
            message: msg.clone(),
            context: None,
            hint: None,
            retryable: false,
            internal: false,
            http_status_code: None,
            source: None,
            human_layer_presentation: HumanLayerPresentation::Normal,
            error: Some(GenericError { message: msg }),
        },
    }
}

fn context_with_request_id(
    context: Option<serde_json::Value>,
    request_id: Option<&str>,
) -> Option<serde_json::Value> {
    let Some(request_id) = request_id else {
        return context;
    };

    match context {
        Some(serde_json::Value::Object(mut object)) => {
            object
                .entry("requestId")
                .or_insert_with(|| serde_json::Value::String(request_id.to_string()));
            Some(serde_json::Value::Object(object))
        }
        Some(value) => Some(serde_json::json!({
            "requestId": request_id,
            "context": value,
        })),
        None => Some(serde_json::json!({ "requestId": request_id })),
    }
}

fn reqwest_failure_message(operation: &str, err: &reqwest::Error) -> String {
    match err.url() {
        Some(url) => format!("{operation} {} failed: {err}", url),
        None => format!("{operation} failed: {err}"),
    }
}

fn reqwest_failure_context(err: &reqwest::Error) -> Option<serde_json::Value> {
    err.url().map(|url| {
        serde_json::json!({
            "url": url.to_string(),
        })
    })
}

/// Build a source error chain from a reqwest error
fn build_reqwest_source(err: &reqwest::Error) -> Option<Box<AlienError<GenericError>>> {
    // Walk the error chain and build AlienError source chain
    use std::error::Error;

    let mut sources = Vec::new();
    let mut current: Option<&(dyn Error + 'static)> = err.source();

    while let Some(src) = current {
        sources.push(src.to_string());
        current = src.source();
    }

    if sources.is_empty() {
        return None;
    }

    // Build chain from innermost to outermost
    let mut result: Option<Box<AlienError<GenericError>>> = None;
    for msg in sources.into_iter().rev() {
        let error = AlienError {
            code: "GENERIC_ERROR".to_string(),
            message: msg.clone(),
            context: None,
            hint: None,
            retryable: false,
            internal: false,
            http_status_code: None,
            source: result,
            human_layer_presentation: HumanLayerPresentation::Normal,
            error: Some(GenericError { message: msg }),
        };
        result = Some(Box::new(error));
    }

    result
}

/// Try to parse a JSON value as a nested AlienError source chain.
fn parse_source_error(value: serde_json::Value) -> Option<Box<AlienError<GenericError>>> {
    let obj = value.as_object()?;

    let code = obj
        .get("code")
        .and_then(|v| v.as_str())
        .unwrap_or("NESTED_ERROR")
        .to_string();

    let message = obj
        .get("message")
        .and_then(|v| v.as_str())
        .unwrap_or("Nested error")
        .to_string();

    let context = obj.get("context").cloned();
    let retryable = obj
        .get("retryable")
        .and_then(|v| v.as_bool())
        .unwrap_or(false);

    // Recursively parse nested source
    let nested_source = obj.get("source").cloned().and_then(parse_source_error);

    Some(Box::new(AlienError {
        code,
        message: message.clone(),
        context,
        hint: None,
        retryable,
        internal: false,
        http_status_code: None,
        source: nested_source,
        human_layer_presentation: HumanLayerPresentation::Normal,
        error: Some(GenericError { message }),
    }))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_api_error_code_deref() {
        // Verify generated types work as expected
        let code = types::ApiErrorCode::try_from("TEST_ERROR").unwrap();
        assert_eq!(code.as_str(), "TEST_ERROR");
    }

    #[test]
    fn context_with_request_id_adds_request_id_to_empty_context() {
        let context = super::context_with_request_id(None, Some("req_123")).unwrap();

        assert_eq!(context["requestId"], "req_123");
    }

    #[test]
    fn context_with_request_id_preserves_existing_context() {
        let context = super::context_with_request_id(
            Some(serde_json::json!({ "workspace": "demo" })),
            Some("req_123"),
        )
        .unwrap();

        assert_eq!(context["workspace"], "demo");
        assert_eq!(context["requestId"], "req_123");
    }

    #[tokio::test]
    async fn communication_error_includes_url_in_message_and_context() {
        let reqwest_err = reqwest::Client::new()
            .get("http://127.0.0.1:9/v1/whoami")
            .send()
            .await
            .expect_err("localhost discard port should refuse the connection");

        let error = super::convert_sdk_error(Error::CommunicationError(reqwest_err));

        assert_eq!(error.code, "COMMUNICATION_ERROR");
        assert!(error
            .message
            .starts_with("HTTP request http://127.0.0.1:9/v1/whoami failed:"));
        assert_eq!(
            error.context.as_ref().unwrap()["url"],
            "http://127.0.0.1:9/v1/whoami"
        );
    }
}