1use serde::Deserialize;
4
5#[derive(Debug, Clone, Deserialize, Default)]
9pub struct ApiError {
10 #[serde(default)]
12 pub title: Option<String>,
13 #[serde(default)]
15 pub detail: Option<String>,
16 #[serde(default)]
18 pub status: Option<String>,
19}
20
21impl ApiError {
22 fn message(&self) -> Option<String> {
23 match (&self.title, &self.detail) {
24 (Some(t), Some(d)) if t != d => Some(format!("{t}: {d}")),
25 (Some(t), _) => Some(t.clone()),
26 (_, Some(d)) => Some(d.clone()),
27 _ => None,
28 }
29 }
30}
31
32#[derive(Debug, thiserror::Error)]
34pub enum Error {
35 #[error("transport error: {0}")]
37 Transport(String),
38
39 #[error("decode error: {0}")]
41 Decode(String),
42
43 #[error("invalid request: {0}")]
45 Invalid(String),
46
47 #[error("HackerOne API error (HTTP {status}): {detail}")]
49 Api {
50 status: u16,
52 detail: String,
54 errors: Vec<ApiError>,
56 },
57}
58
59impl Error {
60 pub fn status(&self) -> Option<u16> {
62 match self {
63 Error::Api { status, .. } => Some(*status),
64 _ => None,
65 }
66 }
67
68 pub fn api_errors(&self) -> &[ApiError] {
70 match self {
71 Error::Api { errors, .. } => errors,
72 _ => &[],
73 }
74 }
75
76 pub fn detail(&self) -> Option<&str> {
78 match self {
79 Error::Api { detail, .. } => Some(detail.as_str()),
80 _ => None,
81 }
82 }
83
84 pub fn is_client_error(&self) -> bool {
90 matches!(self.status(), Some(s) if (400..500).contains(&s))
91 }
92
93 pub fn is_server_error(&self) -> bool {
95 matches!(self.status(), Some(s) if (500..600).contains(&s))
96 }
97}
98
99pub type Result<T> = std::result::Result<T, Error>;
101
102pub(crate) fn from_response(response: &crate::Response) -> Error {
104 let value = response.json().unwrap_or(serde_json::Value::Null);
105 let errors: Vec<ApiError> = value
106 .get("errors")
107 .cloned()
108 .and_then(|v| serde_json::from_value(v).ok())
109 .unwrap_or_default();
110
111 let detail = errors
112 .iter()
113 .filter_map(ApiError::message)
114 .collect::<Vec<_>>()
115 .join("; ");
116
117 let detail = if detail.is_empty() {
118 let text = response.text();
119 if text.trim().is_empty() {
120 "no response body".to_string()
121 } else {
122 text
123 }
124 } else {
125 detail
126 };
127
128 Error::Api {
129 status: response.status,
130 detail,
131 errors,
132 }
133}