use serde::de::DeserializeOwned;
use crate::error::{from_response, Error, Result};
use crate::transport::{Method, Request, Transport, UreqTransport};
use crate::types::{
CollectionDoc, CreateHackerReport, DataDoc, Earning, Hacktivity, HacktivityQuery, Page,
PageQuery, Report, ReportQuery, ReportState, Resource, SingleDoc, StructuredScope, User,
Weakness,
};
pub const DEFAULT_BASE_URL: &str = "https://api.hackerone.com";
#[derive(Debug, Clone)]
struct Auth {
identifier: String,
token: String,
}
impl Auth {
fn header_value(&self) -> String {
use base64::Engine as _;
let raw = format!("{}:{}", self.identifier, self.token);
format!(
"Basic {}",
base64::engine::general_purpose::STANDARD.encode(raw)
)
}
}
pub struct Client<T: Transport = UreqTransport> {
base_url: String,
auth: Option<Auth>,
transport: T,
}
impl<T: Transport> Client<T> {
pub fn with_transport(base_url: impl Into<String>, transport: T) -> Self {
Self {
base_url: base_url.into().trim_end_matches('/').to_string(),
auth: None,
transport,
}
}
pub fn with_credentials(
mut self,
identifier: impl Into<String>,
token: impl Into<String>,
) -> Self {
self.auth = Some(Auth {
identifier: identifier.into(),
token: token.into(),
});
self
}
pub fn base_url(&self) -> &str {
&self.base_url
}
fn absolute(&self, path_or_url: &str) -> String {
if path_or_url.starts_with("http://") || path_or_url.starts_with("https://") {
path_or_url.to_string()
} else if path_or_url.starts_with('/') {
format!("{}{}", self.base_url, path_or_url)
} else {
format!("{}/{}", self.base_url, path_or_url)
}
}
fn endpoint(&self, path: &str, query: &[(String, String)]) -> String {
let mut url = self.absolute(path);
if !query.is_empty() {
let qs = query
.iter()
.map(|(k, v)| format!("{}={}", encode(k), encode(v)))
.collect::<Vec<_>>()
.join("&");
url.push('?');
url.push_str(&qs);
}
url
}
fn execute(
&self,
method: Method,
path: &str,
query: &[(String, String)],
body: Option<&serde_json::Value>,
) -> Result<serde_json::Value> {
let url = self.endpoint(path, query);
let mut request = Request::new(method, url).header("Accept", "application/json");
if let Some(auth) = &self.auth {
request = request.header("Authorization", auth.header_value());
}
if let Some(value) = body {
request = request
.body_json(value)?
.header("Content-Type", "application/json");
}
let response = self.transport.send(&request)?;
if !(200..300).contains(&response.status) {
return Err(from_response(&response));
}
response.json()
}
fn single<A: DeserializeOwned + Default>(
&self,
method: Method,
path: &str,
query: &[(String, String)],
body: Option<&serde_json::Value>,
) -> Result<A> {
let value = self.execute(method, path, query, body)?;
let doc: SingleDoc<A> = serde_json::from_value(value)
.map_err(|e| Error::Decode(format!("unexpected single-resource shape: {e}")))?;
Ok(doc.data.attributes)
}
fn collection<A: DeserializeOwned + Default>(
&self,
method: Method,
path: &str,
query: &[(String, String)],
body: Option<&serde_json::Value>,
) -> Result<Page<A>> {
let value = self.execute(method, path, query, body)?;
let doc: CollectionDoc<A> = serde_json::from_value(value)
.map_err(|e| Error::Decode(format!("unexpected collection shape: {e}")))?;
Ok(Page::from_doc(doc))
}
fn data_object<A: DeserializeOwned>(
&self,
method: Method,
path: &str,
query: &[(String, String)],
body: Option<&serde_json::Value>,
) -> Result<A> {
let value = self.execute(method, path, query, body)?;
let doc: DataDoc<A> = serde_json::from_value(value)
.map_err(|e| Error::Decode(format!("unexpected data-object shape: {e}")))?;
Ok(doc.data)
}
pub fn me(&self) -> Result<User> {
self.single(Method::Get, "/v1/me", &[], None)
}
pub fn programs(&self) -> Result<Page<crate::types::Program>> {
self.collection(Method::Get, "/v1/me/programs", &[], None)
}
pub fn program(&self, id: &str) -> Result<crate::types::Program> {
self.single(Method::Get, &format!("/v1/programs/{id}"), &[], None)
}
pub fn structured_scopes(
&self,
program_id: &str,
page: Option<(u32, u32)>,
) -> Result<Page<StructuredScope>> {
let mut query = Vec::new();
if let Some((number, size)) = page {
query.push(("page[number]".to_string(), number.to_string()));
query.push(("page[size]".to_string(), size.to_string()));
}
self.collection(
Method::Get,
&format!("/v1/programs/{program_id}/structured_scopes"),
&query,
None,
)
}
pub fn reports(&self, query: &ReportQuery) -> Result<Page<Report>> {
self.collection(Method::Get, "/v1/reports", &query.to_pairs(), None)
}
pub fn report(&self, id: &str) -> Result<Report> {
self.single(Method::Get, &format!("/v1/reports/{id}"), &[], None)
}
pub fn create_report(&self, report: &CreateHackerReport) -> Result<Report> {
let body = report.to_json()?;
self.single(Method::Post, "/v1/hackers/reports", &[], Some(&body))
}
pub fn my_reports(&self, query: &PageQuery) -> Result<Page<Report>> {
self.collection(
Method::Get,
"/v1/hackers/me/reports",
&query.to_pairs(),
None,
)
}
pub fn my_report(&self, id: &str) -> Result<Report> {
self.single(Method::Get, &format!("/v1/hackers/reports/{id}"), &[], None)
}
pub fn hacktivity(&self, query: &HacktivityQuery) -> Result<Page<Hacktivity>> {
self.collection(
Method::Get,
"/v1/hackers/hacktivity",
&query.to_pairs(),
None,
)
}
pub fn balance(&self) -> Result<crate::types::Balance> {
self.data_object(Method::Get, "/v1/hackers/payments/balance", &[], None)
}
pub fn earnings(&self, query: &PageQuery) -> Result<Page<Earning>> {
self.collection(
Method::Get,
"/v1/hackers/payments/earnings",
&query.to_pairs(),
None,
)
}
pub fn add_comment(
&self,
report_id: &str,
message: &str,
) -> Result<Resource<serde_json::Value>> {
let body = serde_json::json!({
"data": {
"type": "activity-comment",
"attributes": { "message": message },
}
});
self.single(
Method::Post,
&format!("/v1/reports/{report_id}/activities"),
&[],
Some(&body),
)
}
pub fn change_state(
&self,
report_id: &str,
state: ReportState,
message: Option<&str>,
) -> Result<Resource<serde_json::Value>> {
let mut attributes = serde_json::Map::new();
attributes.insert("state".into(), serde_json::json!(state.as_str()));
if let Some(message) = message {
attributes.insert("message".into(), serde_json::json!(message));
}
let body = serde_json::json!({
"data": {
"type": "state-change",
"attributes": serde_json::Value::Object(attributes),
}
});
self.single(
Method::Post,
&format!("/v1/reports/{report_id}/state_changes"),
&[],
Some(&body),
)
}
pub fn weaknesses(&self) -> Result<Page<Weakness>> {
self.collection(Method::Get, "/v1/weaknesses", &[], None)
}
pub fn next_page<A: DeserializeOwned + Default>(
&self,
page: &Page<A>,
) -> Result<Option<Page<A>>> {
match &page.next {
Some(url) => self.collection(Method::Get, url, &[], None).map(Some),
None => Ok(None),
}
}
pub fn get_raw(&self, path: &str, query: &[(String, String)]) -> Result<serde_json::Value> {
self.execute(Method::Get, path, query, None)
}
}
impl Client<UreqTransport> {
pub fn new(identifier: impl Into<String>, token: impl Into<String>) -> Self {
Self::with_transport(DEFAULT_BASE_URL, UreqTransport::new())
.with_credentials(identifier, token)
}
pub fn anonymous() -> Self {
Self::with_transport(DEFAULT_BASE_URL, UreqTransport::new())
}
}
fn encode(input: &str) -> String {
let mut out = String::with_capacity(input.len());
for byte in input.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(byte as char)
}
_ => out.push_str(&format!("%{byte:02X}")),
}
}
out
}