#![deny(missing_docs)]
#[cfg(all(not(feature = "producer"), not(feature = "consumer")))]
compile_error!("at least one of feature \"producer\" and feature \"consumer\" must be enabled");
use chrono::{DateTime, Utc};
#[cfg(feature = "producer")]
use lazy_regex::regex_captures;
#[cfg(feature = "producer")]
use reqwest::StatusCode;
#[cfg(feature = "producer")]
use reqwest::header::{
ACCEPT, AUTHORIZATION, HeaderMap, HeaderValue, InvalidHeaderValue, RETRY_AFTER,
};
#[cfg(feature = "producer")]
use reqwest::{Client, ResponseBuilderExt, Url};
#[cfg(feature = "producer")]
use serde::ser::Error as SerializationError;
#[cfg(feature = "producer")]
use serde::{Deserialize, Serialize, Serializer};
#[cfg(feature = "producer")]
use std::borrow::Cow;
#[cfg(feature = "producer")]
use std::collections::hash_map::RandomState;
#[cfg(feature = "producer")]
use std::collections::{HashMap, HashSet};
#[cfg(feature = "producer")]
use std::fmt::Display;
#[cfg(feature = "producer")]
use std::hash::{BuildHasher, Hasher};
#[cfg(feature = "producer")]
use std::str::FromStr;
#[cfg(feature = "producer")]
use tracing::{debug, error, trace};
#[cfg(feature = "producer")]
use url::ParseError;
#[cfg(feature = "producer")]
use uuid::Uuid;
#[cfg(feature = "consumer")]
use chrono::{Duration, OutOfRangeError};
#[cfg(any(feature = "consumer", feature = "producer"))]
use std::time::Duration as StdDuration;
#[cfg(feature = "consumer")]
mod signature;
#[cfg(feature = "producer")]
pub mod generated;
#[cfg(feature = "producer")]
pub const DEFAULT_REQUEST_TIMEOUT: StdDuration = StdDuration::from_secs(10);
#[cfg(feature = "producer")]
pub const DEFAULT_MAX_PAYLOAD_BYTES: usize = 1024 * 1024;
#[cfg(feature = "producer")]
pub const DEFAULT_MAX_RESPONSE_BYTES: usize = 8 * 1024 * 1024;
#[cfg(feature = "producer")]
pub const MAX_RESPONSE_HEADERS: usize = 64;
#[cfg(feature = "producer")]
pub const MAX_HEADER_BYTES: usize = 64 * 1024;
#[cfg(feature = "producer")]
pub const MAX_HEAD_BYTES: usize = 16 * 1024;
#[cfg(feature = "producer")]
pub const MAX_ATTEMPTS_CAP: u32 = 16;
#[cfg(feature = "producer")]
const JSON_MEDIA_TYPE: &str = "application/json";
#[cfg(feature = "producer")]
const MAX_USER_AGENT_PART_CHARS: usize = 64;
#[cfg(feature = "producer")]
fn user_agent() -> String {
let version = clipped(env!("CARGO_PKG_VERSION"));
let os = clipped(&format!(
"{} {}",
std::env::consts::OS,
std::env::consts::ARCH
));
format!("hook0-client-rust/{version} (rust; {os})")
}
#[cfg(feature = "producer")]
fn clipped(part: &str) -> String {
part.chars()
.filter(|c| c.is_ascii_graphic() || *c == ' ')
.filter(|c| !matches!(c, '(' | ')' | ';'))
.take(MAX_USER_AGENT_PART_CHARS)
.collect()
}
#[cfg(feature = "producer")]
const CLIENT_OPTIONS: &str = "Hook0-Client-Options";
#[cfg(feature = "producer")]
const MAX_STATED_DELAY_MS: u128 = (1 << 53) - 1;
#[cfg(feature = "producer")]
fn client_options(policy: &RetryPolicy) -> String {
format!(
"attempts={},backoff={},ceiling={},budget={}",
policy.attempts(),
stated_delay(policy.initial_backoff),
stated_delay(policy.max_backoff),
stated_delay(policy.max_total_delay),
)
}
#[cfg(feature = "producer")]
fn stated_delay(delay: StdDuration) -> u128 {
delay.as_millis().min(MAX_STATED_DELAY_MS)
}
#[cfg(feature = "producer")]
const ALREADY_INGESTED: &str = "EventAlreadyIngested";
#[cfg(feature = "producer")]
const RATE_LIMITED: &str = "RateLimited";
#[cfg(feature = "producer")]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RetryPolicy {
pub max_attempts: u32,
pub initial_backoff: StdDuration,
pub max_backoff: StdDuration,
pub max_total_delay: StdDuration,
}
#[cfg(feature = "producer")]
impl Default for RetryPolicy {
fn default() -> Self {
Self {
max_attempts: 4,
initial_backoff: StdDuration::from_millis(100),
max_backoff: StdDuration::from_secs(2),
max_total_delay: StdDuration::from_secs(5),
}
}
}
#[cfg(feature = "producer")]
impl RetryPolicy {
pub const fn disabled() -> Self {
Self {
max_attempts: 1,
initial_backoff: StdDuration::ZERO,
max_backoff: StdDuration::ZERO,
max_total_delay: StdDuration::ZERO,
}
}
pub fn attempts(&self) -> u32 {
self.max_attempts.clamp(1, MAX_ATTEMPTS_CAP)
}
pub fn backoff_ceiling(&self, retry: u32) -> StdDuration {
let doublings = retry.saturating_sub(1).min(u32::BITS - 1);
self.initial_backoff
.saturating_mul(2u32.saturating_pow(doublings))
.min(self.max_backoff)
}
pub fn delays(&self, draws: &[f64]) -> Vec<StdDuration> {
let retries = self.attempts().saturating_sub(1);
let mut delays = Vec::with_capacity(retries as usize);
let mut spent = StdDuration::ZERO;
for retry in 1..=retries {
let draw = match draws.get((retry - 1) as usize) {
Some(draw) if draw.is_finite() => draw.clamp(0.0, 1.0),
_ => 1.0,
};
let delay = self.backoff_ceiling(retry).mul_f64(draw);
if spent.saturating_add(delay) > self.max_total_delay {
break;
}
spent = spent.saturating_add(delay);
delays.push(delay);
}
delays
}
}
#[cfg(feature = "producer")]
fn jitter_draws(count: usize) -> Vec<f64> {
const KEPT_BITS: u32 = 53;
(0..count)
.map(|_| {
let drawn = RandomState::new().build_hasher().finish();
(drawn >> (u64::BITS - KEPT_BITS)) as f64 / (1u64 << KEPT_BITS) as f64
})
.collect()
}
#[cfg(feature = "producer")]
#[derive(Debug, Clone)]
pub struct Hook0Client {
client: Client,
api_url: Url,
application_id: Uuid,
retry_policy: RetryPolicy,
request_timeout: StdDuration,
max_payload_bytes: usize,
max_response_bytes: usize,
}
#[cfg(feature = "producer")]
impl Hook0Client {
pub fn new(api_url: Url, application_id: Uuid, token: &str) -> Result<Self, Hook0ClientError> {
let authenticated_client = HeaderValue::from_str(&format!("Bearer {token}"))
.map_err(|e| Hook0ClientError::AuthHeader(e).log_and_return())
.map(|hv| {
HeaderMap::from_iter([
(AUTHORIZATION, hv),
(ACCEPT, HeaderValue::from_static(JSON_MEDIA_TYPE)),
])
})
.and_then(|headers| {
Client::builder()
.default_headers(headers)
.user_agent(user_agent())
.build()
.map_err(|e| Hook0ClientError::ReqwestClient(e).log_and_return())
})?;
Ok(Self {
api_url,
client: authenticated_client,
application_id,
retry_policy: RetryPolicy::default(),
request_timeout: DEFAULT_REQUEST_TIMEOUT,
max_payload_bytes: DEFAULT_MAX_PAYLOAD_BYTES,
max_response_bytes: DEFAULT_MAX_RESPONSE_BYTES,
})
}
pub fn api_url(&self) -> &Url {
&self.api_url
}
pub fn application_id(&self) -> &Uuid {
&self.application_id
}
pub fn with_retry_policy(mut self, retry_policy: RetryPolicy) -> Self {
self.retry_policy = retry_policy;
self
}
pub fn retry_policy(&self) -> &RetryPolicy {
&self.retry_policy
}
pub fn with_request_timeout(mut self, request_timeout: StdDuration) -> Self {
self.request_timeout = request_timeout;
self
}
pub fn request_timeout(&self) -> StdDuration {
self.request_timeout
}
pub fn with_max_payload_bytes(mut self, max_payload_bytes: usize) -> Self {
self.max_payload_bytes = max_payload_bytes;
self
}
pub fn max_payload_bytes(&self) -> usize {
self.max_payload_bytes
}
pub fn with_max_response_bytes(mut self, max_response_bytes: usize) -> Self {
self.max_response_bytes = max_response_bytes;
self
}
pub fn max_response_bytes(&self) -> usize {
self.max_response_bytes
}
fn mk_url(&self, segments: &[&str]) -> Result<Url, Hook0ClientError> {
append_url_segments(&self.api_url, segments)
.map_err(|e| Hook0ClientError::Url(e).log_and_return())
}
pub async fn send_event(&self, event: &Event<'_>) -> Result<Uuid, Hook0ClientError> {
let event_ingestion_url = self.mk_url(&["event"])?;
let event_id = match event.event_id {
Some(event_id) => event_id.to_owned(),
None => Uuid::now_v7(),
};
let full_event = FullEvent::from_event(event, &self.application_id, &event_id);
let body = BoundedEvent {
event: &full_event,
max_payload_bytes: self.max_payload_bytes,
};
let delays = self.retry_policy.delays(&jitter_draws(
self.retry_policy.attempts().saturating_sub(1) as usize,
));
let mut waited = StdDuration::ZERO;
let mut attempts = 0u32;
loop {
attempts += 1;
let outcome = self.attempt_event_send(&event_ingestion_url, &body).await;
let failure = match outcome {
Attempt::Ingested(id) => return Ok(id),
Attempt::AlreadyIngested { error, body } => {
if attempts > 1 {
debug!(
"Event {event_id} was already ingested by a previous attempt of this send"
);
return Ok(event_id);
}
Failure {
error,
body,
retryable: false,
named_delay: None,
}
}
Attempt::Failed(failure) => failure,
};
match delays.get((attempts - 1) as usize) {
Some(delay) if failure.retryable => {
trace!("Attempt {attempts} at sending event {event_id} failed, retrying");
let wait = wait_before_retry(&self.retry_policy, &failure, *delay, waited);
waited = waited.saturating_add(wait);
tokio::time::sleep(wait).await;
}
_ => {
return Err(Hook0ClientError::EventSending {
event_id: Some(event_id),
error: failure.error,
body: give_up_reason(attempts, waited, failure.body),
}
.log_and_return());
}
}
}
}
async fn attempt_event_send(&self, url: &Url, body: &BoundedEvent<'_>) -> Attempt {
let response = self
.client
.post(url.as_str())
.header(CLIENT_OPTIONS, client_options(&self.retry_policy))
.timeout(self.request_timeout)
.json(body)
.send()
.await;
let answer = match response {
Ok(res) => res,
Err(error) => {
return Attempt::Failed(Failure {
retryable: is_transient(&error),
body: underlying_cause(&error),
named_delay: None,
error,
});
}
};
let status = answer.status();
let named_delay = named_delay(answer.headers());
let (res, refusal) = match bounded(answer, self.max_response_bytes).await {
Ok(read) => read,
Err(error) => {
return Attempt::Failed(Failure {
retryable: true,
body: underlying_cause(&error),
named_delay: None,
error,
});
}
};
match res.error_for_status_ref() {
Ok(_) => {
#[derive(Debug, Deserialize)]
struct Response {
event_id: Uuid,
}
match res.json::<Response>().await {
Ok(response) => Attempt::Ingested(response.event_id),
Err(error) => Attempt::Failed(Failure {
body: refusal.or_else(|| underlying_cause(&error)),
error,
retryable: false,
named_delay: None,
}),
}
}
Err(error) => {
let body = res.text().await.ok();
if status == StatusCode::CONFLICT && is_already_ingested(body.as_deref()) {
Attempt::AlreadyIngested { error, body }
} else {
Attempt::Failed(Failure {
retryable: refusal.is_none() && is_retryable(status, body.as_deref()),
body: refusal.or(body),
error,
named_delay,
})
}
}
}
}
pub async fn upsert_event_types(
&self,
event_types: &[&str],
) -> Result<Vec<String>, Hook0ClientError> {
let structured_event_types = event_types
.iter()
.map(|str| {
EventType::from_str(str)
.map_err(|_| Hook0ClientError::InvalidEventType(str.to_string()))
})
.collect::<Result<Vec<EventType>, Hook0ClientError>>()?;
let event_types_url = self.mk_url(&["event_types"])?;
#[derive(Debug, Deserialize)]
struct ApiEventType {
event_type_name: String,
}
trace!("Getting the list of available event types");
let available_event_types_answer = self
.client
.get(event_types_url.as_str())
.header(CLIENT_OPTIONS, client_options(&self.retry_policy))
.query(&[("application_id", self.application_id())])
.send()
.await
.map_err(Hook0ClientError::GetAvailableEventTypes)?;
let available_event_types_vec =
bounded(available_event_types_answer, self.max_response_bytes)
.await
.map_err(Hook0ClientError::GetAvailableEventTypes)?
.0
.error_for_status()
.map_err(Hook0ClientError::GetAvailableEventTypes)?
.json::<Vec<ApiEventType>>()
.await
.map_err(Hook0ClientError::GetAvailableEventTypes)?;
let available_event_types = available_event_types_vec
.iter()
.map(|et| et.event_type_name.to_owned())
.collect::<HashSet<String>>();
debug!(
"There are currently {} event types",
available_event_types.len(),
);
#[derive(Debug, Serialize)]
struct ApiEventTypePost {
application_id: Uuid,
service: String,
resource_type: String,
verb: String,
}
impl Display for ApiEventTypePost {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}.{}.{}", self.service, self.resource_type, self.verb)
}
}
let mut added_event_types = vec![];
for event_type in structured_event_types {
let event_type_str = event_type.to_string();
if !available_event_types.contains(&event_type_str) {
debug!("Creating the '{event_type}' event type");
let body = ApiEventTypePost {
application_id: self.application_id,
service: event_type.service,
resource_type: event_type.resource_type,
verb: event_type.verb,
};
self.client
.post(event_types_url.as_str())
.header(CLIENT_OPTIONS, client_options(&self.retry_policy))
.json(&body)
.send()
.await
.map_err(|e| Hook0ClientError::CreatingEventType {
event_type_name: body.to_string(),
error: e,
})?
.error_for_status()
.map_err(|e| Hook0ClientError::CreatingEventType {
event_type_name: body.to_string(),
error: e,
})?;
added_event_types.push(body.to_string());
}
}
debug!("{} new event types were created", added_event_types.len());
Ok(added_event_types)
}
}
#[cfg(feature = "consumer")]
pub fn verify_webhook_signature_with_current_time<
HeaderKey: AsRef<[u8]>,
HeaderValue: AsRef<[u8]>,
>(
signature: &str,
payload: &[u8],
headers: &[(HeaderKey, HeaderValue)],
subscription_secret: &str,
tolerance: StdDuration,
current_time: DateTime<Utc>,
) -> Result<(), Hook0ClientError> {
let parsed_sig =
signature::Signature::parse(signature).map_err(|_| Hook0ClientError::InvalidSignature)?;
let headers_with_parsed_name = headers
.iter()
.map(|(k, v)| {
let name = http::HeaderName::from_bytes(k.as_ref()).map_err(|error| {
Hook0ClientError::InvalidHeaderName {
header_name: String::from_utf8_lossy(k.as_ref()).into_owned(),
error,
}
});
name.map(|n| (n, v))
})
.collect::<Result<std::collections::HashMap<_, _>, _>>()?;
let headers_vec = parsed_sig
.h
.iter()
.map(|expected| {
headers_with_parsed_name
.get(expected)
.ok_or_else(|| Hook0ClientError::MissingHeader(expected.to_owned()))
.and_then(|v| {
String::from_utf8(v.as_ref().to_vec()).map_err(|error| {
Hook0ClientError::InvalidHeaderValue {
header_name: expected.to_owned(),
header_value: String::from_utf8_lossy(v.as_ref()).into_owned(),
error,
}
})
})
})
.collect::<Result<Vec<_>, _>>()?;
if !parsed_sig.verify(payload, &headers_vec, subscription_secret)? {
Err(Hook0ClientError::InvalidSignature)
} else {
let signed_at = DateTime::from_timestamp(parsed_sig.timestamp, 0);
match signed_at {
Some(signed_at) => {
let tolerance = Duration::from_std(tolerance);
match tolerance {
Ok(tolerance) => {
if (current_time - signed_at).abs() > tolerance {
Err(Hook0ClientError::ExpiredWebhook {
signed_at,
tolerance,
current_time,
})
} else {
Ok(())
}
}
Err(e) => Err(Hook0ClientError::InvalidTolerance(e)),
}
}
None => Err(Hook0ClientError::InvalidSignature),
}
}
}
#[cfg(feature = "consumer")]
pub fn verify_webhook_signature<HeaderKey: AsRef<[u8]>, HeaderValue: AsRef<[u8]>>(
signature: &str,
payload: &[u8],
headers: &[(HeaderKey, HeaderValue)],
subscription_secret: &str,
tolerance: StdDuration,
) -> Result<(), Hook0ClientError> {
verify_webhook_signature_with_current_time(
signature,
payload,
headers,
subscription_secret,
tolerance,
Utc::now(),
)
}
#[cfg(feature = "producer")]
#[derive(Debug, Serialize, PartialEq, Eq)]
struct EventType {
service: String,
resource_type: String,
verb: String,
}
#[cfg(feature = "producer")]
impl FromStr for EventType {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
let captures = regex_captures!("^([A-Z0-9_]+)[.]([A-Z0-9_]+)[.]([A-Z0-9_]+)$"i, s);
if let Some((_, service, resource_type, verb)) = captures {
Ok(Self {
resource_type: resource_type.to_owned(),
service: service.to_owned(),
verb: verb.to_owned(),
})
} else {
Err(())
}
}
}
#[cfg(feature = "producer")]
impl Display for EventType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}.{}.{}", self.service, self.resource_type, self.verb)
}
}
#[cfg(feature = "producer")]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Event<'a> {
pub event_id: Option<&'a Uuid>,
pub event_type: &'a str,
pub payload: Cow<'a, str>,
pub payload_content_type: &'a str,
pub metadata: Option<Vec<(String, String)>>,
pub occurred_at: Option<DateTime<Utc>>,
pub labels: Vec<(String, String)>,
}
#[cfg(feature = "producer")]
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
struct FullEvent<'a> {
pub application_id: Uuid,
pub event_id: &'a Uuid,
pub event_type: &'a str,
pub payload: &'a str,
pub payload_content_type: &'a str,
pub metadata: Option<HashMap<String, String>>,
pub occurred_at: DateTime<Utc>,
pub labels: HashMap<String, String>,
}
#[cfg(feature = "producer")]
impl<'a> FullEvent<'a> {
pub fn from_event(event: &'a Event, application_id: &Uuid, event_id: &'a Uuid) -> Self {
let occurred_at = event.occurred_at.unwrap_or_else(Utc::now);
Self {
application_id: application_id.to_owned(),
event_id,
event_type: event.event_type,
payload: event.payload.as_ref(),
payload_content_type: event.payload_content_type,
metadata: event
.metadata
.as_ref()
.map(|items| HashMap::from_iter(items.iter().cloned())),
occurred_at,
labels: HashMap::from_iter(event.labels.iter().cloned()),
}
}
}
#[cfg(feature = "producer")]
struct BoundedEvent<'a> {
event: &'a FullEvent<'a>,
max_payload_bytes: usize,
}
#[cfg(feature = "producer")]
impl Serialize for BoundedEvent<'_> {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
let size = self.event.payload.len();
if size > self.max_payload_bytes {
return Err(S::Error::custom(format!(
"event payload is {size} bytes, which is more than the {} bytes this client sends at most; nothing was sent",
self.max_payload_bytes
)));
}
self.event.serialize(serializer)
}
}
#[cfg(feature = "producer")]
enum Attempt {
Ingested(Uuid),
AlreadyIngested {
error: reqwest::Error,
body: Option<String>,
},
Failed(Failure),
}
#[cfg(feature = "producer")]
struct Failure {
error: reqwest::Error,
body: Option<String>,
retryable: bool,
named_delay: Option<StdDuration>,
}
#[cfg(feature = "producer")]
async fn bounded(
mut answer: reqwest::Response,
max_response_bytes: usize,
) -> Result<(reqwest::Response, Option<String>), reqwest::Error> {
let mut refusal = head_above_a_bound(answer.headers());
let mut read: Vec<u8> = Vec::new();
if refusal.is_none() {
while let Some(frame) = answer.chunk().await? {
if read.len().saturating_add(frame.len()) > max_response_bytes {
read = Vec::new();
refusal = Some(format!(
"the API answered more than the {max_response_bytes} bytes read at most"
));
break;
}
read.extend_from_slice(&frame);
}
}
let url = answer.url().to_owned();
let (mut parts, _) = http::Response::<reqwest::Body>::from(answer).into_parts();
if let Ok(carrier) = http::Response::builder().url(url).body(()) {
parts.extensions = carrier.into_parts().0.extensions;
}
let held =
reqwest::Response::from(http::Response::from_parts(parts, reqwest::Body::from(read)));
Ok((held, refusal))
}
#[cfg(feature = "producer")]
fn head_above_a_bound(headers: &HeaderMap) -> Option<String> {
let mut lines = 0usize;
let mut whole = 0usize;
for (name, value) in headers {
lines += 1;
if lines > MAX_RESPONSE_HEADERS {
return Some(format!(
"the API answered more than the {MAX_RESPONSE_HEADERS} header lines read at most"
));
}
let line = name.as_str().len().saturating_add(value.len());
if line > MAX_HEADER_BYTES {
return Some(format!(
"the API answered a `{name}` header above the {MAX_HEADER_BYTES} bytes read at most"
));
}
whole = whole.saturating_add(line);
if whole > MAX_HEAD_BYTES {
return Some(format!(
"the API answered a head above the {MAX_HEAD_BYTES} bytes read at most"
));
}
}
None
}
#[cfg(feature = "producer")]
fn is_retryable(status: StatusCode, body: Option<&str>) -> bool {
if status == StatusCode::TOO_MANY_REQUESTS {
return problem_id(body).as_deref() == Some(RATE_LIMITED);
}
status.is_server_error()
}
#[cfg(feature = "producer")]
fn named_delay(headers: &HeaderMap) -> Option<StdDuration> {
headers
.get(RETRY_AFTER)
.and_then(|named| named.to_str().ok())
.and_then(|named| named.trim().parse::<u32>().ok())
.map(|seconds| StdDuration::from_secs(u64::from(seconds)))
}
#[cfg(feature = "producer")]
fn wait_before_retry(
policy: &RetryPolicy,
failure: &Failure,
scheduled: StdDuration,
waited: StdDuration,
) -> StdDuration {
let remaining = policy.max_total_delay.saturating_sub(waited);
failure.named_delay.unwrap_or(scheduled).min(remaining)
}
#[cfg(feature = "producer")]
fn is_transient(error: &reqwest::Error) -> bool {
error.is_timeout() || error.is_connect() || error.is_request() || error.is_body()
}
#[cfg(feature = "producer")]
fn underlying_cause(error: &reqwest::Error) -> Option<String> {
const MAX_LINKS: usize = 8;
let mut causes = Vec::new();
let mut cause = std::error::Error::source(error);
while let Some(current) = cause {
if causes.len() >= MAX_LINKS {
break;
}
causes.push(current.to_string());
cause = current.source();
}
if causes.is_empty() {
None
} else {
Some(causes.join(": "))
}
}
#[cfg(feature = "producer")]
fn problem_id(body: Option<&str>) -> Option<String> {
#[derive(Debug, Deserialize)]
struct Problem {
id: String,
}
body.and_then(|body| serde_json::from_str::<Problem>(body).ok())
.map(|problem| problem.id)
}
#[cfg(feature = "producer")]
fn is_already_ingested(body: Option<&str>) -> bool {
problem_id(body).as_deref() == Some(ALREADY_INGESTED)
}
#[cfg(feature = "producer")]
fn give_up_reason(attempts: u32, waited: StdDuration, body: Option<String>) -> Option<String> {
if attempts <= 1 {
return body;
}
let answer = match body {
Some(body) => format!("; last response body: {body}"),
None => String::new(),
};
Some(format!(
"gave up after {attempts} attempts spread over {waited:?} of retry delay{answer}"
))
}
#[derive(Debug, thiserror::Error)]
pub enum Hook0ClientError {
#[cfg(feature = "producer")]
#[error("Could not build auth header: {0}")]
AuthHeader(InvalidHeaderValue),
#[cfg(feature = "producer")]
#[error("Could not build reqwest HTTP client: {0}")]
ReqwestClient(reqwest::Error),
#[cfg(feature = "producer")]
#[error("Could not create a valid URL to request Hook0's API: {0}")]
Url(ParseError),
#[cfg(feature = "producer")]
#[error("Sending event{} failed: {error} [body={}]", event_id.map(|id| format!(" {id}")).unwrap_or_else(String::new), body.as_deref().unwrap_or(""))]
EventSending {
event_id: Option<Uuid>,
error: reqwest::Error,
body: Option<String>,
},
#[cfg(feature = "producer")]
#[error("Provided event type '{0}' does not have a valid syntax (service.resource_type.verb)")]
InvalidEventType(String),
#[cfg(feature = "producer")]
#[error("Getting available event types failed: {0}")]
GetAvailableEventTypes(reqwest::Error),
#[cfg(feature = "producer")]
#[error("Creating event type '{event_type_name}' failed: {error}")]
CreatingEventType {
event_type_name: String,
error: reqwest::Error,
},
#[cfg(feature = "consumer")]
#[error("Invalid signature")]
InvalidSignature,
#[cfg(feature = "consumer")]
#[error(
"The webhook's signature timestamp is outside the tolerance window (signed_at={signed_at}, tolerance={tolerance}, current_time={current_time})"
)]
ExpiredWebhook {
signed_at: DateTime<Utc>,
tolerance: Duration,
current_time: DateTime<Utc>,
},
#[cfg(feature = "consumer")]
#[error("Could not parse signature header: {0}")]
SignatureHeaderParsing(String),
#[cfg(feature = "consumer")]
#[error("Could not parse timestamp `{timestamp}` in signature: {error}")]
TimestampParsing {
timestamp: String,
error: std::num::ParseIntError,
},
#[cfg(feature = "consumer")]
#[error("Could not parse v0 signature `{signature}`: {error}")]
V0SignatureParsing {
signature: String,
error: hex::FromHexError,
},
#[cfg(feature = "consumer")]
#[error("Could not parse header name `{header}` in `h` field: {error}")]
HeaderNameParsing {
header: String,
error: http::header::InvalidHeaderName,
},
#[cfg(feature = "consumer")]
#[error("Could not parse v1 signature `{signature}`: {error}")]
V1SignatureParsing {
signature: String,
error: hex::FromHexError,
},
#[cfg(feature = "consumer")]
#[error("The `{0}` header present in the webhook's signature was not provided with a value")]
MissingHeader(http::HeaderName),
#[cfg(feature = "consumer")]
#[error("Provided `{header_name}` has an invalid header name: {error}")]
InvalidHeaderName {
header_name: String,
error: http::header::InvalidHeaderName,
},
#[cfg(feature = "consumer")]
#[error("Provided `{header_name}` has an invalid header value `{header_value}`: {error}")]
InvalidHeaderValue {
header_name: http::HeaderName,
header_value: String,
error: std::string::FromUtf8Error,
},
#[cfg(feature = "consumer")]
#[error("Invalid tolerance Duration: {0}")]
InvalidTolerance(OutOfRangeError),
}
#[cfg(feature = "producer")]
impl Hook0ClientError {
pub fn log_and_return(self) -> Self {
error!("{self}");
self
}
}
#[cfg(feature = "producer")]
fn append_url_segments(base_url: &Url, segments: &[&str]) -> Result<Url, url::ParseError> {
const SEP: &str = "/";
let segments_str = segments.join(SEP);
let url = Url::parse(&format!("{base_url}/{segments_str}").replace("//", "/"))?;
Ok(url)
}