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
//! Transport abstraction.
//!
//! The client depends on the [`Transport`] trait, not on a specific HTTP
//! stack. [`UreqTransport`] is the default (`ureq`, blocking, TLS via
//! rustls/native-tls depending on features), but embedders can supply their
//! own — or a mock — by implementing the trait. Nothing else in the crate
//! knows how bytes reach the network.

use std::io::Read;
use std::time::Duration;

use serde::Serialize;

use crate::error::{Error, Result};

/// HTTP method.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Method {
    /// `GET`
    Get,
    /// `POST`
    Post,
    /// `PUT`
    Put,
    /// `PATCH`
    Patch,
    /// `DELETE`
    Delete,
}

impl Method {
    /// The wire form of the method.
    pub fn as_str(self) -> &'static str {
        match self {
            Method::Get => "GET",
            Method::Post => "POST",
            Method::Put => "PUT",
            Method::Patch => "PATCH",
            Method::Delete => "DELETE",
        }
    }
}

/// A fully-built request handed to a [`Transport`].
#[derive(Debug, Clone)]
pub struct Request {
    /// HTTP method.
    pub method: Method,
    /// Absolute URL.
    pub url: String,
    /// Header name/value pairs.
    pub headers: Vec<(String, String)>,
    /// Serialized JSON body, if any.
    pub body: Option<Vec<u8>>,
}

impl Request {
    /// Start a request.
    pub fn new(method: Method, url: impl Into<String>) -> Self {
        Self {
            method,
            url: url.into(),
            headers: Vec::new(),
            body: None,
        }
    }

    /// Add a header (builder style).
    pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
        self.headers.push((name.into(), value.into()));
        self
    }

    /// Set a JSON body (builder style).
    pub fn body_json<T: Serialize>(mut self, value: &T) -> Result<Self> {
        let bytes = serde_json::to_vec(value)
            .map_err(|e| Error::Invalid(format!("serialize request body: {e}")))?;
        self.body = Some(bytes);
        Ok(self)
    }

    /// Look up a header value (case-insensitive).
    pub fn header_value(&self, name: &str) -> Option<&str> {
        self.headers
            .iter()
            .find(|(k, _)| k.eq_ignore_ascii_case(name))
            .map(|(_, v)| v.as_str())
    }
}

/// A raw HTTP response.
#[derive(Debug, Clone)]
pub struct Response {
    /// Status code.
    pub status: u16,
    /// Raw response body.
    pub body: Vec<u8>,
}

impl Response {
    /// Construct a response (useful for tests).
    pub fn new(status: u16, body: impl Into<Vec<u8>>) -> Self {
        Self {
            status,
            body: body.into(),
        }
    }

    /// The body as lossy UTF-8.
    pub fn text(&self) -> String {
        String::from_utf8_lossy(&self.body).into_owned()
    }

    /// Parse the body as JSON (empty bodies become `Value::Null`).
    pub fn json(&self) -> Result<serde_json::Value> {
        if self.body.is_empty() {
            return Ok(serde_json::Value::Null);
        }
        serde_json::from_slice(&self.body)
            .map_err(|e| Error::Decode(format!("invalid JSON response: {e}")))
    }
}

/// Anything that can turn a [`Request`] into a [`Response`].
pub trait Transport: Send + Sync {
    /// Execute the request.
    fn send(&self, request: &Request) -> Result<Response>;
}

/// Default transport backed by blocking `ureq`.
pub struct UreqTransport {
    agent: ureq::Agent,
}

impl UreqTransport {
    /// A transport with the default 30s timeout.
    pub fn new() -> Self {
        Self::with_timeout(Duration::from_secs(30))
    }

    /// A transport with a custom timeout.
    pub fn with_timeout(timeout: Duration) -> Self {
        Self {
            agent: ureq::AgentBuilder::new().timeout(timeout).build(),
        }
    }
}

impl Default for UreqTransport {
    fn default() -> Self {
        Self::new()
    }
}

impl Transport for UreqTransport {
    fn send(&self, request: &Request) -> Result<Response> {
        let mut req = self.agent.request(request.method.as_str(), &request.url);
        for (name, value) in &request.headers {
            req = req.set(name, value);
        }

        let result = match &request.body {
            Some(bytes) => req.send_bytes(bytes),
            None => req.call(),
        };

        match result {
            Ok(resp) => {
                let status = resp.status();
                let mut body = Vec::new();
                resp.into_reader()
                    .read_to_end(&mut body)
                    .map_err(|e| Error::Transport(e.to_string()))?;
                Ok(Response { status, body })
            }
            // Non-2xx is a normal HTTP response, not a transport failure.
            Err(ureq::Error::Status(code, resp)) => {
                let mut body = Vec::new();
                let _ = resp.into_reader().read_to_end(&mut body);
                Ok(Response { status: code, body })
            }
            Err(ureq::Error::Transport(t)) => Err(Error::Transport(t.to_string())),
        }
    }
}