hackerone-api 0.2.0

Unofficial, dependency-light Rust client for the HackerOne API (v1): submit reports, read your reports, hacktivity, balance, and earnings.
Documentation
//! Error types for the HackerOne API client.

use serde::Deserialize;

/// A single entry from the HackerOne API's `errors` array.
///
/// API failures come back as `{"errors":[{"title": …, "detail": …, "status": …}]}`.
#[derive(Debug, Clone, Deserialize, Default)]
pub struct ApiError {
    /// Short summary of the problem.
    #[serde(default)]
    pub title: Option<String>,
    /// Human-readable explanation.
    #[serde(default)]
    pub detail: Option<String>,
    /// HTTP status as a string, e.g. `"404"`.
    #[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,
        }
    }
}

/// Everything that can go wrong talking to the HackerOne API.
#[derive(Debug, thiserror::Error)]
pub enum Error {
    /// The request never produced an HTTP response (DNS, TLS, timeout, …).
    #[error("transport error: {0}")]
    Transport(String),

    /// The response body was not the JSON we expected.
    #[error("decode error: {0}")]
    Decode(String),

    /// The caller built an invalid request (bad input, serialization failure).
    #[error("invalid request: {0}")]
    Invalid(String),

    /// The API returned a non-2xx status.
    #[error("HackerOne API error (HTTP {status}): {detail}")]
    Api {
        /// HTTP status code.
        status: u16,
        /// Best-effort human-readable detail, flattened from `errors`.
        detail: String,
        /// The raw `errors` array, if the body carried one.
        errors: Vec<ApiError>,
    },
}

impl Error {
    /// The HTTP status, if this was an `Api` error.
    pub fn status(&self) -> Option<u16> {
        match self {
            Error::Api { status, .. } => Some(*status),
            _ => None,
        }
    }

    /// The structured `errors` array, if present.
    pub fn api_errors(&self) -> &[ApiError] {
        match self {
            Error::Api { errors, .. } => errors,
            _ => &[],
        }
    }

    /// The human-readable detail for an `Api` error, if any.
    pub fn detail(&self) -> Option<&str> {
        match self {
            Error::Api { detail, .. } => Some(detail.as_str()),
            _ => None,
        }
    }

    /// Whether this is an HTTP 4xx from the API (bad request, auth, validation).
    ///
    /// Submission failures are the common case: `401` (bad token), `403`
    /// (not allowed / not in scope) and `422` (validation) all surface here as
    /// [`Error::Api`] with the server's `errors` array attached.
    pub fn is_client_error(&self) -> bool {
        matches!(self.status(), Some(s) if (400..500).contains(&s))
    }

    /// Whether this is an HTTP 5xx from the API.
    pub fn is_server_error(&self) -> bool {
        matches!(self.status(), Some(s) if (500..600).contains(&s))
    }
}

/// Convenience alias.
pub type Result<T> = std::result::Result<T, Error>;

/// Parse a non-2xx [`Response`](crate::Response) into [`Error::Api`].
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,
    }
}