Skip to main content

zenodo_rs/
error.rs

1//! Error types and HTTP error decoding for Zenodo responses.
2//!
3//! [`ZenodoError`] intentionally covers both structured Zenodo API failures and
4//! lower-level transport or local I/O problems so callers can decide whether an
5//! operation should be retried, surfaced to users, or treated as a workflow
6//! invariant violation.
7
8use client_uploader_traits::UploadNameValidationError;
9use reqwest::{Response, StatusCode};
10use serde::{Deserialize, Serialize};
11use serde_json::Value;
12use thiserror::Error;
13
14/// Field-specific validation error returned by Zenodo.
15#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
16pub struct FieldError {
17    /// The field name, when Zenodo reports one.
18    #[serde(default)]
19    pub field: Option<String>,
20    /// Human-readable error message for the field.
21    pub message: String,
22}
23
24/// Errors produced by the Zenodo client.
25#[derive(Debug, Error)]
26pub enum ZenodoError {
27    /// Zenodo returned a non-success HTTP status.
28    #[error("Zenodo returned HTTP {status}: {message:?}")]
29    Http {
30        /// HTTP status returned by Zenodo.
31        status: StatusCode,
32        /// Summary message extracted from the response body, when available.
33        message: Option<String>,
34        /// Field-level validation errors extracted from the response body.
35        field_errors: Vec<FieldError>,
36        /// Trimmed raw response body for diagnostics.
37        raw_body: Option<String>,
38    },
39    /// A transport error occurred while sending or receiving a request.
40    #[error(transparent)]
41    Transport(
42        /// Underlying transport error.
43        #[from]
44        reqwest::Error,
45    ),
46    /// JSON serialization or deserialization failed.
47    #[error(transparent)]
48    Json(
49        /// Underlying JSON error.
50        #[from]
51        serde_json::Error,
52    ),
53    /// A local I/O operation failed.
54    #[error(transparent)]
55    Io(
56        /// Underlying I/O error.
57        #[from]
58        std::io::Error,
59    ),
60    /// A URL could not be parsed or joined.
61    #[error(transparent)]
62    Url(
63        /// Underlying URL parse error.
64        #[from]
65        url::ParseError,
66    ),
67    /// A required environment variable could not be read.
68    #[error("failed to read environment variable {name}: {source}")]
69    EnvVar {
70        /// Environment variable name.
71        name: String,
72        /// Underlying environment lookup error.
73        #[source]
74        source: std::env::VarError,
75    },
76    /// Zenodo returned data that violates a workflow invariant.
77    #[error("invalid Zenodo state: {0}")]
78    InvalidState(
79        /// Description of the invalid state.
80        String,
81    ),
82    /// A required link relation was missing from a Zenodo payload.
83    #[error("missing Zenodo link: {0}")]
84    MissingLink(
85        /// Missing link relation name.
86        &'static str,
87    ),
88    /// A requested file key was not present on a record.
89    #[error("missing record file: {key}")]
90    MissingFile {
91        /// Missing record file key.
92        key: String,
93    },
94    /// Multiple uploads targeted the same final filename.
95    #[error("duplicate upload filename: {filename}")]
96    DuplicateUploadFilename {
97        /// Duplicate filename seen in the upload set.
98        filename: String,
99    },
100    /// A keep-existing upload would overwrite an existing draft file.
101    #[error("draft already contains file and replacement policy forbids overwrite: {filename}")]
102    ConflictingDraftFile {
103        /// Conflicting filename already present on the draft.
104        filename: String,
105    },
106    /// A selector could not be resolved to a record or artifact.
107    #[error("unsupported selector: {0}")]
108    UnsupportedSelector(
109        /// Description of the unsupported selector.
110        String,
111    ),
112    /// A checksum validation step failed.
113    #[error("checksum mismatch: expected {expected}, got {actual}")]
114    ChecksumMismatch {
115        /// Expected checksum value.
116        expected: String,
117        /// Actual checksum value.
118        actual: String,
119    },
120    /// Polling timed out before Zenodo reached the requested state.
121    #[error("timed out waiting for Zenodo {0}")]
122    Timeout(
123        /// Label for the operation that timed out.
124        &'static str,
125    ),
126}
127
128impl From<UploadNameValidationError> for ZenodoError {
129    fn from(value: UploadNameValidationError) -> Self {
130        match value {
131            UploadNameValidationError::EmptyFilename => {
132                Self::InvalidState("upload filename cannot be empty".to_owned())
133            }
134            UploadNameValidationError::DuplicateFilename { filename } => {
135                Self::DuplicateUploadFilename { filename }
136            }
137        }
138    }
139}
140
141impl ZenodoError {
142    pub(crate) async fn from_response(response: Response) -> Self {
143        let status = response.status();
144        let content_type = response
145            .headers()
146            .get(reqwest::header::CONTENT_TYPE)
147            .and_then(|value| value.to_str().ok())
148            .map(str::to_owned);
149
150        let body = match response.bytes().await {
151            Ok(body) => body,
152            Err(error) => return Self::Transport(error),
153        };
154
155        decode_http_error(status, content_type.as_deref(), &body)
156    }
157}
158
159pub(crate) fn decode_http_error(
160    status: StatusCode,
161    content_type: Option<&str>,
162    body: &[u8],
163) -> ZenodoError {
164    let raw_body = trimmed_body(body);
165    let parsed = if looks_like_json(content_type, body) {
166        parse_json_error(body)
167    } else {
168        None
169    };
170
171    let (message, field_errors) = match parsed {
172        Some((message, field_errors)) => (message, field_errors),
173        None => (raw_body.clone(), Vec::new()),
174    };
175
176    ZenodoError::Http {
177        status,
178        message,
179        field_errors,
180        raw_body,
181    }
182}
183
184fn looks_like_json(content_type: Option<&str>, body: &[u8]) -> bool {
185    if content_type
186        .is_some_and(|value| value.starts_with("application/json") || value.ends_with("+json"))
187    {
188        return true;
189    }
190
191    body.iter()
192        .find(|byte| !byte.is_ascii_whitespace())
193        .is_some_and(|byte| matches!(byte, b'{' | b'['))
194}
195
196fn parse_json_error(body: &[u8]) -> Option<(Option<String>, Vec<FieldError>)> {
197    let value: Value = serde_json::from_slice(body).ok()?;
198    let message = if let Some(message) = value.get("message").and_then(Value::as_str) {
199        Some(message.to_owned())
200    } else {
201        value
202            .get("title")
203            .and_then(Value::as_str)
204            .map(str::to_owned)
205    };
206
207    let field_errors = if let Some(errors) = value.get("errors") {
208        parse_field_errors(errors).unwrap_or_default()
209    } else {
210        Vec::new()
211    };
212
213    Some((message, field_errors))
214}
215
216fn parse_field_errors(value: &Value) -> Option<Vec<FieldError>> {
217    match value {
218        Value::Array(items) => {
219            let mut errors = Vec::new();
220            for item in items {
221                match item {
222                    Value::Object(map) => {
223                        let message =
224                            if let Some(message) = map.get("message").and_then(Value::as_str) {
225                                message.to_owned()
226                            } else {
227                                "unknown error".to_owned()
228                            };
229                        errors.push(FieldError {
230                            field: map.get("field").and_then(Value::as_str).map(str::to_owned),
231                            message,
232                        });
233                    }
234                    Value::String(message) => errors.push(FieldError {
235                        field: None,
236                        message: message.clone(),
237                    }),
238                    _ => {}
239                }
240            }
241            Some(errors)
242        }
243        Value::Object(map) => {
244            let mut errors = Vec::new();
245            for (field, message) in map {
246                let message = if let Some(message) = message.as_str() {
247                    message.to_owned()
248                } else {
249                    message.to_string()
250                };
251                errors.push(FieldError {
252                    field: Some(field.clone()),
253                    message,
254                });
255            }
256            Some(errors)
257        }
258        _ => None,
259    }
260}
261
262fn trimmed_body(body: &[u8]) -> Option<String> {
263    let text = String::from_utf8_lossy(body);
264    for line in text.lines().map(str::trim) {
265        if !line.is_empty() {
266            return Some(line.chars().take(512).collect());
267        }
268    }
269
270    None
271}
272
273#[cfg(test)]
274mod tests {
275    use client_uploader_traits::UploadNameValidationError;
276
277    use super::{decode_http_error, parse_field_errors, parse_json_error, trimmed_body};
278    use reqwest::StatusCode;
279    use serde_json::json;
280
281    #[test]
282    fn parses_json_error_bodies() {
283        let error = decode_http_error(
284            StatusCode::BAD_REQUEST,
285            Some("application/json"),
286            br#"{"message":"bad metadata","errors":[{"field":"metadata.title","message":"required"}]}"#,
287        );
288
289        match error {
290            super::ZenodoError::Http {
291                message,
292                field_errors,
293                ..
294            } => {
295                assert_eq!(message.as_deref(), Some("bad metadata"));
296                assert_eq!(field_errors.len(), 1);
297                assert_eq!(field_errors[0].field.as_deref(), Some("metadata.title"));
298            }
299            other => panic!("unexpected error: {other:?}"),
300        }
301    }
302
303    #[test]
304    fn parses_plaintext_error_bodies() {
305        let error = decode_http_error(
306            StatusCode::INTERNAL_SERVER_ERROR,
307            Some("text/plain"),
308            b"upstream exploded\nstack trace omitted",
309        );
310
311        match error {
312            super::ZenodoError::Http { message, .. } => {
313                assert_eq!(message.as_deref(), Some("upstream exploded"));
314            }
315            other => panic!("unexpected error: {other:?}"),
316        }
317    }
318
319    #[test]
320    fn parses_object_shaped_field_errors_and_json_without_content_type() {
321        let error = decode_http_error(
322            StatusCode::UNPROCESSABLE_ENTITY,
323            None,
324            br#"{"title":"validation failed","errors":{"metadata.creators":"required"}}"#,
325        );
326
327        match error {
328            super::ZenodoError::Http {
329                message,
330                field_errors,
331                ..
332            } => {
333                assert_eq!(message.as_deref(), Some("validation failed"));
334                assert_eq!(field_errors[0].field.as_deref(), Some("metadata.creators"));
335                assert_eq!(field_errors[0].message, "required");
336            }
337            other => panic!("unexpected error: {other:?}"),
338        }
339    }
340
341    #[test]
342    fn preserves_string_array_errors_and_empty_bodies() {
343        let error = decode_http_error(
344            StatusCode::BAD_REQUEST,
345            Some("application/problem+json"),
346            br#"{"errors":["first","second"]}"#,
347        );
348        match error {
349            super::ZenodoError::Http { field_errors, .. } => {
350                assert_eq!(field_errors.len(), 2);
351                assert_eq!(field_errors[0].message, "first");
352            }
353            other => panic!("unexpected error: {other:?}"),
354        }
355
356        let empty = decode_http_error(StatusCode::BAD_GATEWAY, Some("text/plain"), b"   ");
357        match empty {
358            super::ZenodoError::Http {
359                message, raw_body, ..
360            } => {
361                assert_eq!(message, None);
362                assert_eq!(raw_body, None);
363            }
364            other => panic!("unexpected error: {other:?}"),
365        }
366    }
367
368    #[test]
369    fn covers_title_only_invalid_json_and_mixed_error_shapes() {
370        let title_only = decode_http_error(
371            StatusCode::BAD_REQUEST,
372            Some("application/json"),
373            br#"{"title":"just title"}"#,
374        );
375        match title_only {
376            super::ZenodoError::Http { message, .. } => {
377                assert_eq!(message.as_deref(), Some("just title"));
378            }
379            other => panic!("unexpected error: {other:?}"),
380        }
381
382        let malformed = decode_http_error(
383            StatusCode::BAD_REQUEST,
384            Some("application/json"),
385            br#"{"broken":"json""#,
386        );
387        match malformed {
388            super::ZenodoError::Http {
389                message, raw_body, ..
390            } => {
391                assert_eq!(message.as_deref(), Some("{\"broken\":\"json\""));
392                assert_eq!(raw_body.as_deref(), Some("{\"broken\":\"json\""));
393            }
394            other => panic!("unexpected error: {other:?}"),
395        }
396
397        let mixed = decode_http_error(
398            StatusCode::BAD_REQUEST,
399            Some("application/json"),
400            br#"{"errors":[{"field":"a"},42],"title":"mix"}"#,
401        );
402        match mixed {
403            super::ZenodoError::Http { field_errors, .. } => {
404                assert_eq!(field_errors.len(), 1);
405                assert_eq!(field_errors[0].message, "unknown error");
406            }
407            other => panic!("unexpected error: {other:?}"),
408        }
409
410        let object_non_string = decode_http_error(
411            StatusCode::BAD_REQUEST,
412            Some("application/json"),
413            br#"{"errors":{"field":{"nested":true}}}"#,
414        );
415        match object_non_string {
416            super::ZenodoError::Http { field_errors, .. } => {
417                assert_eq!(field_errors[0].message, "{\"nested\":true}");
418            }
419            other => panic!("unexpected error: {other:?}"),
420        }
421    }
422
423    #[test]
424    fn direct_error_helpers_cover_remaining_shapes() {
425        let parsed = parse_json_error(br#"{"title":"title only"}"#).unwrap();
426        assert_eq!(parsed.0.as_deref(), Some("title only"));
427        assert!(parsed.1.is_empty());
428
429        let object_errors = parse_field_errors(&json!({
430            "metadata.title": { "detail": "required" }
431        }))
432        .unwrap();
433        assert_eq!(object_errors[0].field.as_deref(), Some("metadata.title"));
434        assert_eq!(object_errors[0].message, r#"{"detail":"required"}"#);
435
436        let array_errors = parse_field_errors(&json!([
437            { "field": "metadata.title" }
438        ]))
439        .unwrap();
440        assert_eq!(array_errors[0].message, "unknown error");
441
442        assert_eq!(parse_field_errors(&json!(true)), None);
443        assert_eq!(
444            trimmed_body(b"   single line without newline   "),
445            Some("single line without newline".into())
446        );
447    }
448
449    #[test]
450    fn field_error_parser_ignores_unknown_array_items() {
451        let parsed = parse_field_errors(&json!([42, true, null])).unwrap();
452        assert!(parsed.is_empty());
453    }
454
455    #[test]
456    fn upload_name_validation_errors_convert_into_zenodo_errors() {
457        let empty = super::ZenodoError::from(UploadNameValidationError::EmptyFilename);
458        assert!(matches!(
459            empty,
460            super::ZenodoError::InvalidState(message) if message == "upload filename cannot be empty"
461        ));
462
463        let duplicate = super::ZenodoError::from(UploadNameValidationError::DuplicateFilename {
464            filename: "artifact.bin".to_owned(),
465        });
466        assert!(matches!(
467            duplicate,
468            super::ZenodoError::DuplicateUploadFilename { filename } if filename == "artifact.bin"
469        ));
470    }
471
472    #[tokio::test]
473    async fn from_response_decodes_reqwest_response() {
474        use tokio::io::{AsyncReadExt, AsyncWriteExt};
475        use tokio::net::TcpListener;
476
477        crate::client::ensure_rustls_provider();
478
479        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
480        let address = listener.local_addr().unwrap();
481
482        tokio::spawn(async move {
483            let (mut stream, _) = listener.accept().await.unwrap();
484            let mut buffer = [0_u8; 1024];
485            let _ = stream.read(&mut buffer).await;
486            let _ = stream
487                .write_all(
488                    b"HTTP/1.1 418 I'm a teapot\r\ncontent-type: text/plain\r\ncontent-length: 13\r\n\r\nbrew failed\r\n",
489                )
490                .await;
491            let _ = stream.shutdown().await;
492        });
493
494        let response = reqwest::get(format!("http://{address}/")).await.unwrap();
495        let error = super::ZenodoError::from_response(response).await;
496
497        match error {
498            super::ZenodoError::Http {
499                status,
500                message,
501                raw_body,
502                ..
503            } => {
504                assert_eq!(status, StatusCode::IM_A_TEAPOT);
505                assert_eq!(message.as_deref(), Some("brew failed"));
506                assert_eq!(raw_body.as_deref(), Some("brew failed"));
507            }
508            other => panic!("unexpected error: {other:?}"),
509        }
510    }
511}