Skip to main content

hackerone_api/
client.rs

1//! The API client: auth, request building, pagination, and endpoints.
2
3use serde::de::DeserializeOwned;
4
5use crate::error::{from_response, Error, Result};
6use crate::transport::{Method, Request, Transport, UreqTransport};
7use crate::types::{
8    CollectionDoc, CreateHackerReport, DataDoc, Earning, Hacktivity, HacktivityQuery, Page,
9    PageQuery, Report, ReportQuery, ReportState, Resource, SingleDoc, StructuredScope, User,
10    Weakness,
11};
12
13/// Default API root.
14pub const DEFAULT_BASE_URL: &str = "https://api.hackerone.com";
15
16/// Basic-auth credentials: API token *identifier* + token *value*.
17#[derive(Debug, Clone)]
18struct Auth {
19    identifier: String,
20    token: String,
21}
22
23impl Auth {
24    fn header_value(&self) -> String {
25        use base64::Engine as _;
26        let raw = format!("{}:{}", self.identifier, self.token);
27        format!(
28            "Basic {}",
29            base64::engine::general_purpose::STANDARD.encode(raw)
30        )
31    }
32}
33
34/// A HackerOne API client.
35///
36/// Generic over its [`Transport`] so it can be unit-tested with a mock and
37/// embedded with a custom HTTP stack. The default transport is
38/// [`UreqTransport`].
39///
40/// ```no_run
41/// # fn main() -> Result<(), hackerone_api::Error> {
42/// use hackerone_api::Client;
43///
44/// let client = Client::new("my-api-identifier", "my-api-token");
45/// let me = client.me()?;
46/// println!("{:?}", me.username);
47/// # Ok(())
48/// # }
49/// ```
50pub struct Client<T: Transport = UreqTransport> {
51    base_url: String,
52    auth: Option<Auth>,
53    transport: T,
54}
55
56impl<T: Transport> Client<T> {
57    /// Build a client over a custom transport (no credentials yet).
58    pub fn with_transport(base_url: impl Into<String>, transport: T) -> Self {
59        Self {
60            base_url: base_url.into().trim_end_matches('/').to_string(),
61            auth: None,
62            transport,
63        }
64    }
65
66    /// Attach HTTP Basic credentials (builder style).
67    pub fn with_credentials(
68        mut self,
69        identifier: impl Into<String>,
70        token: impl Into<String>,
71    ) -> Self {
72        self.auth = Some(Auth {
73            identifier: identifier.into(),
74            token: token.into(),
75        });
76        self
77    }
78
79    /// The configured base URL.
80    pub fn base_url(&self) -> &str {
81        &self.base_url
82    }
83
84    // ── internals ──────────────────────────────────────────────────────
85
86    /// Build an absolute URL from a path or pass an absolute URL through.
87    fn absolute(&self, path_or_url: &str) -> String {
88        if path_or_url.starts_with("http://") || path_or_url.starts_with("https://") {
89            path_or_url.to_string()
90        } else if path_or_url.starts_with('/') {
91            format!("{}{}", self.base_url, path_or_url)
92        } else {
93            format!("{}/{}", self.base_url, path_or_url)
94        }
95    }
96
97    fn endpoint(&self, path: &str, query: &[(String, String)]) -> String {
98        let mut url = self.absolute(path);
99        if !query.is_empty() {
100            let qs = query
101                .iter()
102                .map(|(k, v)| format!("{}={}", encode(k), encode(v)))
103                .collect::<Vec<_>>()
104                .join("&");
105            url.push('?');
106            url.push_str(&qs);
107        }
108        url
109    }
110
111    fn execute(
112        &self,
113        method: Method,
114        path: &str,
115        query: &[(String, String)],
116        body: Option<&serde_json::Value>,
117    ) -> Result<serde_json::Value> {
118        let url = self.endpoint(path, query);
119        let mut request = Request::new(method, url).header("Accept", "application/json");
120        if let Some(auth) = &self.auth {
121            request = request.header("Authorization", auth.header_value());
122        }
123        if let Some(value) = body {
124            request = request
125                .body_json(value)?
126                .header("Content-Type", "application/json");
127        }
128
129        let response = self.transport.send(&request)?;
130        if !(200..300).contains(&response.status) {
131            return Err(from_response(&response));
132        }
133        response.json()
134    }
135
136    fn single<A: DeserializeOwned + Default>(
137        &self,
138        method: Method,
139        path: &str,
140        query: &[(String, String)],
141        body: Option<&serde_json::Value>,
142    ) -> Result<A> {
143        let value = self.execute(method, path, query, body)?;
144        let doc: SingleDoc<A> = serde_json::from_value(value)
145            .map_err(|e| Error::Decode(format!("unexpected single-resource shape: {e}")))?;
146        Ok(doc.data.attributes)
147    }
148
149    fn collection<A: DeserializeOwned + Default>(
150        &self,
151        method: Method,
152        path: &str,
153        query: &[(String, String)],
154        body: Option<&serde_json::Value>,
155    ) -> Result<Page<A>> {
156        let value = self.execute(method, path, query, body)?;
157        let doc: CollectionDoc<A> = serde_json::from_value(value)
158            .map_err(|e| Error::Decode(format!("unexpected collection shape: {e}")))?;
159        Ok(Page::from_doc(doc))
160    }
161
162    /// Decode a bare `{ "data": … }` envelope (no `id`/`type`/`attributes`).
163    fn data_object<A: DeserializeOwned>(
164        &self,
165        method: Method,
166        path: &str,
167        query: &[(String, String)],
168        body: Option<&serde_json::Value>,
169    ) -> Result<A> {
170        let value = self.execute(method, path, query, body)?;
171        let doc: DataDoc<A> = serde_json::from_value(value)
172            .map_err(|e| Error::Decode(format!("unexpected data-object shape: {e}")))?;
173        Ok(doc.data)
174    }
175
176    // ── endpoints ──────────────────────────────────────────────────────
177
178    /// `GET /v1/me` — the authenticated user.
179    ///
180    /// This is a **customer/profile** endpoint. A hacker-only API token
181    /// receives `401` here; use [`Client::my_reports`] (which hits
182    /// `/v1/hackers/me/reports`) to confirm a hacker token works.
183    pub fn me(&self) -> Result<User> {
184        self.single(Method::Get, "/v1/me", &[], None)
185    }
186
187    /// `GET /v1/me/programs` — programs the token can access.
188    pub fn programs(&self) -> Result<Page<crate::types::Program>> {
189        self.collection(Method::Get, "/v1/me/programs", &[], None)
190    }
191
192    /// `GET /v1/programs/{id}` — one program by handle or id.
193    pub fn program(&self, id: &str) -> Result<crate::types::Program> {
194        self.single(Method::Get, &format!("/v1/programs/{id}"), &[], None)
195    }
196
197    /// `GET /v1/programs/{id}/structured_scopes` — a program's scopes.
198    pub fn structured_scopes(
199        &self,
200        program_id: &str,
201        page: Option<(u32, u32)>,
202    ) -> Result<Page<StructuredScope>> {
203        let mut query = Vec::new();
204        if let Some((number, size)) = page {
205            query.push(("page[number]".to_string(), number.to_string()));
206            query.push(("page[size]".to_string(), size.to_string()));
207        }
208        self.collection(
209            Method::Get,
210            &format!("/v1/programs/{program_id}/structured_scopes"),
211            &query,
212            None,
213        )
214    }
215
216    /// `GET /v1/reports` — reports, filtered by `query`.
217    pub fn reports(&self, query: &ReportQuery) -> Result<Page<Report>> {
218        self.collection(Method::Get, "/v1/reports", &query.to_pairs(), None)
219    }
220
221    /// `GET /v1/reports/{id}` — one report.
222    pub fn report(&self, id: &str) -> Result<Report> {
223        self.single(Method::Get, &format!("/v1/reports/{id}"), &[], None)
224    }
225
226    /// `POST /v1/hackers/reports` — submit a report to a program as a hacker.
227    ///
228    /// This is the endpoint a researcher uses to *file* a report; it posts the
229    /// [`CreateHackerReport`] body (`team_handle` + attributes) to the hacker
230    /// surface and returns the created [`Report`].
231    pub fn create_report(&self, report: &CreateHackerReport) -> Result<Report> {
232        let body = report.to_json()?;
233        self.single(Method::Post, "/v1/hackers/reports", &[], Some(&body))
234    }
235
236    /// `GET /v1/hackers/me/reports` — the authenticated hacker's own reports.
237    pub fn my_reports(&self, query: &PageQuery) -> Result<Page<Report>> {
238        self.collection(
239            Method::Get,
240            "/v1/hackers/me/reports",
241            &query.to_pairs(),
242            None,
243        )
244    }
245
246    /// `GET /v1/hackers/reports/{id}` — one of the authenticated hacker's reports.
247    pub fn my_report(&self, id: &str) -> Result<Report> {
248        self.single(Method::Get, &format!("/v1/hackers/reports/{id}"), &[], None)
249    }
250
251    /// `GET /v1/hackers/hacktivity` — the public hacktivity feed.
252    ///
253    /// `query.query_string` is a Lucene filter, e.g.
254    /// `severity_rating:critical AND disclosed:true`.
255    pub fn hacktivity(&self, query: &HacktivityQuery) -> Result<Page<Hacktivity>> {
256        self.collection(
257            Method::Get,
258            "/v1/hackers/hacktivity",
259            &query.to_pairs(),
260            None,
261        )
262    }
263
264    /// `GET /v1/hackers/payments/balance` — the authenticated hacker's balance.
265    pub fn balance(&self) -> Result<crate::types::Balance> {
266        self.data_object(Method::Get, "/v1/hackers/payments/balance", &[], None)
267    }
268
269    /// `GET /v1/hackers/payments/earnings` — the authenticated hacker's earnings.
270    pub fn earnings(&self, query: &PageQuery) -> Result<Page<Earning>> {
271        self.collection(
272            Method::Get,
273            "/v1/hackers/payments/earnings",
274            &query.to_pairs(),
275            None,
276        )
277    }
278
279    /// `POST /v1/reports/{id}/activities` — add a comment.
280    pub fn add_comment(
281        &self,
282        report_id: &str,
283        message: &str,
284    ) -> Result<Resource<serde_json::Value>> {
285        let body = serde_json::json!({
286            "data": {
287                "type": "activity-comment",
288                "attributes": { "message": message },
289            }
290        });
291        self.single(
292            Method::Post,
293            &format!("/v1/reports/{report_id}/activities"),
294            &[],
295            Some(&body),
296        )
297    }
298
299    /// `POST /v1/reports/{id}/state_changes` — change a report's state.
300    pub fn change_state(
301        &self,
302        report_id: &str,
303        state: ReportState,
304        message: Option<&str>,
305    ) -> Result<Resource<serde_json::Value>> {
306        let mut attributes = serde_json::Map::new();
307        attributes.insert("state".into(), serde_json::json!(state.as_str()));
308        if let Some(message) = message {
309            attributes.insert("message".into(), serde_json::json!(message));
310        }
311        let body = serde_json::json!({
312            "data": {
313                "type": "state-change",
314                "attributes": serde_json::Value::Object(attributes),
315            }
316        });
317        self.single(
318            Method::Post,
319            &format!("/v1/reports/{report_id}/state_changes"),
320            &[],
321            Some(&body),
322        )
323    }
324
325    /// `GET /v1/weaknesses` — the CWE catalog.
326    pub fn weaknesses(&self) -> Result<Page<Weakness>> {
327        self.collection(Method::Get, "/v1/weaknesses", &[], None)
328    }
329
330    /// Fetch the next page from a `next` link returned by a previous call.
331    pub fn next_page<A: DeserializeOwned + Default>(
332        &self,
333        page: &Page<A>,
334    ) -> Result<Option<Page<A>>> {
335        match &page.next {
336            Some(url) => self.collection(Method::Get, url, &[], None).map(Some),
337            None => Ok(None),
338        }
339    }
340
341    /// Escape hatch: a raw authenticated GET returning the parsed body.
342    pub fn get_raw(&self, path: &str, query: &[(String, String)]) -> Result<serde_json::Value> {
343        self.execute(Method::Get, path, query, None)
344    }
345}
346
347impl Client<UreqTransport> {
348    /// A client with the default transport and credentials.
349    pub fn new(identifier: impl Into<String>, token: impl Into<String>) -> Self {
350        Self::with_transport(DEFAULT_BASE_URL, UreqTransport::new())
351            .with_credentials(identifier, token)
352    }
353
354    /// A client with the default transport and no credentials (public reads).
355    pub fn anonymous() -> Self {
356        Self::with_transport(DEFAULT_BASE_URL, UreqTransport::new())
357    }
358}
359
360/// Percent-encode a query key or value (RFC 3986 unreserved set preserved).
361fn encode(input: &str) -> String {
362    let mut out = String::with_capacity(input.len());
363    for byte in input.bytes() {
364        match byte {
365            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
366                out.push(byte as char)
367            }
368            _ => out.push_str(&format!("%{byte:02X}")),
369        }
370    }
371    out
372}