//! Wire error envelope. Mirrors `MizanError` in `frontends/mizan-base/src/index.ts`. //! //! Two envelope shapes are tolerated: //! //! - FastAPI: `{"error": {"code": "...", "message": "...", "details": ...}}` //! - Django: `{"error": true, "code": "...", "message": "...", "details": ...}` //! //! When neither shape parses, `code` falls back to `HTTP_` and the //! raw response body is the message. use serde::Deserialize; use serde_json::Value; #[derive(Debug, Clone)] pub struct MizanError { pub status: u16, pub code: String, pub message: String, pub details: Option, pub raw_body: String, } impl MizanError { pub fn from_response(status: u16, body: String) -> Self { let parsed = serde_json::from_str::(&body).ok(); let (code, message, details) = match parsed { Some(Envelope::Fastapi { error }) => ( error.code.unwrap_or_else(|| format!("HTTP_{status}")), error.message.unwrap_or_else(|| format!("Mizan call failed ({status})")), error.details, ), Some(Envelope::Django { code, message, details, .. }) => ( code.unwrap_or_else(|| format!("HTTP_{status}")), message.unwrap_or_else(|| format!("Mizan call failed ({status})")), details, ), None => ( format!("HTTP_{status}"), format!("Mizan call failed ({status})"), None, ), }; Self { status, code, message, details, raw_body: body } } pub fn transport(message: impl Into) -> Self { Self { status: 0, code: "TRANSPORT".to_string(), message: message.into(), details: None, raw_body: String::new(), } } } impl std::fmt::Display for MizanError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "Mizan {} ({}): {}", self.status, self.code, self.message) } } impl std::error::Error for MizanError {} #[derive(Deserialize)] #[serde(untagged)] enum Envelope { Fastapi { error: NestedError }, Django { // Django form is `{"error": true, "code": ..., "message": ..., "details": ...}`. // `error` is a bool sentinel; the actual fields are siblings. #[allow(dead_code)] error: bool, code: Option, message: Option, details: Option, }, } #[derive(Deserialize)] struct NestedError { code: Option, message: Option, details: Option, } #[cfg(test)] mod tests { use super::*; #[test] fn parses_fastapi_envelope() { let body = r#"{"error":{"code":"BAD_REQUEST","message":"oops","details":{"k":1}}}"#; let e = MizanError::from_response(400, body.to_string()); assert_eq!(e.code, "BAD_REQUEST"); assert_eq!(e.message, "oops"); assert_eq!(e.details, Some(serde_json::json!({"k": 1}))); } #[test] fn parses_django_envelope() { let body = r#"{"error":true,"code":"NOT_FOUND","message":"missing","details":null}"#; let e = MizanError::from_response(404, body.to_string()); assert_eq!(e.code, "NOT_FOUND"); assert_eq!(e.message, "missing"); } #[test] fn falls_back_on_unparseable_body() { let e = MizanError::from_response(500, "Internal Server Error".to_string()); assert_eq!(e.code, "HTTP_500"); assert!(e.message.contains("500")); } }