1use reqwest::{Response, StatusCode};
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6use thiserror::Error;
7
8#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
10pub struct FieldError {
11 #[serde(default)]
13 pub field: Option<String>,
14 pub message: String,
16}
17
18#[derive(Debug, Error)]
20pub enum FigshareError {
21 #[error("Figshare returned HTTP {status}: {message:?}")]
23 Http {
24 status: StatusCode,
26 message: Option<String>,
28 code: Option<String>,
30 field_errors: Vec<FieldError>,
32 raw_body: Option<String>,
34 },
35 #[error(transparent)]
37 Transport(
38 #[from]
40 reqwest::Error,
41 ),
42 #[error(transparent)]
44 Json(
45 #[from]
47 serde_json::Error,
48 ),
49 #[error(transparent)]
51 Io(
52 #[from]
54 std::io::Error,
55 ),
56 #[error(transparent)]
58 Url(
59 #[from]
61 url::ParseError,
62 ),
63 #[error("failed to read environment variable {name}: {source}")]
65 EnvVar {
66 name: String,
68 #[source]
70 source: std::env::VarError,
71 },
72 #[error("authentication required for {0}")]
74 MissingAuth(
75 &'static str,
77 ),
78 #[error("invalid Figshare state: {0}")]
80 InvalidState(
81 String,
83 ),
84 #[error("missing Figshare link: {0}")]
86 MissingLink(
87 &'static str,
89 ),
90 #[error("missing article file: {name}")]
92 MissingFile {
93 name: String,
95 },
96 #[error("duplicate upload filename: {filename}")]
98 DuplicateUploadFilename {
99 filename: String,
101 },
102 #[error("article already contains file and replacement policy forbids overwrite: {filename}")]
104 ConflictingDraftFile {
105 filename: String,
107 },
108 #[error("unsupported selector: {0}")]
110 UnsupportedSelector(
111 String,
113 ),
114 #[error("timed out waiting for Figshare {0}")]
116 Timeout(
117 &'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}