Skip to main content

plunk_rs/
error.rs

1use reqwest::StatusCode;
2use std::fmt;
3use thiserror::Error as ThisError;
4
5pub type Result<T> = std::result::Result<T, Error>;
6
7#[derive(Clone, Debug, PartialEq, Eq)]
8pub struct ApiFieldError {
9    field: String,
10    message: String,
11    code: Option<String>,
12}
13
14impl ApiFieldError {
15    pub fn field(&self) -> &str {
16        &self.field
17    }
18
19    pub fn message(&self) -> &str {
20        &self.message
21    }
22
23    pub fn error_code(&self) -> Option<&str> {
24        self.code.as_deref()
25    }
26}
27
28#[derive(Clone, Debug, PartialEq, Eq)]
29pub struct ApiError {
30    status: StatusCode,
31    error_code: Option<String>,
32    message: String,
33    field_errors: Vec<ApiFieldError>,
34    body: String,
35}
36
37impl ApiError {
38    pub(crate) fn from_response(status: StatusCode, body: String) -> Self {
39        match serde_json::from_str::<crate::wire::WireApiError>(&body) {
40            Ok(payload) => Self {
41                status,
42                error_code: Some(payload.error.code),
43                message: payload.error.message,
44                field_errors: payload
45                    .error
46                    .errors
47                    .into_iter()
48                    .map(|error| ApiFieldError {
49                        field: error.field,
50                        message: error.message,
51                        code: error.code,
52                    })
53                    .collect(),
54                body,
55            },
56            Err(_) => Self {
57                status,
58                error_code: None,
59                message: "unparseable API error response".to_string(),
60                field_errors: Vec::new(),
61                body,
62            },
63        }
64    }
65
66    pub fn status(&self) -> StatusCode {
67        self.status
68    }
69
70    pub fn error_code(&self) -> Option<&str> {
71        self.error_code.as_deref()
72    }
73
74    pub fn message(&self) -> &str {
75        &self.message
76    }
77
78    pub fn field_errors(&self) -> &[ApiFieldError] {
79        &self.field_errors
80    }
81
82    pub fn body(&self) -> &str {
83        &self.body
84    }
85}
86
87impl fmt::Display for ApiError {
88    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
89        match self.error_code() {
90            Some(code) => write!(f, "{} {}: {}", self.status, code, self.message),
91            None => write!(f, "{}: {}", self.status, self.message),
92        }
93    }
94}
95
96#[derive(Debug, ThisError)]
97pub enum Error {
98    #[error("api key cannot be empty")]
99    InvalidApiKey,
100    #[error("base URL is invalid: {0}")]
101    InvalidBaseUrl(#[from] url::ParseError),
102    #[error("at least one recipient is required")]
103    MissingRecipients,
104    #[error("subject cannot be empty")]
105    InvalidSubject,
106    #[error("body cannot be empty")]
107    InvalidBody,
108    #[error("template id cannot be empty")]
109    InvalidTemplateId,
110    #[error("template id must be a UUID")]
111    InvalidTemplateIdFormat,
112    #[error("display name cannot be empty")]
113    InvalidDisplayName,
114    #[error("header name cannot be empty")]
115    InvalidHeaderName,
116    #[error("header value cannot be empty")]
117    InvalidHeaderValue,
118    #[error("email address is invalid: {reason} ({value})")]
119    InvalidEmailAddress { reason: &'static str, value: String },
120    #[error("template data must serialize into a JSON object")]
121    TemplateDataMustBeObject,
122    #[error("failed to serialize template data: {0}")]
123    TemplateDataSerialization(serde_json::Error),
124    #[error("http request failed: {0}")]
125    Transport(#[from] reqwest::Error),
126    #[error("plunk API returned {0}")]
127    Api(ApiError),
128    #[error("unexpected API response: {0}")]
129    UnexpectedResponse(String),
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135
136    #[test]
137    fn api_error_preserves_raw_body_when_json_is_unparseable() {
138        let error =
139            ApiError::from_response(StatusCode::BAD_GATEWAY, "<html>bad gateway</html>".into());
140
141        assert_eq!(error.status(), StatusCode::BAD_GATEWAY);
142        assert_eq!(error.error_code(), None);
143        assert!(error.field_errors().is_empty());
144        assert_eq!(error.body(), "<html>bad gateway</html>");
145    }
146
147    #[test]
148    fn api_error_parses_nested_plunk_error_shape() {
149        let body = r#"{"success":false,"error":{"code":"VALIDATION_ERROR","message":"Request validation failed","statusCode":422,"requestId":"babb9a40-0826-4246-998d-35f6b3265612","errors":[{"field":"template","message":"Invalid uuid","code":"invalid_string"}],"suggestion":"Please check the API documentation for the correct request format."},"timestamp":"2026-04-30T02:47:06.773Z"}"#;
150
151        let error = ApiError::from_response(StatusCode::UNPROCESSABLE_ENTITY, body.into());
152
153        assert_eq!(error.status(), StatusCode::UNPROCESSABLE_ENTITY);
154        assert_eq!(error.error_code(), Some("VALIDATION_ERROR"));
155        assert_eq!(error.message(), "Request validation failed");
156        assert_eq!(error.field_errors().len(), 1);
157        assert_eq!(error.field_errors()[0].field(), "template");
158        assert_eq!(error.field_errors()[0].message(), "Invalid uuid");
159        assert_eq!(error.field_errors()[0].error_code(), Some("invalid_string"));
160    }
161}