hook0_client/
lib.rs

1// Force exposed items to be documented
2#![deny(missing_docs)]
3
4//! This is the Rust client for Hook0.
5//! It makes it easier to send events from a Rust application to a Hook0 instance.
6
7use chrono::{DateTime, Utc};
8use lazy_regex::regex_captures;
9use log::{debug, error, trace};
10use reqwest::header::{HeaderMap, HeaderValue, InvalidHeaderValue, AUTHORIZATION};
11use reqwest::{Client, Url};
12use serde::{Deserialize, Serialize};
13use serde_json::{Map, Value};
14use std::borrow::Cow;
15use std::collections::HashSet;
16use std::fmt::Display;
17use std::str::FromStr;
18use url::ParseError;
19use uuid::Uuid;
20
21/// The Hook0 client
22///
23/// This struct is supposed to be initialized once and shared/reused wherever you need to send events in your app.
24#[derive(Debug, Clone)]
25pub struct Hook0Client {
26    client: Client,
27    api_url: Url,
28    application_id: Uuid,
29}
30
31impl Hook0Client {
32    /// Initialize a client
33    ///
34    /// - `api_url` - Base API URL of a Hook0 instance (example: `https://app.hook0.com/api/v1`).
35    /// - `application_id` - UUID of your Hook0 application.
36    /// - `token` - Authentication token valid for your Hook0 application.
37    pub fn new(api_url: Url, application_id: Uuid, token: &str) -> Result<Self, Hook0ClientError> {
38        let authenticated_client = HeaderValue::from_str(&format!("Bearer {token}"))
39            .map_err(|e| Hook0ClientError::AuthHeader(e).log_and_return())
40            .map(|hv| HeaderMap::from_iter([(AUTHORIZATION, hv)]))
41            .and_then(|headers| {
42                Client::builder()
43                    .default_headers(headers)
44                    .build()
45                    .map_err(|e| Hook0ClientError::ReqwestClient(e).log_and_return())
46            })?;
47
48        Ok(Self {
49            api_url,
50            client: authenticated_client,
51            application_id,
52        })
53    }
54
55    /// Get the API URL of this client
56    pub fn api_url(&self) -> &Url {
57        &self.api_url
58    }
59
60    /// Get the application ID of this client
61    pub fn application_id(&self) -> &Uuid {
62        &self.application_id
63    }
64
65    fn mk_url(&self, segments: &[&str]) -> Result<Url, Hook0ClientError> {
66        append_url_segments(&self.api_url, segments)
67            .map_err(|e| Hook0ClientError::Url(e).log_and_return())
68    }
69
70    /// Send an event to Hook0
71    pub async fn send_event(&self, event: &Event<'_>) -> Result<Uuid, Hook0ClientError> {
72        let event_ingestion_url = self.mk_url(&["event"])?;
73        let full_event = FullEvent::from_event(event, &self.application_id);
74
75        let res = self
76            .client
77            .post(event_ingestion_url)
78            .json(&full_event)
79            .send()
80            .await
81            .map_err(|e| {
82                Hook0ClientError::EventSending {
83                    event_id: full_event.event_id.to_owned(),
84                    error: e,
85                    body: None,
86                }
87                .log_and_return()
88            })?;
89
90        match res.error_for_status_ref() {
91            Ok(_) => Ok(full_event.event_id),
92            Err(e) => {
93                let body = res.text().await.ok();
94                Err(Hook0ClientError::EventSending {
95                    event_id: full_event.event_id.to_owned(),
96                    error: e,
97                    body,
98                }
99                .log_and_return())
100            }
101        }
102    }
103
104    /// Ensure the configured app has the right event types or create them
105    ///
106    /// Returns the list of event types that were created, if any.
107    pub async fn upsert_event_types(
108        &self,
109        event_types: &[&str],
110    ) -> Result<Vec<String>, Hook0ClientError> {
111        let structured_event_types = event_types
112            .iter()
113            .map(|str| {
114                EventType::from_str(str)
115                    .map_err(|_| Hook0ClientError::InvalidEventType(str.to_string()))
116            })
117            .collect::<Result<Vec<EventType>, Hook0ClientError>>()?;
118
119        let event_types_url = self.mk_url(&["event_types"])?;
120        #[derive(Debug, Deserialize)]
121        struct ApiEventType {
122            event_type_name: String,
123        }
124
125        trace!("Getting the list of available event types");
126        let available_event_types_vec = self
127            .client
128            .get(event_types_url.as_str())
129            .query(&[("application_id", self.application_id())])
130            .send()
131            .await
132            .map_err(Hook0ClientError::GetAvailableEventTypes)?
133            .error_for_status()
134            .map_err(Hook0ClientError::GetAvailableEventTypes)?
135            .json::<Vec<ApiEventType>>()
136            .await
137            .map_err(Hook0ClientError::GetAvailableEventTypes)?;
138        let available_event_types = available_event_types_vec
139            .iter()
140            .map(|et| et.event_type_name.to_owned())
141            .collect::<HashSet<String>>();
142        debug!(
143            "There are currently {} event types",
144            available_event_types.len(),
145        );
146
147        #[derive(Debug, Serialize)]
148        struct ApiEventTypePost {
149            application_id: Uuid,
150            service: String,
151            resource_type: String,
152            verb: String,
153        }
154        impl Display for ApiEventTypePost {
155            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
156                write!(f, "{}.{}.{}", self.service, self.resource_type, self.verb)
157            }
158        }
159
160        let mut added_event_types = vec![];
161        for event_type in structured_event_types {
162            let event_type_str = event_type.to_string();
163            if !available_event_types.contains(&event_type_str) {
164                debug!("Creating the '{event_type}' event type");
165
166                let body = ApiEventTypePost {
167                    application_id: self.application_id,
168                    service: event_type.service,
169                    resource_type: event_type.resource_type,
170                    verb: event_type.verb,
171                };
172
173                self.client
174                    .post(event_types_url.as_str())
175                    .json(&body)
176                    .send()
177                    .await
178                    .map_err(|e| Hook0ClientError::CreatingEventType {
179                        event_type_name: body.to_string(),
180                        error: e,
181                    })?
182                    .error_for_status()
183                    .map_err(|e| Hook0ClientError::CreatingEventType {
184                        event_type_name: body.to_string(),
185                        error: e,
186                    })?;
187
188                added_event_types.push(body.to_string());
189            }
190        }
191        debug!("{} new event types were created", added_event_types.len());
192
193        Ok(added_event_types)
194    }
195}
196
197/// A structured event type
198#[derive(Debug, Serialize, PartialEq, Eq)]
199pub struct EventType {
200    service: String,
201    resource_type: String,
202    verb: String,
203}
204
205impl FromStr for EventType {
206    type Err = ();
207
208    fn from_str(s: &str) -> Result<Self, Self::Err> {
209        let captures = regex_captures!("^([A-Z0-9_]+)[.]([A-Z0-9_]+)[.]([A-Z0-9_]+)$"i, s);
210        if let Some((_, service, resource_type, verb)) = captures {
211            Ok(Self {
212                resource_type: resource_type.to_owned(),
213                service: service.to_owned(),
214                verb: verb.to_owned(),
215            })
216        } else {
217            Err(())
218        }
219    }
220}
221
222impl Display for EventType {
223    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
224        write!(f, "{}.{}.{}", self.service, self.resource_type, self.verb)
225    }
226}
227
228/// An event that can be sent to Hook0
229#[derive(Debug, Clone, PartialEq, Eq)]
230pub struct Event<'a> {
231    /// Unique ID of the event (a UUIDv4 will be generated if nothing is provided)
232    pub event_id: &'a Option<&'a Uuid>,
233    /// Type of the event (as configured in your Hook0 application)
234    pub event_type: &'a str,
235    /// Payload
236    pub payload: Cow<'a, str>,
237    /// Content type of the payload
238    pub payload_content_type: &'a str,
239    /// Optional key-value metadata
240    pub metadata: Option<Vec<(String, Value)>>,
241    /// Datetime of when the event occurred (current time will be used if nothing is provided)
242    pub occurred_at: Option<DateTime<Utc>>,
243    /// Labels that Hook0 will use to route the event
244    pub labels: Vec<(String, Value)>,
245}
246
247#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
248struct FullEvent<'a> {
249    pub application_id: Uuid,
250    pub event_id: Uuid,
251    pub event_type: &'a str,
252    pub payload: &'a str,
253    pub payload_content_type: &'a str,
254    pub metadata: Option<Map<String, Value>>,
255    pub occurred_at: DateTime<Utc>,
256    pub labels: Map<String, Value>,
257}
258
259impl<'a> FullEvent<'a> {
260    pub fn from_event(event: &'a Event, application_id: &Uuid) -> Self {
261        let event_id = event
262            .event_id
263            .map(|uuid| uuid.to_owned())
264            .unwrap_or_else(Uuid::new_v4);
265        let occurred_at = event.occurred_at.unwrap_or_else(Utc::now);
266
267        Self {
268            application_id: application_id.to_owned(),
269            event_id,
270            event_type: event.event_type,
271            payload: event.payload.as_ref(),
272            payload_content_type: event.payload_content_type,
273            metadata: event
274                .metadata
275                .as_ref()
276                .map(|items| Map::from_iter(items.iter().cloned())),
277            occurred_at,
278            labels: Map::from_iter(event.labels.iter().cloned()),
279        }
280    }
281}
282
283/// Every error Hook0 client can encounter
284#[derive(Debug, thiserror::Error)]
285pub enum Hook0ClientError {
286    /// Cannot build a structurally-valid `Authorization` header
287    ///
288    /// _This is an internal error that is unlikely to happen._
289    #[error("Could not build auth header: {0}")]
290    AuthHeader(InvalidHeaderValue),
291
292    /// Cannot build a Reqwest HTTP client
293    ///
294    /// _This is an internal error that is unlikely to happen._
295    #[error("Could not build reqwest HTTP client: {0}")]
296    ReqwestClient(reqwest::Error),
297
298    /// Cannot build a structurally-valid endpoint URL
299    ///
300    /// _This is an internal error that is unlikely to happen._
301    #[error("Could not create a valid URL to request Hook0's API: {0}")]
302    Url(ParseError),
303
304    /// Something went wrong when sending an event to Hook0
305    #[error("Sending event {event_id} failed: {error} [body={}]", body.as_deref().unwrap_or(""))]
306    EventSending {
307        /// ID of the event
308        event_id: Uuid,
309
310        /// Error as reported by Reqwest
311        error: reqwest::Error,
312
313        /// Body of the HTTP response
314        body: Option<String>,
315    },
316
317    /// Provided event type does not have a valid syntax
318    #[error("Provided event type '{0}' does not have a valid syntax (service.resource_type.verb)")]
319    InvalidEventType(String),
320
321    /// Something went wrong when trying to fetch the list of available event types
322    #[error("Getting available event types failed: {0}")]
323    GetAvailableEventTypes(reqwest::Error),
324
325    /// Something went wrong when creating an event type
326    #[error("Creating event type '{event_type_name}' failed: {error}")]
327    CreatingEventType {
328        /// Name of the event type
329        event_type_name: String,
330
331        /// Error as reported by Reqwest
332        error: reqwest::Error,
333    },
334}
335
336impl Hook0ClientError {
337    /// Log the error (using the log crate) and return it as a result of this function's call
338    pub fn log_and_return(self) -> Self {
339        error!("{self}");
340        self
341    }
342}
343
344fn append_url_segments(base_url: &Url, segments: &[&str]) -> Result<Url, url::ParseError> {
345    const SEP: &str = "/";
346    let segments_str = segments.join(SEP);
347
348    let url = Url::parse(&format!("{base_url}/{segments_str}").replace("//", "/"))?;
349
350    Ok(url)
351}
352
353#[cfg(test)]
354mod tests {
355    use super::*;
356
357    #[test]
358    fn displaying_event_type() {
359        let et = EventType {
360            service: "service".to_owned(),
361            resource_type: "resource".to_owned(),
362            verb: "verb".to_owned(),
363        };
364
365        assert_eq!(et.to_string(), "service.resource.verb")
366    }
367
368    #[test]
369    fn parsing_valid_event_type() {
370        let et = EventType {
371            service: "service".to_owned(),
372            resource_type: "resource".to_owned(),
373            verb: "verb".to_owned(),
374        };
375
376        assert_eq!(EventType::from_str(&et.to_string()), Ok(et))
377    }
378
379    #[test]
380    fn parsing_invalid_event_type() {
381        assert_eq!(EventType::from_str("test.test"), Err(()))
382    }
383}