use serde::Deserialize;
#[derive(Debug, Clone, Deserialize, Default)]
pub struct ApiError {
#[serde(default)]
pub title: Option<String>,
#[serde(default)]
pub detail: Option<String>,
#[serde(default)]
pub status: Option<String>,
}
impl ApiError {
fn message(&self) -> Option<String> {
match (&self.title, &self.detail) {
(Some(t), Some(d)) if t != d => Some(format!("{t}: {d}")),
(Some(t), _) => Some(t.clone()),
(_, Some(d)) => Some(d.clone()),
_ => None,
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("transport error: {0}")]
Transport(String),
#[error("decode error: {0}")]
Decode(String),
#[error("invalid request: {0}")]
Invalid(String),
#[error("HackerOne API error (HTTP {status}): {detail}")]
Api {
status: u16,
detail: String,
errors: Vec<ApiError>,
},
}
impl Error {
pub fn status(&self) -> Option<u16> {
match self {
Error::Api { status, .. } => Some(*status),
_ => None,
}
}
pub fn api_errors(&self) -> &[ApiError] {
match self {
Error::Api { errors, .. } => errors,
_ => &[],
}
}
pub fn detail(&self) -> Option<&str> {
match self {
Error::Api { detail, .. } => Some(detail.as_str()),
_ => None,
}
}
pub fn is_client_error(&self) -> bool {
matches!(self.status(), Some(s) if (400..500).contains(&s))
}
pub fn is_server_error(&self) -> bool {
matches!(self.status(), Some(s) if (500..600).contains(&s))
}
}
pub type Result<T> = std::result::Result<T, Error>;
pub(crate) fn from_response(response: &crate::Response) -> Error {
let value = response.json().unwrap_or(serde_json::Value::Null);
let errors: Vec<ApiError> = value
.get("errors")
.cloned()
.and_then(|v| serde_json::from_value(v).ok())
.unwrap_or_default();
let detail = errors
.iter()
.filter_map(ApiError::message)
.collect::<Vec<_>>()
.join("; ");
let detail = if detail.is_empty() {
let text = response.text();
if text.trim().is_empty() {
"no response body".to_string()
} else {
text
}
} else {
detail
};
Error::Api {
status: response.status,
detail,
errors,
}
}