1use 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
12pub const DEFAULT_BASE_URL: &str = "https://api.hackerone.com";
14
15#[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
33pub struct Client<T: Transport = UreqTransport> {
50 base_url: String,
51 auth: Option<Auth>,
52 transport: T,
53}
54
55impl<T: Transport> Client<T> {
56 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 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 pub fn base_url(&self) -> &str {
80 &self.base_url
81 }
82
83 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 pub fn me(&self) -> Result<User> {
165 self.single(Method::Get, "/v1/me", &[], None)
166 }
167
168 pub fn programs(&self) -> Result<Page<crate::types::Program>> {
170 self.collection(Method::Get, "/v1/me/programs", &[], None)
171 }
172
173 pub fn program(&self, id: &str) -> Result<crate::types::Program> {
175 self.single(Method::Get, &format!("/v1/programs/{id}"), &[], None)
176 }
177
178 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 pub fn reports(&self, query: &ReportQuery) -> Result<Page<Report>> {
199 self.collection(Method::Get, "/v1/reports", &query.to_pairs(), None)
200 }
201
202 pub fn report(&self, id: &str) -> Result<Report> {
204 self.single(Method::Get, &format!("/v1/reports/{id}"), &[], None)
205 }
206
207 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 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 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 pub fn weaknesses(&self) -> Result<Page<Weakness>> {
261 self.collection(Method::Get, "/v1/weaknesses", &[], None)
262 }
263
264 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 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 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 pub fn anonymous() -> Self {
290 Self::with_transport(DEFAULT_BASE_URL, UreqTransport::new())
291 }
292}
293
294fn 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}