From 247b818fb5c20bff9fd7759930a945d0e14828db Mon Sep 17 00:00:00 2001 From: Oliver Browne Date: Tue, 11 Feb 2025 13:45:37 +0200 Subject: [PATCH 1/3] initial refactor --- Cargo.toml | 8 +- src/async_client.rs | 0 src/client.rs | 54 ++++++++++ src/error.rs | 18 ++++ src/event.rs | 100 +++++++++++++++++++ src/lib.rs | 236 +++----------------------------------------- tests/test.rs | 43 ++++++++ 7 files changed, 234 insertions(+), 225 deletions(-) create mode 100644 src/async_client.rs create mode 100644 src/client.rs create mode 100644 src/error.rs create mode 100644 src/event.rs create mode 100644 tests/test.rs diff --git a/Cargo.toml b/Cargo.toml index 3d6372d..4f4456d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,9 +10,12 @@ edition = "2018" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -reqwest = { version = "0.11.3", default-features = false, features = ["blocking", "rustls-tls"] } +reqwest = { version = "0.11.3", default-features = false, features = [ + "blocking", + "rustls-tls", +] } serde = { version = "1.0.125", features = ["derive"] } -chrono = {version = "0.4.19", features = ["serde"] } +chrono = { version = "0.4.19", features = ["serde"] } serde_json = "1.0.64" semver = "1.0.24" @@ -22,3 +25,4 @@ ctor = "0.1.26" [features] e2e-test = [] +async = [] diff --git a/src/async_client.rs b/src/async_client.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/client.rs b/src/client.rs new file mode 100644 index 0000000..0f15e8d --- /dev/null +++ b/src/client.rs @@ -0,0 +1,54 @@ +use reqwest::header::CONTENT_TYPE; + +use crate::{event::InnerEvent, Error, Event, API_ENDPOINT, TIMEOUT}; + +pub struct Client { + options: ClientOptions, + client: HttpClient, +} + +pub struct ClientOptions { + api_endpoint: String, + api_key: String, +} + +impl Client { + pub fn capture(&self, event: Event) -> Result<(), Error> { + let inner_event = InnerEvent::new(event, self.options.api_key.clone()); + let _res = self + .client + .post(self.options.api_endpoint.clone()) + .header(CONTENT_TYPE, "application/json") + .body(serde_json::to_string(&inner_event).expect("unwrap here is safe")) + .send() + .map_err(|e| Error::Connection(e.to_string()))?; + Ok(()) + } + + pub fn capture_batch(&self, events: Vec) -> Result<(), Error> { + for event in events { + self.capture(event)?; + } + Ok(()) + } +} + +impl From<&str> for ClientOptions { + fn from(api_key: &str) -> Self { + ClientOptions { + api_endpoint: API_ENDPOINT.to_string(), + api_key: api_key.to_string(), + } + } +} + +pub fn client>(options: C) -> Client { + let client = HttpClient::builder() + .timeout(Some(*TIMEOUT)) + .build() + .unwrap(); // Unwrap here is as safe as `HttpClient::new` + Client { + options: options.into(), + client, + } +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..de038d3 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,18 @@ +use std::fmt::{Display, Formatter}; + +impl Display for Error { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Error::Connection(msg) => write!(f, "Connection Error: {}", msg), + Error::Serialization(msg) => write!(f, "Serialization Error: {}", msg), + } + } +} + +impl std::error::Error for Error {} + +#[derive(Debug)] +pub enum Error { + Connection(String), + Serialization(String), +} diff --git a/src/event.rs b/src/event.rs new file mode 100644 index 0000000..e389d34 --- /dev/null +++ b/src/event.rs @@ -0,0 +1,100 @@ +use std::collections::HashMap; + +use chrono::NaiveDateTime; +use semver::Version; +use serde::Serialize; + +use crate::Error; + +#[derive(Serialize, Debug, PartialEq, Eq)] +pub struct Event { + event: String, + properties: Properties, + timestamp: Option, +} + +#[derive(Serialize, Debug, PartialEq, Eq)] +pub struct Properties { + distinct_id: String, + props: HashMap, +} + +impl Properties { + fn new>(distinct_id: S) -> Self { + Self { + distinct_id: distinct_id.into(), + props: Default::default(), + } + } +} + +impl Event { + pub fn new>(event: S, distinct_id: S) -> Self { + Self { + event: event.into(), + properties: Properties::new(distinct_id), + timestamp: None, + } + } + + /// Errors if `prop` fails to serialize + pub fn insert_prop, P: Serialize>( + &mut self, + key: K, + prop: P, + ) -> Result<(), Error> { + let as_json = + serde_json::to_value(prop).map_err(|e| Error::Serialization(e.to_string()))?; + let _ = self.properties.props.insert(key.into(), as_json); + Ok(()) + } +} + +// This exists so that the client doesn't have to specify the API key over and over +#[derive(Serialize)] +pub struct InnerEvent { + api_key: String, + event: String, + properties: Properties, + timestamp: Option, +} + +impl InnerEvent { + pub fn new(event: Event, api_key: String) -> Self { + let mut properties = event.properties; + + // Add $lib_name and $lib_version to the properties + properties.props.insert( + "$lib_name".into(), + serde_json::Value::String("posthog-rs".into()), + ); + + let version_str = env!("CARGO_PKG_VERSION"); + properties.props.insert( + "$lib_version".into(), + serde_json::Value::String(version_str.into()), + ); + + if let Ok(version) = version_str.parse::() { + properties.props.insert( + "$lib_version__major".into(), + serde_json::Value::Number(version.major.into()), + ); + properties.props.insert( + "$lib_version__minor".into(), + serde_json::Value::Number(version.minor.into()), + ); + properties.props.insert( + "$lib_version__patch".into(), + serde_json::Value::Number(version.patch.into()), + ); + } + + Self { + api_key, + event: event.event, + properties, + timestamp: event.timestamp, + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 41bb4fd..6343a94 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,230 +1,20 @@ -use chrono::NaiveDateTime; -use reqwest::blocking::Client as HttpClient; -use reqwest::header::CONTENT_TYPE; -use semver::Version; -use serde::Serialize; -use std::collections::HashMap; -use std::fmt::{Display, Formatter}; -use std::time::Duration; +mod async_client; +mod client; +mod error; +mod event; -extern crate serde_json; +use std::time::Duration; const API_ENDPOINT: &str = "https://us.i.posthog.com/capture/"; const TIMEOUT: &Duration = &Duration::from_millis(800); // This should be specified by the user -pub fn client>(options: C) -> Client { - let client = HttpClient::builder() - .timeout(Some(*TIMEOUT)) - .build() - .unwrap(); // Unwrap here is as safe as `HttpClient::new` - Client { - options: options.into(), - client, - } -} - -impl Display for Error { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - match self { - Error::Connection(msg) => write!(f, "Connection Error: {}", msg), - Error::Serialization(msg) => write!(f, "Serialization Error: {}", msg), - } - } -} - -impl std::error::Error for Error {} - -#[derive(Debug)] -pub enum Error { - Connection(String), - Serialization(String), -} - -pub struct ClientOptions { - api_endpoint: String, - api_key: String, -} - -impl From<&str> for ClientOptions { - fn from(api_key: &str) -> Self { - ClientOptions { - api_endpoint: API_ENDPOINT.to_string(), - api_key: api_key.to_string(), - } - } -} - -pub struct Client { - options: ClientOptions, - client: HttpClient, -} - -impl Client { - pub fn capture(&self, event: Event) -> Result<(), Error> { - let inner_event = InnerEvent::new(event, self.options.api_key.clone()); - let _res = self - .client - .post(self.options.api_endpoint.clone()) - .header(CONTENT_TYPE, "application/json") - .body(serde_json::to_string(&inner_event).expect("unwrap here is safe")) - .send() - .map_err(|e| Error::Connection(e.to_string()))?; - Ok(()) - } - - pub fn capture_batch(&self, events: Vec) -> Result<(), Error> { - for event in events { - self.capture(event)?; - } - Ok(()) - } -} - -// This exists so that the client doesn't have to specify the API key over and over -#[derive(Serialize)] -struct InnerEvent { - api_key: String, - event: String, - properties: Properties, - timestamp: Option, -} - -impl InnerEvent { - fn new(event: Event, api_key: String) -> Self { - let mut properties = event.properties; - - // Add $lib_name and $lib_version to the properties - properties.props.insert( - "$lib_name".into(), - serde_json::Value::String("posthog-rs".into()), - ); - - let version_str = env!("CARGO_PKG_VERSION"); - properties.props.insert( - "$lib_version".into(), - serde_json::Value::String(version_str.into()), - ); - - if let Ok(version) = version_str.parse::() { - properties.props.insert( - "$lib_version__major".into(), - serde_json::Value::Number(version.major.into()), - ); - properties.props.insert( - "$lib_version__minor".into(), - serde_json::Value::Number(version.minor.into()), - ); - properties.props.insert( - "$lib_version__patch".into(), - serde_json::Value::Number(version.patch.into()), - ); - } - - Self { - api_key, - event: event.event, - properties, - timestamp: event.timestamp, - } - } -} - -#[derive(Serialize, Debug, PartialEq, Eq)] -pub struct Event { - event: String, - properties: Properties, - timestamp: Option, -} - -#[derive(Serialize, Debug, PartialEq, Eq)] -pub struct Properties { - distinct_id: String, - props: HashMap, -} - -impl Properties { - fn new>(distinct_id: S) -> Self { - Self { - distinct_id: distinct_id.into(), - props: Default::default(), - } - } -} - -impl Event { - pub fn new>(event: S, distinct_id: S) -> Self { - Self { - event: event.into(), - properties: Properties::new(distinct_id), - timestamp: None, - } - } - - /// Errors if `prop` fails to serialize - pub fn insert_prop, P: Serialize>( - &mut self, - key: K, - prop: P, - ) -> Result<(), Error> { - let as_json = - serde_json::to_value(prop).map_err(|e| Error::Serialization(e.to_string()))?; - let _ = self.properties.props.insert(key.into(), as_json); - Ok(()) - } -} - -#[cfg(test)] -mod test_setup { - use ctor::ctor; - use dotenv::dotenv; - - #[ctor] - fn load_dotenv() { - dotenv().ok(); // Load the .env file - println!("Loaded .env for tests"); - } -} - -#[cfg(test)] -pub mod tests { - use super::*; - - // see https://us.posthog.com/project/115809/ for the e2e project - - #[test] - fn inner_event_adds_lib_properties_correctly() { - // Arrange - let mut event = Event::new("unit test event", "1234"); - event.insert_prop("key1", "value1").unwrap(); - let api_key = "test_api_key".to_string(); - - // Act - let inner_event = InnerEvent::new(event, api_key); - - // Assert - let props = &inner_event.properties.props; - assert_eq!( - props.get("$lib_name"), - Some(&serde_json::Value::String("posthog-rs".to_string())) - ); - } - - #[cfg(feature = "e2e-test")] - #[test] - fn get_client() { - use std::collections::HashMap; - - let api_key = std::env::var("POSTHOG_RS_E2E_TEST_API_KEY").unwrap(); - let client = crate::client(api_key.as_str()); - - let mut child_map = HashMap::new(); - child_map.insert("child_key1", "child_value1"); +// Client +pub use client::client; +pub use client::Client; +pub use client::ClientOptions; - let mut event = Event::new("e2e test event", "1234"); - event.insert_prop("key1", "value1").unwrap(); - event.insert_prop("key2", vec!["a", "b"]).unwrap(); - event.insert_prop("key3", child_map).unwrap(); +// Error +pub use error::Error; - client.capture(event).unwrap(); - } -} +// Event +pub use event::Event; diff --git a/tests/test.rs b/tests/test.rs new file mode 100644 index 0000000..5719909 --- /dev/null +++ b/tests/test.rs @@ -0,0 +1,43 @@ +#[cfg(test)] +pub mod tests { + use super::*; + + // see https://us.posthog.com/project/115809/ for the e2e project + + #[test] + fn inner_event_adds_lib_properties_correctly() { + // Arrange + let mut event = Event::new("unit test event", "1234"); + event.insert_prop("key1", "value1").unwrap(); + let api_key = "test_api_key".to_string(); + + // Act + let inner_event = InnerEvent::new(event, api_key); + + // Assert + let props = &inner_event.properties.props; + assert_eq!( + props.get("$lib_name"), + Some(&serde_json::Value::String("posthog-rs".to_string())) + ); + } + + #[cfg(feature = "e2e-test")] + #[test] + fn get_client() { + use std::collections::HashMap; + + let api_key = std::env::var("POSTHOG_RS_E2E_TEST_API_KEY").unwrap(); + let client = crate::client(api_key.as_str()); + + let mut child_map = HashMap::new(); + child_map.insert("child_key1", "child_value1"); + + let mut event = Event::new("e2e test event", "1234"); + event.insert_prop("key1", "value1").unwrap(); + event.insert_prop("key2", vec!["a", "b"]).unwrap(); + event.insert_prop("key3", child_map).unwrap(); + + client.capture(event).unwrap(); + } +} From 16f435caed241784e99d5ec069737efb5471df68 Mon Sep 17 00:00:00 2001 From: Oliver Browne Date: Tue, 11 Feb 2025 13:58:06 +0200 Subject: [PATCH 2/3] final shape --- Cargo.toml | 1 - src/async_client.rs | 0 src/{client.rs => client/blocking.rs} | 20 +++-------- src/client/mod.rs | 20 +++++++++++ src/event.rs | 23 ++++++++++++ src/lib.rs | 2 +- tests/test.rs | 50 +++++++-------------------- 7 files changed, 61 insertions(+), 55 deletions(-) delete mode 100644 src/async_client.rs rename src/{client.rs => client/blocking.rs} (72%) create mode 100644 src/client/mod.rs diff --git a/Cargo.toml b/Cargo.toml index 4f4456d..d0497fc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,4 +25,3 @@ ctor = "0.1.26" [features] e2e-test = [] -async = [] diff --git a/src/async_client.rs b/src/async_client.rs deleted file mode 100644 index e69de29..0000000 diff --git a/src/client.rs b/src/client/blocking.rs similarity index 72% rename from src/client.rs rename to src/client/blocking.rs index 0f15e8d..e6aa80a 100644 --- a/src/client.rs +++ b/src/client/blocking.rs @@ -1,17 +1,14 @@ -use reqwest::header::CONTENT_TYPE; +use reqwest::{blocking::Client as HttpClient, header::CONTENT_TYPE}; -use crate::{event::InnerEvent, Error, Event, API_ENDPOINT, TIMEOUT}; +use crate::{event::InnerEvent, Error, Event, TIMEOUT}; + +use super::ClientOptions; pub struct Client { options: ClientOptions, client: HttpClient, } -pub struct ClientOptions { - api_endpoint: String, - api_key: String, -} - impl Client { pub fn capture(&self, event: Event) -> Result<(), Error> { let inner_event = InnerEvent::new(event, self.options.api_key.clone()); @@ -33,15 +30,6 @@ impl Client { } } -impl From<&str> for ClientOptions { - fn from(api_key: &str) -> Self { - ClientOptions { - api_endpoint: API_ENDPOINT.to_string(), - api_key: api_key.to_string(), - } - } -} - pub fn client>(options: C) -> Client { let client = HttpClient::builder() .timeout(Some(*TIMEOUT)) diff --git a/src/client/mod.rs b/src/client/mod.rs new file mode 100644 index 0000000..a61b6c7 --- /dev/null +++ b/src/client/mod.rs @@ -0,0 +1,20 @@ +use crate::API_ENDPOINT; + +mod blocking; + +pub use blocking::client; +pub use blocking::Client; + +pub struct ClientOptions { + api_endpoint: String, + api_key: String, +} + +impl From<&str> for ClientOptions { + fn from(api_key: &str) -> Self { + ClientOptions { + api_endpoint: API_ENDPOINT.to_string(), + api_key: api_key.to_string(), + } + } +} diff --git a/src/event.rs b/src/event.rs index e389d34..a2bfb14 100644 --- a/src/event.rs +++ b/src/event.rs @@ -98,3 +98,26 @@ impl InnerEvent { } } } + +#[cfg(test)] +pub mod tests { + use crate::{event::InnerEvent, Event}; + + #[test] + fn inner_event_adds_lib_properties_correctly() { + // Arrange + let mut event = Event::new("unit test event", "1234"); + event.insert_prop("key1", "value1").unwrap(); + let api_key = "test_api_key".to_string(); + + // Act + let inner_event = InnerEvent::new(event, api_key); + + // Assert + let props = &inner_event.properties.props; + assert_eq!( + props.get("$lib_name"), + Some(&serde_json::Value::String("posthog-rs".to_string())) + ); + } +} diff --git a/src/lib.rs b/src/lib.rs index 6343a94..c0d137c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,3 @@ -mod async_client; mod client; mod error; mod event; @@ -8,6 +7,7 @@ use std::time::Duration; const API_ENDPOINT: &str = "https://us.i.posthog.com/capture/"; const TIMEOUT: &Duration = &Duration::from_millis(800); // This should be specified by the user +// Public interface - any change to this is breaking! // Client pub use client::client; pub use client::Client; diff --git a/tests/test.rs b/tests/test.rs index 5719909..5e9be76 100644 --- a/tests/test.rs +++ b/tests/test.rs @@ -1,43 +1,19 @@ -#[cfg(test)] -pub mod tests { - use super::*; - +#[cfg(feature = "e2e-test")] +#[test] +fn get_client() { // see https://us.posthog.com/project/115809/ for the e2e project + use std::collections::HashMap; - #[test] - fn inner_event_adds_lib_properties_correctly() { - // Arrange - let mut event = Event::new("unit test event", "1234"); - event.insert_prop("key1", "value1").unwrap(); - let api_key = "test_api_key".to_string(); - - // Act - let inner_event = InnerEvent::new(event, api_key); - - // Assert - let props = &inner_event.properties.props; - assert_eq!( - props.get("$lib_name"), - Some(&serde_json::Value::String("posthog-rs".to_string())) - ); - } - - #[cfg(feature = "e2e-test")] - #[test] - fn get_client() { - use std::collections::HashMap; - - let api_key = std::env::var("POSTHOG_RS_E2E_TEST_API_KEY").unwrap(); - let client = crate::client(api_key.as_str()); + let api_key = std::env::var("POSTHOG_RS_E2E_TEST_API_KEY").unwrap(); + let client = crate::client(api_key.as_str()); - let mut child_map = HashMap::new(); - child_map.insert("child_key1", "child_value1"); + let mut child_map = HashMap::new(); + child_map.insert("child_key1", "child_value1"); - let mut event = Event::new("e2e test event", "1234"); - event.insert_prop("key1", "value1").unwrap(); - event.insert_prop("key2", vec!["a", "b"]).unwrap(); - event.insert_prop("key3", child_map).unwrap(); + let mut event = Event::new("e2e test event", "1234"); + event.insert_prop("key1", "value1").unwrap(); + event.insert_prop("key2", vec!["a", "b"]).unwrap(); + event.insert_prop("key3", child_map).unwrap(); - client.capture(event).unwrap(); - } + client.capture(event).unwrap(); } From a43e4280046f673e25f8d43220e7e2b07bfc5ff7 Mon Sep 17 00:00:00 2001 From: Oliver Browne Date: Tue, 11 Feb 2025 14:05:48 +0200 Subject: [PATCH 3/3] tests --- tests/test.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/test.rs b/tests/test.rs index 5e9be76..4d27cca 100644 --- a/tests/test.rs +++ b/tests/test.rs @@ -1,11 +1,16 @@ #[cfg(feature = "e2e-test")] #[test] fn get_client() { + use dotenv::dotenv; + dotenv().ok(); // Load the .env file + println!("Loaded .env for tests"); + // see https://us.posthog.com/project/115809/ for the e2e project + use posthog_rs::Event; use std::collections::HashMap; let api_key = std::env::var("POSTHOG_RS_E2E_TEST_API_KEY").unwrap(); - let client = crate::client(api_key.as_str()); + let client = posthog_rs::client(api_key.as_str()); let mut child_map = HashMap::new(); child_map.insert("child_key1", "child_value1");