1use client_uploader_traits::UploadNameValidationError;
9use reqwest::{Response, StatusCode};
10use serde::{Deserialize, Serialize};
11use serde_json::Value;
12use thiserror::Error;
13
14#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
16pub struct FieldError {
17 #[serde(default)]
19 pub field: Option<String>,
20 pub message: String,
22}
23
24#[derive(Debug, Error)]
26pub enum ZenodoError {
27 #[error("Zenodo returned HTTP {status}: {message:?}")]
29 Http {
30 status: StatusCode,
32 message: Option<String>,
34 field_errors: Vec<FieldError>,
36 raw_body: Option<String>,
38 },
39 #[error(transparent)]
41 Transport(
42 #[from]
44 reqwest::Error,
45 ),
46 #[error(transparent)]
48 Json(
49 #[from]
51 serde_json::Error,
52 ),
53 #[error(transparent)]
55 Io(
56 #[from]
58 std::io::Error,
59 ),
60 #[error(transparent)]
62 Url(
63 #[from]
65 url::ParseError,
66 ),
67 #[error("failed to read environment variable {name}: {source}")]
69 EnvVar {
70 name: String,
72 #[source]
74 source: std::env::VarError,
75 },
76 #[error("invalid Zenodo state: {0}")]
78 InvalidState(
79 String,
81 ),
82 #[error("missing Zenodo link: {0}")]
84 MissingLink(
85 &'static str,
87 ),
88 #[error("missing record file: {key}")]
90 MissingFile {
91 key: String,
93 },
94 #[error("duplicate upload filename: {filename}")]
96 DuplicateUploadFilename {
97 filename: String,
99 },
100 #[error("draft already contains file and replacement policy forbids overwrite: {filename}")]
102 ConflictingDraftFile {
103 filename: String,
105 },
106 #[error("unsupported selector: {0}")]
108 UnsupportedSelector(
109 String,
111 ),
112 #[error("checksum mismatch: expected {expected}, got {actual}")]
114 ChecksumMismatch {
115 expected: String,
117 actual: String,
119 },
120 #[error("timed out waiting for Zenodo {0}")]
122 Timeout(
123 &'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}