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
//! The API client: auth, request building, pagination, and endpoints.

use serde::de::DeserializeOwned;

use crate::error::{from_response, Error, Result};
use crate::transport::{Method, Request, Transport, UreqTransport};
use crate::types::{
    CollectionDoc, CreateHackerReport, DataDoc, Earning, Hacktivity, HacktivityQuery, Page,
    PageQuery, Report, ReportQuery, ReportState, Resource, SingleDoc, StructuredScope, User,
    Weakness,
};

/// Default API root.
pub const DEFAULT_BASE_URL: &str = "https://api.hackerone.com";

/// Basic-auth credentials: API token *identifier* + token *value*.
#[derive(Debug, Clone)]
struct Auth {
    identifier: String,
    token: String,
}

impl Auth {
    fn header_value(&self) -> String {
        use base64::Engine as _;
        let raw = format!("{}:{}", self.identifier, self.token);
        format!(
            "Basic {}",
            base64::engine::general_purpose::STANDARD.encode(raw)
        )
    }
}

/// A HackerOne API client.
///
/// Generic over its [`Transport`] so it can be unit-tested with a mock and
/// embedded with a custom HTTP stack. The default transport is
/// [`UreqTransport`].
///
/// ```no_run
/// # fn main() -> Result<(), hackerone_api::Error> {
/// use hackerone_api::Client;
///
/// let client = Client::new("my-api-identifier", "my-api-token");
/// let me = client.me()?;
/// println!("{:?}", me.username);
/// # Ok(())
/// # }
/// ```
pub struct Client<T: Transport = UreqTransport> {
    base_url: String,
    auth: Option<Auth>,
    transport: T,
}

impl<T: Transport> Client<T> {
    /// Build a client over a custom transport (no credentials yet).
    pub fn with_transport(base_url: impl Into<String>, transport: T) -> Self {
        Self {
            base_url: base_url.into().trim_end_matches('/').to_string(),
            auth: None,
            transport,
        }
    }

    /// Attach HTTP Basic credentials (builder style).
    pub fn with_credentials(
        mut self,
        identifier: impl Into<String>,
        token: impl Into<String>,
    ) -> Self {
        self.auth = Some(Auth {
            identifier: identifier.into(),
            token: token.into(),
        });
        self
    }

    /// The configured base URL.
    pub fn base_url(&self) -> &str {
        &self.base_url
    }

    // ── internals ──────────────────────────────────────────────────────

    /// Build an absolute URL from a path or pass an absolute URL through.
    fn absolute(&self, path_or_url: &str) -> String {
        if path_or_url.starts_with("http://") || path_or_url.starts_with("https://") {
            path_or_url.to_string()
        } else if path_or_url.starts_with('/') {
            format!("{}{}", self.base_url, path_or_url)
        } else {
            format!("{}/{}", self.base_url, path_or_url)
        }
    }

    fn endpoint(&self, path: &str, query: &[(String, String)]) -> String {
        let mut url = self.absolute(path);
        if !query.is_empty() {
            let qs = query
                .iter()
                .map(|(k, v)| format!("{}={}", encode(k), encode(v)))
                .collect::<Vec<_>>()
                .join("&");
            url.push('?');
            url.push_str(&qs);
        }
        url
    }

    fn execute(
        &self,
        method: Method,
        path: &str,
        query: &[(String, String)],
        body: Option<&serde_json::Value>,
    ) -> Result<serde_json::Value> {
        let url = self.endpoint(path, query);
        let mut request = Request::new(method, url).header("Accept", "application/json");
        if let Some(auth) = &self.auth {
            request = request.header("Authorization", auth.header_value());
        }
        if let Some(value) = body {
            request = request
                .body_json(value)?
                .header("Content-Type", "application/json");
        }

        let response = self.transport.send(&request)?;
        if !(200..300).contains(&response.status) {
            return Err(from_response(&response));
        }
        response.json()
    }

    fn single<A: DeserializeOwned + Default>(
        &self,
        method: Method,
        path: &str,
        query: &[(String, String)],
        body: Option<&serde_json::Value>,
    ) -> Result<A> {
        let value = self.execute(method, path, query, body)?;
        let doc: SingleDoc<A> = serde_json::from_value(value)
            .map_err(|e| Error::Decode(format!("unexpected single-resource shape: {e}")))?;
        Ok(doc.data.attributes)
    }

    fn collection<A: DeserializeOwned + Default>(
        &self,
        method: Method,
        path: &str,
        query: &[(String, String)],
        body: Option<&serde_json::Value>,
    ) -> Result<Page<A>> {
        let value = self.execute(method, path, query, body)?;
        let doc: CollectionDoc<A> = serde_json::from_value(value)
            .map_err(|e| Error::Decode(format!("unexpected collection shape: {e}")))?;
        Ok(Page::from_doc(doc))
    }

    /// Decode a bare `{ "data": … }` envelope (no `id`/`type`/`attributes`).
    fn data_object<A: DeserializeOwned>(
        &self,
        method: Method,
        path: &str,
        query: &[(String, String)],
        body: Option<&serde_json::Value>,
    ) -> Result<A> {
        let value = self.execute(method, path, query, body)?;
        let doc: DataDoc<A> = serde_json::from_value(value)
            .map_err(|e| Error::Decode(format!("unexpected data-object shape: {e}")))?;
        Ok(doc.data)
    }

    // ── endpoints ──────────────────────────────────────────────────────

    /// `GET /v1/me` — the authenticated user.
    ///
    /// This is a **customer/profile** endpoint. A hacker-only API token
    /// receives `401` here; use [`Client::my_reports`] (which hits
    /// `/v1/hackers/me/reports`) to confirm a hacker token works.
    pub fn me(&self) -> Result<User> {
        self.single(Method::Get, "/v1/me", &[], None)
    }

    /// `GET /v1/me/programs` — programs the token can access.
    pub fn programs(&self) -> Result<Page<crate::types::Program>> {
        self.collection(Method::Get, "/v1/me/programs", &[], None)
    }

    /// `GET /v1/programs/{id}` — one program by handle or id.
    pub fn program(&self, id: &str) -> Result<crate::types::Program> {
        self.single(Method::Get, &format!("/v1/programs/{id}"), &[], None)
    }

    /// `GET /v1/programs/{id}/structured_scopes` — a program's scopes.
    pub fn structured_scopes(
        &self,
        program_id: &str,
        page: Option<(u32, u32)>,
    ) -> Result<Page<StructuredScope>> {
        let mut query = Vec::new();
        if let Some((number, size)) = page {
            query.push(("page[number]".to_string(), number.to_string()));
            query.push(("page[size]".to_string(), size.to_string()));
        }
        self.collection(
            Method::Get,
            &format!("/v1/programs/{program_id}/structured_scopes"),
            &query,
            None,
        )
    }

    /// `GET /v1/reports` — reports, filtered by `query`.
    pub fn reports(&self, query: &ReportQuery) -> Result<Page<Report>> {
        self.collection(Method::Get, "/v1/reports", &query.to_pairs(), None)
    }

    /// `GET /v1/reports/{id}` — one report.
    pub fn report(&self, id: &str) -> Result<Report> {
        self.single(Method::Get, &format!("/v1/reports/{id}"), &[], None)
    }

    /// `POST /v1/hackers/reports` — submit a report to a program as a hacker.
    ///
    /// This is the endpoint a researcher uses to *file* a report; it posts the
    /// [`CreateHackerReport`] body (`team_handle` + attributes) to the hacker
    /// surface and returns the created [`Report`].
    pub fn create_report(&self, report: &CreateHackerReport) -> Result<Report> {
        let body = report.to_json()?;
        self.single(Method::Post, "/v1/hackers/reports", &[], Some(&body))
    }

    /// `GET /v1/hackers/me/reports` — the authenticated hacker's own reports.
    pub fn my_reports(&self, query: &PageQuery) -> Result<Page<Report>> {
        self.collection(
            Method::Get,
            "/v1/hackers/me/reports",
            &query.to_pairs(),
            None,
        )
    }

    /// `GET /v1/hackers/reports/{id}` — one of the authenticated hacker's reports.
    pub fn my_report(&self, id: &str) -> Result<Report> {
        self.single(Method::Get, &format!("/v1/hackers/reports/{id}"), &[], None)
    }

    /// `GET /v1/hackers/hacktivity` — the public hacktivity feed.
    ///
    /// `query.query_string` is a Lucene filter, e.g.
    /// `severity_rating:critical AND disclosed:true`.
    pub fn hacktivity(&self, query: &HacktivityQuery) -> Result<Page<Hacktivity>> {
        self.collection(
            Method::Get,
            "/v1/hackers/hacktivity",
            &query.to_pairs(),
            None,
        )
    }

    /// `GET /v1/hackers/payments/balance` — the authenticated hacker's balance.
    pub fn balance(&self) -> Result<crate::types::Balance> {
        self.data_object(Method::Get, "/v1/hackers/payments/balance", &[], None)
    }

    /// `GET /v1/hackers/payments/earnings` — the authenticated hacker's earnings.
    pub fn earnings(&self, query: &PageQuery) -> Result<Page<Earning>> {
        self.collection(
            Method::Get,
            "/v1/hackers/payments/earnings",
            &query.to_pairs(),
            None,
        )
    }

    /// `POST /v1/reports/{id}/activities` — add a comment.
    pub fn add_comment(
        &self,
        report_id: &str,
        message: &str,
    ) -> Result<Resource<serde_json::Value>> {
        let body = serde_json::json!({
            "data": {
                "type": "activity-comment",
                "attributes": { "message": message },
            }
        });
        self.single(
            Method::Post,
            &format!("/v1/reports/{report_id}/activities"),
            &[],
            Some(&body),
        )
    }

    /// `POST /v1/reports/{id}/state_changes` — change a report's state.
    pub fn change_state(
        &self,
        report_id: &str,
        state: ReportState,
        message: Option<&str>,
    ) -> Result<Resource<serde_json::Value>> {
        let mut attributes = serde_json::Map::new();
        attributes.insert("state".into(), serde_json::json!(state.as_str()));
        if let Some(message) = message {
            attributes.insert("message".into(), serde_json::json!(message));
        }
        let body = serde_json::json!({
            "data": {
                "type": "state-change",
                "attributes": serde_json::Value::Object(attributes),
            }
        });
        self.single(
            Method::Post,
            &format!("/v1/reports/{report_id}/state_changes"),
            &[],
            Some(&body),
        )
    }

    /// `GET /v1/weaknesses` — the CWE catalog.
    pub fn weaknesses(&self) -> Result<Page<Weakness>> {
        self.collection(Method::Get, "/v1/weaknesses", &[], None)
    }

    /// Fetch the next page from a `next` link returned by a previous call.
    pub fn next_page<A: DeserializeOwned + Default>(
        &self,
        page: &Page<A>,
    ) -> Result<Option<Page<A>>> {
        match &page.next {
            Some(url) => self.collection(Method::Get, url, &[], None).map(Some),
            None => Ok(None),
        }
    }

    /// Escape hatch: a raw authenticated GET returning the parsed body.
    pub fn get_raw(&self, path: &str, query: &[(String, String)]) -> Result<serde_json::Value> {
        self.execute(Method::Get, path, query, None)
    }
}

impl Client<UreqTransport> {
    /// A client with the default transport and credentials.
    pub fn new(identifier: impl Into<String>, token: impl Into<String>) -> Self {
        Self::with_transport(DEFAULT_BASE_URL, UreqTransport::new())
            .with_credentials(identifier, token)
    }

    /// A client with the default transport and no credentials (public reads).
    pub fn anonymous() -> Self {
        Self::with_transport(DEFAULT_BASE_URL, UreqTransport::new())
    }
}

/// Percent-encode a query key or value (RFC 3986 unreserved set preserved).
fn encode(input: &str) -> String {
    let mut out = String::with_capacity(input.len());
    for byte in input.bytes() {
        match byte {
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
                out.push(byte as char)
            }
            _ => out.push_str(&format!("%{byte:02X}")),
        }
    }
    out
}