1use 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
13pub const DEFAULT_BASE_URL: &str = "https://api.hackerone.com";
15
16#[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
34pub struct Client<T: Transport = UreqTransport> {
51 base_url: String,
52 auth: Option<Auth>,
53 transport: T,
54}
55
56impl<T: Transport> Client<T> {
57 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 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 pub fn base_url(&self) -> &str {
81 &self.base_url
82 }
83
84 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 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 pub fn me(&self) -> Result<User> {
184 self.single(Method::Get, "/v1/me", &[], None)
185 }
186
187 pub fn programs(&self) -> Result<Page<crate::types::Program>> {
189 self.collection(Method::Get, "/v1/me/programs", &[], None)
190 }
191
192 pub fn program(&self, id: &str) -> Result<crate::types::Program> {
194 self.single(Method::Get, &format!("/v1/programs/{id}"), &[], None)
195 }
196
197 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 pub fn reports(&self, query: &ReportQuery) -> Result<Page<Report>> {
218 self.collection(Method::Get, "/v1/reports", &query.to_pairs(), None)
219 }
220
221 pub fn report(&self, id: &str) -> Result<Report> {
223 self.single(Method::Get, &format!("/v1/reports/{id}"), &[], None)
224 }
225
226 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 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 pub fn my_report(&self, id: &str) -> Result<Report> {
248 self.single(Method::Get, &format!("/v1/hackers/reports/{id}"), &[], None)
249 }
250
251 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 pub fn balance(&self) -> Result<crate::types::Balance> {
266 self.data_object(Method::Get, "/v1/hackers/payments/balance", &[], None)
267 }
268
269 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 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 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 pub fn weaknesses(&self) -> Result<Page<Weakness>> {
327 self.collection(Method::Get, "/v1/weaknesses", &[], None)
328 }
329
330 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 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 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 pub fn anonymous() -> Self {
356 Self::with_transport(DEFAULT_BASE_URL, UreqTransport::new())
357 }
358}
359
360fn 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}