Skip to main content

hackerone_api/
error.rs

1//! Error types for the HackerOne API client.
2
3use serde::Deserialize;
4
5/// A single entry from the HackerOne API's `errors` array.
6///
7/// API failures come back as `{"errors":[{"title": …, "detail": …, "status": …}]}`.
8#[derive(Debug, Clone, Deserialize, Default)]
9pub struct ApiError {
10    /// Short summary of the problem.
11    #[serde(default)]
12    pub title: Option<String>,
13    /// Human-readable explanation.
14    #[serde(default)]
15    pub detail: Option<String>,
16    /// HTTP status as a string, e.g. `"404"`.
17    #[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/// Everything that can go wrong talking to the HackerOne API.
33#[derive(Debug, thiserror::Error)]
34pub enum Error {
35    /// The request never produced an HTTP response (DNS, TLS, timeout, …).
36    #[error("transport error: {0}")]
37    Transport(String),
38
39    /// The response body was not the JSON we expected.
40    #[error("decode error: {0}")]
41    Decode(String),
42
43    /// The caller built an invalid request (bad input, serialization failure).
44    #[error("invalid request: {0}")]
45    Invalid(String),
46
47    /// The API returned a non-2xx status.
48    #[error("HackerOne API error (HTTP {status}): {detail}")]
49    Api {
50        /// HTTP status code.
51        status: u16,
52        /// Best-effort human-readable detail, flattened from `errors`.
53        detail: String,
54        /// The raw `errors` array, if the body carried one.
55        errors: Vec<ApiError>,
56    },
57}
58
59impl Error {
60    /// The HTTP status, if this was an `Api` error.
61    pub fn status(&self) -> Option<u16> {
62        match self {
63            Error::Api { status, .. } => Some(*status),
64            _ => None,
65        }
66    }
67
68    /// The structured `errors` array, if present.
69    pub fn api_errors(&self) -> &[ApiError] {
70        match self {
71            Error::Api { errors, .. } => errors,
72            _ => &[],
73        }
74    }
75
76    /// The human-readable detail for an `Api` error, if any.
77    pub fn detail(&self) -> Option<&str> {
78        match self {
79            Error::Api { detail, .. } => Some(detail.as_str()),
80            _ => None,
81        }
82    }
83
84    /// Whether this is an HTTP 4xx from the API (bad request, auth, validation).
85    ///
86    /// Submission failures are the common case: `401` (bad token), `403`
87    /// (not allowed / not in scope) and `422` (validation) all surface here as
88    /// [`Error::Api`] with the server's `errors` array attached.
89    pub fn is_client_error(&self) -> bool {
90        matches!(self.status(), Some(s) if (400..500).contains(&s))
91    }
92
93    /// Whether this is an HTTP 5xx from the API.
94    pub fn is_server_error(&self) -> bool {
95        matches!(self.status(), Some(s) if (500..600).contains(&s))
96    }
97}
98
99/// Convenience alias.
100pub type Result<T> = std::result::Result<T, Error>;
101
102/// Parse a non-2xx [`Response`](crate::Response) into [`Error::Api`].
103pub(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}