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