growthbook_rust/
client.rs

1use std::collections::HashMap;
2use std::fmt::Debug;
3use std::sync::{Arc, RwLock};
4use std::time::Duration;
5
6use tokio::time::sleep;
7use tracing::error;
8
9use crate::cache::{FeatureCache, InMemoryCache};
10use crate::dto::GrowthBookResponse;
11use crate::env::Environment;
12use crate::error::GrowthbookError;
13use crate::gateway::GrowthbookGateway;
14use crate::growthbook::GrowthBook;
15use crate::model_public::{ExperimentResult, FeatureResult, GrowthBookAttribute};
16
17pub type OnFeatureUsageCallback = Arc<dyn Fn(String, FeatureResult) + Send + Sync>;
18pub type OnExperimentViewedCallback = Arc<dyn Fn(ExperimentResult) + Send + Sync>;
19pub type OnRefreshCallback = Arc<dyn Fn() + Send + Sync>; // Keeping it simple for now, maybe pass features later if needed
20
21#[derive(Clone)]
22pub struct GrowthBookClient {
23    pub gb: Arc<RwLock<GrowthBook>>,
24    pub cache: Option<Arc<dyn FeatureCache>>,
25    gateway: Option<Arc<GrowthbookGateway>>,
26    auto_refresh: bool,
27    refresh_interval: Duration,
28    pub on_feature_usage: Option<OnFeatureUsageCallback>,
29    pub on_experiment_viewed: Option<OnExperimentViewedCallback>,
30    pub on_refresh: Vec<OnRefreshCallback>,
31    pub decryption_key: Option<String>,
32}
33
34impl Debug for GrowthBookClient {
35    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
36        f.debug_struct("GrowthBookClient")
37            .field("gb", &self.gb)
38            .field("auto_refresh", &self.auto_refresh)
39            .field("refresh_interval", &self.refresh_interval)
40            .field("on_feature_usage", &self.on_feature_usage.is_some())
41            .field("on_experiment_viewed", &self.on_experiment_viewed.is_some())
42            .field("on_refresh", &self.on_refresh.len())
43            .field("decryption_key", &self.decryption_key.is_some())
44            .finish()
45    }
46}
47
48pub struct GrowthBookClientBuilder {
49    api_url: Option<String>,
50    client_key: Option<String>,
51    cache: Option<Arc<dyn FeatureCache>>,
52    ttl: Option<Duration>,
53    auto_refresh: bool,
54    refresh_interval: Option<Duration>,
55    attributes: Option<HashMap<String, GrowthBookAttribute>>,
56    on_feature_usage: Option<OnFeatureUsageCallback>,
57    on_experiment_viewed: Option<OnExperimentViewedCallback>,
58    on_refresh: Vec<OnRefreshCallback>,
59    features: Option<HashMap<String, crate::dto::GrowthBookFeature>>,
60    decryption_key: Option<String>,
61}
62
63impl GrowthBookClientBuilder {
64    pub fn new() -> Self {
65        Self {
66            api_url: None,
67            client_key: None,
68            cache: None,
69            ttl: None,
70            auto_refresh: false,
71            refresh_interval: None,
72            attributes: None,
73            on_feature_usage: None,
74            on_experiment_viewed: None,
75            on_refresh: Vec::new(),
76            features: None,
77            decryption_key: None,
78        }
79    }
80
81    pub fn api_url(mut self, api_url: String) -> Self {
82        self.api_url = Some(api_url);
83        self
84    }
85
86    pub fn client_key(mut self, client_key: String) -> Self {
87        self.client_key = Some(client_key);
88        self
89    }
90
91    pub fn cache(mut self, cache: Arc<dyn FeatureCache>) -> Self {
92        self.cache = Some(cache);
93        self
94    }
95
96    pub fn ttl(mut self, ttl: Duration) -> Self {
97        self.ttl = Some(ttl);
98        self
99    }
100
101    pub fn auto_refresh(mut self, auto_refresh: bool) -> Self {
102        self.auto_refresh = auto_refresh;
103        self
104    }
105
106    pub fn refresh_interval(mut self, interval: Duration) -> Self {
107        self.refresh_interval = Some(interval);
108        self
109    }
110
111    pub fn attributes(mut self, attributes: HashMap<String, GrowthBookAttribute>) -> Self {
112        self.attributes = Some(attributes);
113        self
114    }
115
116    pub fn on_feature_usage(mut self, callback: Box<dyn Fn(String, FeatureResult) + Send + Sync>) -> Self {
117        self.on_feature_usage = Some(Arc::from(callback));
118        self
119    }
120
121    pub fn on_experiment_viewed(mut self, callback: Box<dyn Fn(ExperimentResult) + Send + Sync>) -> Self {
122        self.on_experiment_viewed = Some(Arc::from(callback));
123        self
124    }
125
126    pub fn add_on_refresh(mut self, callback: Box<dyn Fn() + Send + Sync>) -> Self {
127        self.on_refresh.push(Arc::from(callback));
128        self
129    }
130
131    pub fn features(mut self, features: HashMap<String, crate::dto::GrowthBookFeature>) -> Self {
132        self.features = Some(features);
133        self
134    }
135
136    pub fn features_json(mut self, features_json: serde_json::Value) -> Result<Self, serde_json::Error> {
137        let features: HashMap<String, crate::dto::GrowthBookFeature> = serde_json::from_value(features_json)?;
138        self.features = Some(features);
139        Ok(self)
140    }
141
142    pub fn decryption_key(mut self, decryption_key: String) -> Self {
143        self.decryption_key = Some(decryption_key);
144        self
145    }
146
147    pub async fn build(self) -> Result<GrowthBookClient, GrowthbookError> {
148        let api_url = self.api_url.ok_or(GrowthbookError::new(crate::error::GrowthbookErrorCode::ConfigError, "API URL is required"))?;
149        let client_key = self.client_key.ok_or(GrowthbookError::new(crate::error::GrowthbookErrorCode::ConfigError, "Client Key is required"))?;
150        
151        let refresh_interval = self.refresh_interval.unwrap_or_else(|| {
152            let seconds = Environment::u64_or_default("GB_UPDATE_INTERVAL", 60);
153            Duration::from_secs(seconds)
154        });
155
156        let gateway = GrowthbookGateway::new(&api_url, &client_key, Duration::from_secs(10))?;
157        let gateway_arc = Arc::new(gateway);
158
159        let cache = self.cache.unwrap_or_else(|| {
160            let ttl = self.ttl.unwrap_or(Duration::from_secs(60));
161            Arc::new(InMemoryCache::new(ttl))
162        });
163
164        let client = GrowthBookClient {
165            gb: Arc::new(RwLock::new(GrowthBook {
166                forced_variations: None,
167                features: self.features.unwrap_or_default(),
168                attributes: self.attributes,
169            })),
170            cache: Some(cache),
171            gateway: Some(gateway_arc),
172            auto_refresh: self.auto_refresh,
173            refresh_interval,
174            on_feature_usage: self.on_feature_usage,
175            on_experiment_viewed: self.on_experiment_viewed,
176            on_refresh: self.on_refresh,
177            decryption_key: self.decryption_key,
178        };
179
180        // Initial load
181        client.refresh().await;
182
183        if client.auto_refresh {
184            client.start_auto_refresh();
185        }
186
187        Ok(client)
188    }
189}
190
191impl GrowthBookClient {
192    pub async fn refresh(&self) {
193        if let Some(gateway) = &self.gateway {
194            let cache_key = "features";
195            
196            // Try cache first
197            if let Some(cache) = &self.cache {
198                if let Some(response) = cache.get(cache_key).await {
199                    self.update_gb(response);
200                    return;
201                }
202            }
203
204            // Fetch from network
205            match gateway.get_features(None).await {
206                Ok(response) => {
207                    // Update cache
208                    if let Some(cache) = &self.cache {
209                        cache.set(cache_key, response.clone()).await;
210                    }
211                    self.update_gb(response);
212                },
213                Err(e) => {
214                    error!("[growthbook-sdk] Failed to fetch features: {:?}", e);
215                }
216            }
217        }
218    }
219
220    fn update_gb(&self, response: GrowthBookResponse) {
221        let mut features = response.features;
222
223        if let Some(encrypted_features) = response.encrypted_features {
224            if let Some(key) = &self.decryption_key {
225                match decrypt_features(&encrypted_features, key) {
226                    Ok(decrypted) => {
227                        if let Ok(parsed_features) = serde_json::from_str(&decrypted) {
228                            features = Some(parsed_features);
229                        } else {
230                            error!("[growthbook-sdk] Failed to parse decrypted features");
231                        }
232                    },
233                    Err(e) => {
234                        error!("[growthbook-sdk] Failed to decrypt features: {:?}", e);
235                    }
236                }
237            } else {
238                error!("[growthbook-sdk] Encrypted features received but no decryption key provided");
239            }
240        }
241
242        let mut writable_config = self.gb.write().expect("problem to create mutex for gb data");
243        let attributes = writable_config.attributes.clone();
244        *writable_config = GrowthBook {
245            forced_variations: response.forced_variations,
246            features: features.unwrap_or_default(),
247            attributes,
248        };
249        
250        for callback in &self.on_refresh {
251            callback();
252        }
253    }
254
255    pub fn start_auto_refresh(&self) {
256        let client = self.clone();
257        tokio::spawn(async move {
258            loop {
259                sleep(client.refresh_interval).await;
260                client.refresh().await;
261            }
262        });
263    }
264
265    // Keep existing new method for backward compatibility, 
266    // Old new: spawned a task immediately.
267    pub async fn new(
268        api_url: &str,
269        sdk_key: &str,
270        update_interval: Option<Duration>,
271        _http_timeout: Option<Duration>,
272    ) -> Result<Self, GrowthbookError> {
273        let mut builder = GrowthBookClientBuilder::new()
274            .api_url(api_url.to_string())
275            .client_key(sdk_key.to_string())
276            .auto_refresh(true)
277            .ttl(Duration::from_secs(0)); // Disable caching for legacy new() to match old behavior
278        
279        // Legacy new doesn't support setting callbacks, so they default to None
280        if let Some(interval) = update_interval {
281            builder = builder.refresh_interval(interval);
282        }
283        
284        builder.build().await
285    }
286
287    fn read_gb(&self) -> GrowthBook {
288        match self.gb.read() {
289            Ok(rw_read_guard) => (*rw_read_guard).clone(),
290            Err(e) => {
291                error!("{}", format!("[growthbook-sdk] problem to reading gb mutex data returning empty {:?}", e));
292                GrowthBook {
293                    forced_variations: None,
294                    features: HashMap::new(),
295                    attributes: None,
296                }
297            },
298        }
299    }
300    fn resolve_feature(
301        &self,
302        feature_name: &str,
303        user_attributes: Option<Vec<GrowthBookAttribute>>,
304    ) -> FeatureResult {
305        let result = self.read_gb().check(feature_name, &user_attributes);
306
307        // 1. Trigger on_feature_usage only for successful evaluations
308        // Exclude: unknownFeature, prerequisite, cyclicPrerequisite
309        let invalid_sources = ["unknownFeature", "prerequisite", "cyclicPrerequisite"];
310        if !invalid_sources.contains(&result.source.as_str()) {
311             if let Some(cb) = &self.on_feature_usage {
312                cb(feature_name.to_string(), result.clone());
313            }
314        }
315
316        // 2. Trigger on_experiment_viewed only if in_experiment is true
317        if let Some(cb) = &self.on_experiment_viewed {
318            if let Some(experiment_result) = &result.experiment_result {
319                if experiment_result.in_experiment {
320                    cb(experiment_result.clone());
321                }
322            }
323        }
324
325        result
326    }
327}
328
329pub trait GrowthBookClientTrait: Debug + Send + Sync {
330    fn is_on(
331        &self,
332        feature_name: &str,
333        user_attributes: Option<Vec<GrowthBookAttribute>>,
334    ) -> bool;
335
336    fn is_off(
337        &self,
338        feature_name: &str,
339        user_attributes: Option<Vec<GrowthBookAttribute>>,
340    ) -> bool;
341
342    fn feature_result(
343        &self,
344        feature_name: &str,
345        user_attributes: Option<Vec<GrowthBookAttribute>>,
346    ) -> FeatureResult;
347
348    fn total_features(&self) -> usize;
349}
350
351impl GrowthBookClientTrait for GrowthBookClient {
352    fn is_on(
353        &self,
354        feature_name: &str,
355        user_attributes: Option<Vec<GrowthBookAttribute>>,
356    ) -> bool {
357        self.resolve_feature(feature_name, user_attributes).on
358    }
359
360    fn is_off(
361        &self,
362        feature_name: &str,
363        user_attributes: Option<Vec<GrowthBookAttribute>>,
364    ) -> bool {
365        self.resolve_feature(feature_name, user_attributes).off
366    }
367
368    fn feature_result(
369        &self,
370        feature_name: &str,
371        user_attributes: Option<Vec<GrowthBookAttribute>>,
372    ) -> FeatureResult {
373        self.resolve_feature(feature_name, user_attributes)
374    }
375
376    fn total_features(&self) -> usize {
377        let gb_data = self.read_gb();
378        gb_data.features.len()
379    }
380}
381
382use aes::cipher::{block_padding::Pkcs7, BlockDecryptMut, KeyIvInit};
383use base64::{engine::general_purpose, Engine as _};
384
385type Aes128CbcDec = cbc::Decryptor<aes::Aes128>;
386
387fn decrypt_features(encrypted_features: &str, key: &str) -> Result<String, Box<dyn std::error::Error>> {
388    let parts: Vec<&str> = encrypted_features.split('.').collect();
389    if parts.len() != 2 {
390        return Err("Invalid encrypted features format".into());
391    }
392
393    let iv = general_purpose::STANDARD.decode(parts[0])?;
394    let mut ciphertext = general_purpose::STANDARD.decode(parts[1])?; // Mutable for in-place decryption
395    let key_bytes = general_purpose::STANDARD.decode(key)?;
396
397    if key_bytes.len() != 16 {
398        return Err("Invalid key length".into());
399    }
400
401    let decryptor = Aes128CbcDec::new_from_slices(&key_bytes, &iv)
402        .map_err(|_| "Invalid key or IV length")?;
403    
404    // Decrypt in-place
405    let plaintext_len = decryptor
406        .decrypt_padded_mut::<Pkcs7>(&mut ciphertext)
407        .map_err(|_| "Decryption failed (padding error)")?
408        .len();
409    
410    // Truncate to actual plaintext length (though decrypt_padded_mut returns slice, we modified vec)
411    ciphertext.truncate(plaintext_len);
412
413    Ok(String::from_utf8(ciphertext)?)
414}