1use crate::endpoints::{EndpointManager, DEFAULT_HOST};
2use derive_builder::Builder;
3use tracing::warn;
4
5#[cfg(not(feature = "async-client"))]
6mod blocking;
7#[cfg(not(feature = "async-client"))]
8pub use blocking::client;
9#[cfg(not(feature = "async-client"))]
10pub use blocking::Client;
11
12#[cfg(feature = "async-client")]
13mod async_client;
14#[cfg(feature = "async-client")]
15pub use async_client::client;
16#[cfg(feature = "async-client")]
17pub use async_client::Client;
18
19#[derive(Builder, Clone)]
36#[builder(build_fn(name = "build_unchecked", private))]
37pub struct ClientOptions {
38 #[builder(setter(into, strip_option), default)]
40 host: Option<String>,
41
42 #[builder(default)]
44 api_key: String,
45
46 #[builder(default = "30")]
48 request_timeout_seconds: u64,
49
50 #[builder(setter(into, strip_option), default)]
52 personal_api_key: Option<String>,
53
54 #[builder(default = "false")]
56 enable_local_evaluation: bool,
57
58 #[builder(default = "30")]
60 poll_interval_seconds: u64,
61
62 #[builder(default = "false")]
64 disabled: bool,
65
66 #[builder(default = "false")]
68 disable_geoip: bool,
69
70 #[builder(default = "3")]
72 feature_flags_request_timeout_seconds: u64,
73
74 #[builder(default = "false")]
79 local_evaluation_only: bool,
80
81 #[builder(setter(skip))]
82 #[builder(default = "EndpointManager::new(DEFAULT_HOST.to_string())")]
83 endpoint_manager: EndpointManager,
84}
85
86impl ClientOptions {
87 pub(crate) fn endpoints(&self) -> &EndpointManager {
89 &self.endpoint_manager
90 }
91
92 pub fn is_disabled(&self) -> bool {
94 self.disabled
95 }
96
97 fn sanitize(mut self) -> Self {
98 self.api_key = self.api_key.trim().to_string();
99 if self.api_key.is_empty() {
100 warn!("api_key is empty after trimming whitespace; disabling PostHog client");
101 self.disabled = true;
102 }
103 self.host = Some(match self.host {
104 Some(host) => {
105 let normalized = host.trim().to_string();
106 if normalized.is_empty() {
107 DEFAULT_HOST.to_string()
108 } else {
109 normalized
110 }
111 }
112 None => DEFAULT_HOST.to_string(),
113 });
114 self.personal_api_key = self.personal_api_key.and_then(|personal_api_key| {
115 let normalized = personal_api_key.trim().to_string();
116 if normalized.is_empty() {
117 None
118 } else {
119 Some(normalized)
120 }
121 });
122 self.endpoint_manager = EndpointManager::new(
123 self.host
124 .clone()
125 .expect("host is always normalized in sanitize"),
126 );
127 self
128 }
129}
130
131impl ClientOptionsBuilder {
132 pub fn build(&self) -> Result<ClientOptions, ClientOptionsBuilderError> {
138 Ok(self.build_unchecked()?.sanitize())
139 }
140}
141
142impl From<&str> for ClientOptions {
143 fn from(api_key: &str) -> Self {
144 ClientOptionsBuilder::default()
145 .api_key(api_key.to_string())
146 .build()
147 .expect("We always set the API key, so this is infallible")
148 }
149}
150
151impl From<(&str, &str)> for ClientOptions {
152 fn from((api_key, host): (&str, &str)) -> Self {
154 ClientOptionsBuilder::default()
155 .api_key(api_key.to_string())
156 .host(host.to_string())
157 .build()
158 .expect("We always set the API key, so this is infallible")
159 }
160}
161
162#[cfg(test)]
163mod tests {
164 use super::ClientOptionsBuilder;
165 use crate::endpoints::{EU_INGESTION_ENDPOINT, US_INGESTION_ENDPOINT};
166
167 #[test]
168 fn trims_whitespace_sensitive_options() {
169 let options = ClientOptionsBuilder::default()
170 .api_key(" \n test-api-key\t ".to_string())
171 .host(" \nhttps://eu.posthog.com/\t ")
172 .personal_api_key(" \n\t ")
173 .build()
174 .unwrap();
175
176 assert_eq!(options.api_key, "test-api-key");
177 assert_eq!(options.host.as_deref(), Some("https://eu.posthog.com/"));
178 assert_eq!(options.personal_api_key, None);
179 assert_eq!(options.endpoints().api_host(), EU_INGESTION_ENDPOINT);
180 }
181
182 #[test]
183 fn defaults_blank_host_after_trimming_whitespace() {
184 let options = ClientOptionsBuilder::default()
185 .api_key("test-api-key".to_string())
186 .host(" \n\t ")
187 .build()
188 .unwrap();
189
190 assert_eq!(options.host.as_deref(), Some(US_INGESTION_ENDPOINT));
191 assert_eq!(options.endpoints().api_host(), US_INGESTION_ENDPOINT);
192 }
193
194 #[test]
195 fn builder_allows_missing_api_key_and_disables_client() {
196 let options = ClientOptionsBuilder::default().build().unwrap();
197
198 assert_eq!(options.api_key, "");
199 assert!(options.is_disabled());
200 }
201
202 #[test]
203 fn builder_disables_client_for_trim_empty_api_key() {
204 let options = ClientOptionsBuilder::default()
205 .api_key(" \n\t ".to_string())
206 .build()
207 .unwrap();
208
209 assert_eq!(options.api_key, "");
210 assert!(options.is_disabled());
211 }
212}