use reqwest::{Response, StatusCode};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use thiserror::Error;
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct FieldError {
#[serde(default)]
pub field: Option<String>,
pub message: String,
}
#[derive(Debug, Error)]
pub enum FigshareError {
#[error("Figshare returned HTTP {status}: {message:?}")]
Http {
status: StatusCode,
message: Option<String>,
code: Option<String>,
field_errors: Vec<FieldError>,
raw_body: Option<String>,
},
#[error(transparent)]
Transport(
#[from]
reqwest::Error,
),
#[error(transparent)]
Json(
#[from]
serde_json::Error,
),
#[error(transparent)]
Io(
#[from]
std::io::Error,
),
#[error(transparent)]
Url(
#[from]
url::ParseError,
),
#[error("failed to read environment variable {name}: {source}")]
EnvVar {
name: String,
#[source]
source: std::env::VarError,
},
#[error("authentication required for {0}")]
MissingAuth(
&'static str,
),
#[error("invalid Figshare state: {0}")]
InvalidState(
String,
),
#[error("missing Figshare link: {0}")]
MissingLink(
&'static str,
),
#[error("missing article file: {name}")]
MissingFile {
name: String,
},
#[error("duplicate upload filename: {filename}")]
DuplicateUploadFilename {
filename: String,
},
#[error("article already contains file and replacement policy forbids overwrite: {filename}")]
ConflictingDraftFile {
filename: String,
},
#[error("unsupported selector: {0}")]
UnsupportedSelector(
String,
),
#[error("timed out waiting for Figshare {0}")]
Timeout(
&'static str,
),
}
impl FigshareError {
pub(crate) async fn from_response(response: Response) -> Self {
let status = response.status();
let content_type = response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.map(str::to_owned);
let body = match response.bytes().await {
Ok(body) => body,
Err(error) => return Self::Transport(error),
};
decode_http_error(status, content_type.as_deref(), &body)
}
}
impl From<client_uploader_traits::UploadNameValidationError> for FigshareError {
fn from(error: client_uploader_traits::UploadNameValidationError) -> Self {
match error {
client_uploader_traits::UploadNameValidationError::EmptyFilename => {
Self::InvalidState("upload filename cannot be empty".into())
}
client_uploader_traits::UploadNameValidationError::DuplicateFilename { filename } => {
Self::DuplicateUploadFilename { filename }
}
}
}
}
pub(crate) fn decode_http_error(
status: StatusCode,
content_type: Option<&str>,
body: &[u8],
) -> FigshareError {
let raw_body = trimmed_body(body);
let parsed = if looks_like_json(content_type, body) {
parse_json_error(body)
} else {
None
};
let (message, code, field_errors) = match parsed {
Some((message, code, field_errors)) => (message, code, field_errors),
None => (raw_body.clone(), None, Vec::new()),
};
FigshareError::Http {
status,
message,
code,
field_errors,
raw_body,
}
}
fn looks_like_json(content_type: Option<&str>, body: &[u8]) -> bool {
if content_type
.is_some_and(|value| value.starts_with("application/json") || value.ends_with("+json"))
{
return true;
}
body.iter()
.find(|byte| !byte.is_ascii_whitespace())
.is_some_and(|byte| matches!(byte, b'{' | b'['))
}
fn parse_json_error(body: &[u8]) -> Option<(Option<String>, Option<String>, Vec<FieldError>)> {
let value: Value = serde_json::from_slice(body).ok()?;
let message = value
.get("message")
.and_then(Value::as_str)
.map(str::to_owned);
let code = value.get("code").and_then(Value::as_str).map(str::to_owned);
let field_errors = value
.get("errors")
.and_then(parse_field_errors)
.or_else(|| value.get("data").and_then(parse_field_errors))
.unwrap_or_default();
Some((message, code, field_errors))
}
fn parse_field_errors(value: &Value) -> Option<Vec<FieldError>> {
match value {
Value::Array(items) => {
let mut errors = Vec::new();
for item in items {
match item {
Value::Object(map) => {
let message = map
.get("message")
.and_then(Value::as_str)
.map(str::to_owned)
.or_else(|| {
map.get("detail").and_then(Value::as_str).map(str::to_owned)
})
.unwrap_or_else(|| "unknown error".to_owned());
errors.push(FieldError {
field: map.get("field").and_then(Value::as_str).map(str::to_owned),
message,
});
}
Value::String(message) => errors.push(FieldError {
field: None,
message: message.clone(),
}),
_ => {}
}
}
Some(errors)
}
Value::Object(map) => {
let mut errors = Vec::new();
for (field, message) in map {
let message = if let Some(message) = message.as_str() {
message.to_owned()
} else {
message.to_string()
};
errors.push(FieldError {
field: Some(field.clone()),
message,
});
}
Some(errors)
}
_ => None,
}
}
fn trimmed_body(body: &[u8]) -> Option<String> {
let text = String::from_utf8_lossy(body);
for line in text.lines().map(str::trim) {
if !line.is_empty() {
return Some(line.chars().take(512).collect());
}
}
None
}
#[cfg(test)]
mod tests {
use super::{decode_http_error, parse_field_errors, parse_json_error, trimmed_body};
use reqwest::StatusCode;
use serde_json::json;
#[test]
fn parses_json_error_bodies() {
let error = decode_http_error(
StatusCode::BAD_REQUEST,
Some("application/json"),
br#"{"message":"bad metadata","code":"ValidationFailed","data":{"title":"required"}}"#,
);
match error {
super::FigshareError::Http {
message,
code,
field_errors,
..
} => {
assert_eq!(message.as_deref(), Some("bad metadata"));
assert_eq!(code.as_deref(), Some("ValidationFailed"));
assert_eq!(field_errors.len(), 1);
assert_eq!(field_errors[0].field.as_deref(), Some("title"));
}
other => panic!("unexpected error: {other:?}"),
}
}
#[test]
fn parses_plaintext_error_bodies() {
let error = decode_http_error(
StatusCode::INTERNAL_SERVER_ERROR,
Some("text/plain"),
b"upstream exploded\nstack trace omitted",
);
match error {
super::FigshareError::Http { message, .. } => {
assert_eq!(message.as_deref(), Some("upstream exploded"));
}
other => panic!("unexpected error: {other:?}"),
}
}
#[test]
fn parses_mixed_error_shapes() {
let parsed =
parse_json_error(br#"{"message":"bad","errors":["first",{"field":"x"}]}"#).unwrap();
assert_eq!(parsed.0.as_deref(), Some("bad"));
assert_eq!(parsed.2.len(), 2);
let object_errors = parse_field_errors(&json!({
"metadata.title": { "detail": "required" }
}))
.unwrap();
assert_eq!(object_errors[0].field.as_deref(), Some("metadata.title"));
assert_eq!(object_errors[0].message, r#"{"detail":"required"}"#);
}
#[test]
fn parses_non_json_and_empty_bodies() {
let malformed = decode_http_error(
StatusCode::BAD_REQUEST,
Some("application/json"),
br#"{"broken":"json""#,
);
match malformed {
super::FigshareError::Http {
message, raw_body, ..
} => {
assert_eq!(message.as_deref(), Some(r#"{"broken":"json""#));
assert_eq!(raw_body.as_deref(), Some(r#"{"broken":"json""#));
}
other => panic!("unexpected error: {other:?}"),
}
let empty = decode_http_error(StatusCode::BAD_GATEWAY, Some("text/plain"), b" ");
match empty {
super::FigshareError::Http {
message, raw_body, ..
} => {
assert_eq!(message, None);
assert_eq!(raw_body, None);
}
other => panic!("unexpected error: {other:?}"),
}
}
#[test]
fn trimmed_body_keeps_first_non_empty_line() {
assert_eq!(
trimmed_body(b" \n first line \nsecond line"),
Some("first line".into())
);
}
#[tokio::test]
async fn from_response_decodes_reqwest_response() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
let mut buffer = [0_u8; 1024];
let _ = stream.read(&mut buffer).await;
let body = br#"{"message":"bad","code":"BadThing","data":{"field":"problem"}}"#;
let response = format!(
"HTTP/1.1 422 Unprocessable Entity\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n",
body.len()
);
let _ = stream.write_all(response.as_bytes()).await;
let _ = stream.write_all(body).await;
let _ = stream.write_all(b"\r\n").await;
let _ = stream.shutdown().await;
});
let response = reqwest::get(format!("http://{address}/")).await.unwrap();
let error = super::FigshareError::from_response(response).await;
match error {
super::FigshareError::Http {
status,
message,
code,
field_errors,
..
} => {
assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
assert_eq!(message.as_deref(), Some("bad"));
assert_eq!(code.as_deref(), Some("BadThing"));
assert_eq!(field_errors[0].field.as_deref(), Some("field"));
}
other => panic!("unexpected error: {other:?}"),
}
}
}