Skip to main content

figshare_rs/
error.rs

1//! Error types and HTTP error decoding for Figshare responses.
2
3use reqwest::{Response, StatusCode};
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6use thiserror::Error;
7
8/// Field-specific validation error returned by Figshare.
9#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
10pub struct FieldError {
11    /// The field name, when Figshare reports one.
12    #[serde(default)]
13    pub field: Option<String>,
14    /// Human-readable error message for the field.
15    pub message: String,
16}
17
18/// Errors produced by the Figshare client.
19#[derive(Debug, Error)]
20pub enum FigshareError {
21    /// Figshare returned a non-success HTTP status.
22    #[error("Figshare returned HTTP {status}: {message:?}")]
23    Http {
24        /// HTTP status returned by Figshare.
25        status: StatusCode,
26        /// Summary message extracted from the response body, when available.
27        message: Option<String>,
28        /// Machine-readable error code returned by Figshare, when available.
29        code: Option<String>,
30        /// Field-level validation errors extracted from the response body.
31        field_errors: Vec<FieldError>,
32        /// Trimmed raw response body for diagnostics.
33        raw_body: Option<String>,
34    },
35    /// A transport error occurred while sending or receiving a request.
36    #[error(transparent)]
37    Transport(
38        /// Underlying transport error.
39        #[from]
40        reqwest::Error,
41    ),
42    /// JSON serialization or deserialization failed.
43    #[error(transparent)]
44    Json(
45        /// Underlying JSON error.
46        #[from]
47        serde_json::Error,
48    ),
49    /// A local I/O operation failed.
50    #[error(transparent)]
51    Io(
52        /// Underlying I/O error.
53        #[from]
54        std::io::Error,
55    ),
56    /// A URL could not be parsed or joined.
57    #[error(transparent)]
58    Url(
59        /// Underlying URL parse error.
60        #[from]
61        url::ParseError,
62    ),
63    /// A required environment variable could not be read.
64    #[error("failed to read environment variable {name}: {source}")]
65    EnvVar {
66        /// Environment variable name.
67        name: String,
68        /// Underlying environment lookup error.
69        #[source]
70        source: std::env::VarError,
71    },
72    /// Authentication was required for a private operation.
73    #[error("authentication required for {0}")]
74    MissingAuth(
75        /// Description of the private operation.
76        &'static str,
77    ),
78    /// Figshare returned data that violates a workflow invariant.
79    #[error("invalid Figshare state: {0}")]
80    InvalidState(
81        /// Description of the invalid state.
82        String,
83    ),
84    /// A required link relation was missing from a Figshare payload.
85    #[error("missing Figshare link: {0}")]
86    MissingLink(
87        /// Missing link relation name.
88        &'static str,
89    ),
90    /// A requested file name was not present on an article.
91    #[error("missing article file: {name}")]
92    MissingFile {
93        /// Missing article file name.
94        name: String,
95    },
96    /// Multiple uploads targeted the same final filename.
97    #[error("duplicate upload filename: {filename}")]
98    DuplicateUploadFilename {
99        /// Duplicate filename seen in the upload set.
100        filename: String,
101    },
102    /// A keep-existing upload would overwrite an existing draft file.
103    #[error("article already contains file and replacement policy forbids overwrite: {filename}")]
104    ConflictingDraftFile {
105        /// Conflicting filename already present on the article.
106        filename: String,
107    },
108    /// A selector could not be resolved to an article.
109    #[error("unsupported selector: {0}")]
110    UnsupportedSelector(
111        /// Description of the unsupported selector.
112        String,
113    ),
114    /// Polling timed out before Figshare reached the requested state.
115    #[error("timed out waiting for Figshare {0}")]
116    Timeout(
117        /// Label for the operation that timed out.
118        &'static str,
119    ),
120}
121
122impl FigshareError {
123    pub(crate) async fn from_response(response: Response) -> Self {
124        let status = response.status();
125        let content_type = response
126            .headers()
127            .get(reqwest::header::CONTENT_TYPE)
128            .and_then(|value| value.to_str().ok())
129            .map(str::to_owned);
130
131        let body = match response.bytes().await {
132            Ok(body) => body,
133            Err(error) => return Self::Transport(error),
134        };
135
136        decode_http_error(status, content_type.as_deref(), &body)
137    }
138}
139
140impl From<client_uploader_traits::UploadNameValidationError> for FigshareError {
141    fn from(error: client_uploader_traits::UploadNameValidationError) -> Self {
142        match error {
143            client_uploader_traits::UploadNameValidationError::EmptyFilename => {
144                Self::InvalidState("upload filename cannot be empty".into())
145            }
146            client_uploader_traits::UploadNameValidationError::DuplicateFilename { filename } => {
147                Self::DuplicateUploadFilename { filename }
148            }
149        }
150    }
151}
152
153pub(crate) fn decode_http_error(
154    status: StatusCode,
155    content_type: Option<&str>,
156    body: &[u8],
157) -> FigshareError {
158    let raw_body = trimmed_body(body);
159    let parsed = if looks_like_json(content_type, body) {
160        parse_json_error(body)
161    } else {
162        None
163    };
164
165    let (message, code, field_errors) = match parsed {
166        Some((message, code, field_errors)) => (message, code, field_errors),
167        None => (raw_body.clone(), None, Vec::new()),
168    };
169
170    FigshareError::Http {
171        status,
172        message,
173        code,
174        field_errors,
175        raw_body,
176    }
177}
178
179fn looks_like_json(content_type: Option<&str>, body: &[u8]) -> bool {
180    if content_type
181        .is_some_and(|value| value.starts_with("application/json") || value.ends_with("+json"))
182    {
183        return true;
184    }
185
186    body.iter()
187        .find(|byte| !byte.is_ascii_whitespace())
188        .is_some_and(|byte| matches!(byte, b'{' | b'['))
189}
190
191fn parse_json_error(body: &[u8]) -> Option<(Option<String>, Option<String>, Vec<FieldError>)> {
192    let value: Value = serde_json::from_slice(body).ok()?;
193    let message = value
194        .get("message")
195        .and_then(Value::as_str)
196        .map(str::to_owned);
197    let code = value.get("code").and_then(Value::as_str).map(str::to_owned);
198    let field_errors = value
199        .get("errors")
200        .and_then(parse_field_errors)
201        .or_else(|| value.get("data").and_then(parse_field_errors))
202        .unwrap_or_default();
203
204    Some((message, code, field_errors))
205}
206
207fn parse_field_errors(value: &Value) -> Option<Vec<FieldError>> {
208    match value {
209        Value::Array(items) => {
210            let mut errors = Vec::new();
211            for item in items {
212                match item {
213                    Value::Object(map) => {
214                        let message = map
215                            .get("message")
216                            .and_then(Value::as_str)
217                            .map(str::to_owned)
218                            .or_else(|| {
219                                map.get("detail").and_then(Value::as_str).map(str::to_owned)
220                            })
221                            .unwrap_or_else(|| "unknown error".to_owned());
222                        errors.push(FieldError {
223                            field: map.get("field").and_then(Value::as_str).map(str::to_owned),
224                            message,
225                        });
226                    }
227                    Value::String(message) => errors.push(FieldError {
228                        field: None,
229                        message: message.clone(),
230                    }),
231                    _ => {}
232                }
233            }
234            Some(errors)
235        }
236        Value::Object(map) => {
237            let mut errors = Vec::new();
238            for (field, message) in map {
239                let message = if let Some(message) = message.as_str() {
240                    message.to_owned()
241                } else {
242                    message.to_string()
243                };
244                errors.push(FieldError {
245                    field: Some(field.clone()),
246                    message,
247                });
248            }
249            Some(errors)
250        }
251        _ => None,
252    }
253}
254
255fn trimmed_body(body: &[u8]) -> Option<String> {
256    let text = String::from_utf8_lossy(body);
257    for line in text.lines().map(str::trim) {
258        if !line.is_empty() {
259            return Some(line.chars().take(512).collect());
260        }
261    }
262
263    None
264}
265
266#[cfg(test)]
267mod tests {
268    use super::{decode_http_error, parse_field_errors, parse_json_error, trimmed_body};
269    use reqwest::StatusCode;
270    use serde_json::json;
271
272    #[test]
273    fn parses_json_error_bodies() {
274        let error = decode_http_error(
275            StatusCode::BAD_REQUEST,
276            Some("application/json"),
277            br#"{"message":"bad metadata","code":"ValidationFailed","data":{"title":"required"}}"#,
278        );
279
280        match error {
281            super::FigshareError::Http {
282                message,
283                code,
284                field_errors,
285                ..
286            } => {
287                assert_eq!(message.as_deref(), Some("bad metadata"));
288                assert_eq!(code.as_deref(), Some("ValidationFailed"));
289                assert_eq!(field_errors.len(), 1);
290                assert_eq!(field_errors[0].field.as_deref(), Some("title"));
291            }
292            other => panic!("unexpected error: {other:?}"),
293        }
294    }
295
296    #[test]
297    fn parses_plaintext_error_bodies() {
298        let error = decode_http_error(
299            StatusCode::INTERNAL_SERVER_ERROR,
300            Some("text/plain"),
301            b"upstream exploded\nstack trace omitted",
302        );
303
304        match error {
305            super::FigshareError::Http { message, .. } => {
306                assert_eq!(message.as_deref(), Some("upstream exploded"));
307            }
308            other => panic!("unexpected error: {other:?}"),
309        }
310    }
311
312    #[test]
313    fn parses_mixed_error_shapes() {
314        let parsed =
315            parse_json_error(br#"{"message":"bad","errors":["first",{"field":"x"}]}"#).unwrap();
316        assert_eq!(parsed.0.as_deref(), Some("bad"));
317        assert_eq!(parsed.2.len(), 2);
318
319        let object_errors = parse_field_errors(&json!({
320            "metadata.title": { "detail": "required" }
321        }))
322        .unwrap();
323        assert_eq!(object_errors[0].field.as_deref(), Some("metadata.title"));
324        assert_eq!(object_errors[0].message, r#"{"detail":"required"}"#);
325    }
326
327    #[test]
328    fn parses_non_json_and_empty_bodies() {
329        let malformed = decode_http_error(
330            StatusCode::BAD_REQUEST,
331            Some("application/json"),
332            br#"{"broken":"json""#,
333        );
334        match malformed {
335            super::FigshareError::Http {
336                message, raw_body, ..
337            } => {
338                assert_eq!(message.as_deref(), Some(r#"{"broken":"json""#));
339                assert_eq!(raw_body.as_deref(), Some(r#"{"broken":"json""#));
340            }
341            other => panic!("unexpected error: {other:?}"),
342        }
343
344        let empty = decode_http_error(StatusCode::BAD_GATEWAY, Some("text/plain"), b"   ");
345        match empty {
346            super::FigshareError::Http {
347                message, raw_body, ..
348            } => {
349                assert_eq!(message, None);
350                assert_eq!(raw_body, None);
351            }
352            other => panic!("unexpected error: {other:?}"),
353        }
354    }
355
356    #[test]
357    fn trimmed_body_keeps_first_non_empty_line() {
358        assert_eq!(
359            trimmed_body(b"   \n  first line  \nsecond line"),
360            Some("first line".into())
361        );
362    }
363
364    #[tokio::test]
365    async fn from_response_decodes_reqwest_response() {
366        use tokio::io::{AsyncReadExt, AsyncWriteExt};
367        use tokio::net::TcpListener;
368
369        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
370        let address = listener.local_addr().unwrap();
371
372        tokio::spawn(async move {
373            let (mut stream, _) = listener.accept().await.unwrap();
374            let mut buffer = [0_u8; 1024];
375            let _ = stream.read(&mut buffer).await;
376            let body = br#"{"message":"bad","code":"BadThing","data":{"field":"problem"}}"#;
377            let response = format!(
378                "HTTP/1.1 422 Unprocessable Entity\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n",
379                body.len()
380            );
381            let _ = stream.write_all(response.as_bytes()).await;
382            let _ = stream.write_all(body).await;
383            let _ = stream.write_all(b"\r\n").await;
384            let _ = stream.shutdown().await;
385        });
386
387        let response = reqwest::get(format!("http://{address}/")).await.unwrap();
388        let error = super::FigshareError::from_response(response).await;
389
390        match error {
391            super::FigshareError::Http {
392                status,
393                message,
394                code,
395                field_errors,
396                ..
397            } => {
398                assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
399                assert_eq!(message.as_deref(), Some("bad"));
400                assert_eq!(code.as_deref(), Some("BadThing"));
401                assert_eq!(field_errors[0].field.as_deref(), Some("field"));
402            }
403            other => panic!("unexpected error: {other:?}"),
404        }
405    }
406}