lightcone 0.5.2

Rust SDK for the Lightcone Protocol — unified native + WASM client
Documentation
//! Generic API response wrapper and structured error details.
//!
//! All backend endpoints return responses in this shape:
//!
//! ```json
//! // Success
//! {"status": "success", "body": { ... }}
//!
//! // Error
//! {"status": "error", "error_details": {"reason": "...", ...}}
//! ```
//!
//! # Usage
//!
//! The HTTP client handles `ApiResponse<T>` unwrapping internally.
//! Domain sub-clients call `get()` / `post()` directly and receive `Result<T, SdkError>`:
//!
//! ```rust,ignore
//! let body: MyBody = client.http
//!     .get(&url, RetryPolicy::Idempotent)
//!     .await?;
//! ```

use super::rejection::RejectionCode;
use serde::{Deserialize, Serialize};
use std::fmt;

/// Generic API response wrapper. All backend endpoints return this shape.
///
/// Discriminated on `"status"`: `"success"` has `body`, `"error"` has `error_details`.
///
/// The HTTP client deserializes this internally and returns the unwrapped body
/// or an `SdkError::ApiRejected` error to callers.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "status", rename_all = "snake_case")]
pub enum ApiResponse<T> {
    Success {
        body: T,
    },
    #[serde(rename = "error")]
    Rejected {
        #[serde(rename = "error_details")]
        details: ApiRejectedDetails,
    },
}

/// Structured error/rejection details from the backend.
///
/// Contains a human-readable `reason`, optional machine-readable codes, and
/// correlation IDs for support and debugging.
///
/// # Display
///
/// `Display` formats as `"{code}: {reason}"` when a code is present, or just `"{reason}"`:
///
/// ```rust,ignore
/// let details = ApiRejectedDetails { reason: "Not enough funds".into(), .. };
/// // "Reason: Not enough funds\nRejection Code: Insufficient Balance\nError Log ID: LCERR_abc"
/// println!("{}", details);
/// ```
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ApiRejectedDetails {
    pub reason: String,

    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub rejection_code: Option<RejectionCode>,

    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub error_code: Option<String>,

    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub error_log_id: Option<String>,

    /// Request correlation ID generated by the SDK and sent as `x-request-id` header.
    /// Set internally by the HTTP client on error; not present in the backend JSON response.
    #[serde(skip)]
    pub request_id: Option<String>,
}

impl fmt::Display for ApiRejectedDetails {
    /// Formats all present fields as a multi-line report.
    ///
    /// # Example output
    ///
    /// ```text
    /// Reason: Insufficient balance: need 1000 but only 500 available
    /// Rejection Code: Insufficient Balance
    /// Error Log ID: LCERR_01963A7B4F2E
    /// Request ID: 8f14e45f-ceea-467f-a8f0-2b3c4d5e6f78
    /// ```
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "Reason: {}", self.reason)?;
        if let Some(code) = &self.rejection_code {
            write!(f, "\nRejection Code: {}", code)?;
        }
        if let Some(code) = &self.error_code {
            write!(f, "\nError Code: {}", code)?;
        }
        if let Some(id) = &self.error_log_id {
            write!(f, "\nError Log ID: {}", id)?;
        }
        if let Some(id) = &self.request_id {
            write!(f, "\nRequest ID: {}", id)?;
        }
        Ok(())
    }
}

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

    #[test]
    fn test_deserialize_success_response() {
        let json = r#"{"status": "success", "body": {"value": 42}}"#;
        let resp: ApiResponse<serde_json::Value> = serde_json::from_str(json).unwrap();
        match resp {
            ApiResponse::Success { body } => assert_eq!(body["value"], 42),
            ApiResponse::Rejected { .. } => panic!("expected success"),
        }
    }

    #[test]
    fn test_deserialize_error_response_with_rejection() {
        let json = r#"{
            "status": "error",
            "error_details": {
                "reason": "Insufficient balance",
                "rejection_code": "INSUFFICIENT_BALANCE",
                "error_log_id": "LCERR_abc123"
            }
        }"#;
        let resp: ApiResponse<serde_json::Value> = serde_json::from_str(json).unwrap();
        match resp {
            ApiResponse::Rejected {
                details: error_details,
            } => {
                assert_eq!(error_details.reason, "Insufficient balance");
                assert_eq!(
                    error_details.rejection_code,
                    Some(RejectionCode::InsufficientBalance)
                );
                assert_eq!(error_details.error_log_id, Some("LCERR_abc123".to_string()));
                assert_eq!(error_details.error_code, None);
            }
            ApiResponse::Success { .. } => panic!("expected error"),
        }
    }

    #[test]
    fn test_deserialize_error_response_with_error_code() {
        let json = r#"{
            "status": "error",
            "error_details": {
                "reason": "Market not found",
                "error_code": "NOT_FOUND"
            }
        }"#;
        let resp: ApiResponse<serde_json::Value> = serde_json::from_str(json).unwrap();
        match resp {
            ApiResponse::Rejected {
                details: error_details,
            } => {
                assert_eq!(error_details.reason, "Market not found");
                assert_eq!(error_details.error_code, Some("NOT_FOUND".to_string()));
                assert_eq!(error_details.rejection_code, None);
            }
            ApiResponse::Success { .. } => panic!("expected error"),
        }
    }

    #[test]
    fn test_display_all_fields() {
        let details = ApiRejectedDetails {
            reason: "Not enough funds".to_string(),
            rejection_code: Some(RejectionCode::InsufficientBalance),
            error_code: None,
            error_log_id: Some("LCERR_abc".to_string()),
            request_id: Some("req-123".to_string()),
        };
        let text = format!("{}", details);
        assert!(text.contains("Reason: Not enough funds"));
        assert!(text.contains("Rejection Code: Insufficient Balance"));
        assert!(text.contains("Error Log ID: LCERR_abc"));
        assert!(text.contains("Request ID: req-123"));
    }

    #[test]
    fn test_display_reason_only() {
        let details = ApiRejectedDetails {
            reason: "Something broke".to_string(),
            rejection_code: None,
            error_code: None,
            error_log_id: None,
            request_id: None,
        };
        assert_eq!(format!("{}", details), "Reason: Something broke");
    }
}