1use std::collections::hash_map::HashMap;
4use std::default::Default;
5use std::fmt::{self, Debug, Display};
6use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
7use std::sync::{Arc, Mutex};
8use std::time::Duration;
9
10use arc_swap::ArcSwapOption;
11use chrono::Utc;
12use enum_map::{EnumArray, EnumMap};
13use futures_timer::Delay;
14use log::{debug, trace, warn};
15use rand::Rng;
16use serde::de::DeserializeOwned;
17use serde::Serialize;
18use uuid::Uuid;
19
20use crate::api::{
21 self, ConstraintExpression, Feature, Features, Metrics, MetricsBucket, Registration,
22 ToggleMetrics,
23};
24use crate::context::Context;
25use crate::http::{HttpClient, HTTP};
26use crate::strategy;
27
28#[derive(Clone, Debug, Default, Eq, PartialEq)]
33pub struct Variant {
34 pub name: String,
35 pub payload: HashMap<String, String>,
36 pub enabled: bool,
37}
38
39impl From<&CachedVariant> for Variant {
40 fn from(variant: &CachedVariant) -> Self {
41 Self {
42 name: variant.value.name.clone(),
43 payload: variant.value.payload.as_ref().cloned().unwrap_or_default(),
44 enabled: true,
45 }
46 }
47}
48
49impl Variant {
50 fn disabled() -> Self {
51 Self {
52 name: "disabled".into(),
53 ..Default::default()
54 }
55 }
56}
57
58pub struct ClientBuilder {
61 disable_metric_submission: bool,
62 enable_str_features: bool,
63 interval: u64,
64 strategies: HashMap<String, strategy::Strategy>,
65}
66
67impl ClientBuilder {
68 pub fn into_client<F, C>(
69 self,
70 api_url: &str,
71 app_name: &str,
72 instance_id: &str,
73 authorization: Option<String>,
74 ) -> Result<Client<F, C>, C::Error>
75 where
76 F: EnumArray<CachedFeature> + Debug + DeserializeOwned + Serialize,
77 C: HttpClient + Default,
78 {
79 let connection_id = Uuid::new_v4().to_string();
80 Ok(Client {
81 api_url: api_url.into(),
82 app_name: app_name.into(),
83 disable_metric_submission: self.disable_metric_submission,
84 enable_str_features: self.enable_str_features,
85 instance_id: instance_id.into(),
86 connection_id: connection_id.clone(),
87 interval: self.interval,
88 polling: AtomicBool::new(false),
89 http: HTTP::new(
90 app_name.into(),
91 instance_id.into(),
92 connection_id,
93 authorization,
94 )?,
95 cached_state: ArcSwapOption::from(None),
96 strategies: Mutex::new(self.strategies),
97 })
98 }
99
100 pub fn disable_metric_submission(mut self) -> Self {
101 self.disable_metric_submission = true;
102 self
103 }
104
105 pub fn enable_string_features(mut self) -> Self {
106 self.enable_str_features = true;
107 self
108 }
109
110 pub fn interval(mut self, interval: u64) -> Self {
111 self.interval = interval;
112 self
113 }
114
115 pub fn strategy(mut self, name: &str, strategy: strategy::Strategy) -> Self {
116 self.strategies.insert(name.into(), strategy);
117 self
118 }
119}
120
121impl Default for ClientBuilder {
122 fn default() -> ClientBuilder {
123 let result = ClientBuilder {
124 disable_metric_submission: false,
125 enable_str_features: false,
126 interval: 15000,
127 strategies: Default::default(),
128 };
129 result
130 .strategy("default", Box::new(&strategy::default))
131 .strategy("applicationHostname", Box::new(&strategy::hostname))
132 .strategy("default", Box::new(&strategy::default))
133 .strategy("gradualRolloutRandom", Box::new(&strategy::random))
134 .strategy("gradualRolloutSessionId", Box::new(&strategy::session_id))
135 .strategy("gradualRolloutUserId", Box::new(&strategy::user_id))
136 .strategy("remoteAddress", Box::new(&strategy::remote_address))
137 .strategy("userWithId", Box::new(&strategy::user_with_id))
138 .strategy("flexibleRollout", Box::new(&strategy::flexible_rollout))
139 }
140}
141
142#[derive(Default)]
143pub struct CachedFeature {
144 pub strategies: Vec<strategy::Evaluate>,
145 known: bool,
148 feature_disabled: bool,
151 enabled: AtomicU64,
155 disabled: AtomicU64,
156 disabled_variant_count: AtomicU64,
157 variants: Vec<CachedVariant>,
159}
160
161impl From<&CachedFeature> for ToggleMetrics {
162 fn from(feature: &CachedFeature) -> Self {
163 ToggleMetrics {
164 yes: feature.enabled.load(Ordering::Relaxed),
165 no: feature.disabled.load(Ordering::Relaxed),
166 variants: feature.variant_metrics(),
167 }
168 }
169}
170
171impl CachedFeature {
172 fn variant_metrics(&self) -> HashMap<String, u64> {
173 self.variants
174 .iter()
175 .map(|variant| {
176 (
177 variant.value.name.clone(),
178 variant.count.load(Ordering::Relaxed),
179 )
180 })
181 .chain([(
182 "disabled".into(),
183 self.disabled_variant_count.load(Ordering::Relaxed),
184 )])
185 .collect()
186 }
187}
188
189pub struct CachedVariant {
190 count: AtomicU64,
191 value: api::Variant,
192}
193
194impl Clone for CachedVariant {
195 fn clone(&self) -> Self {
196 Self {
197 count: AtomicU64::new(self.count.load(Ordering::Relaxed)),
198 value: self.value.clone(),
199 }
200 }
201}
202
203impl From<api::Variant> for CachedVariant {
204 fn from(variant: api::Variant) -> Self {
205 CachedVariant {
206 value: variant,
207 count: AtomicU64::new(0),
208 }
209 }
210}
211
212pub struct CachedState<F>
213where
214 F: EnumArray<CachedFeature>,
215{
216 start: chrono::DateTime<chrono::Utc>,
217 features: EnumMap<F, CachedFeature>,
220 str_features: HashMap<String, CachedFeature>,
221}
222
223impl<F> CachedState<F>
224where
225 F: EnumArray<CachedFeature>,
226{
227 pub fn str_features(&self) -> &HashMap<String, CachedFeature> {
229 &self.str_features
230 }
231}
232
233pub struct Client<F, C>
234where
235 F: EnumArray<CachedFeature> + Debug + DeserializeOwned + Serialize,
236 C: HttpClient,
237{
238 api_url: String,
239 app_name: String,
240 disable_metric_submission: bool,
241 enable_str_features: bool,
242 instance_id: String,
243 connection_id: String,
244 interval: u64,
245 polling: AtomicBool,
246 pub http: HTTP<C>,
248 strategies: Mutex<HashMap<String, strategy::Strategy>>,
250 cached_state: ArcSwapOption<CachedState<F>>,
252}
253
254trait Enabled<F>
255where
256 F: EnumArray<CachedFeature>,
257{
258 fn is_enabled(&self, feature_enum: F, context: Option<&Context>, default: bool) -> bool;
259 fn is_enabled_str(
260 &self,
261 feature_name: &str,
262 context: Option<&Context>,
263 default: bool,
264 cached_features: &ArcSwapOption<CachedState<F>>,
265 ) -> bool;
266}
267
268impl<F> Enabled<F> for &Arc<CachedState<F>>
269where
270 F: EnumArray<CachedFeature> + Clone + Debug + DeserializeOwned + Serialize,
271{
272 fn is_enabled(&self, feature_enum: F, context: Option<&Context>, default: bool) -> bool {
273 fn raw_enabled<F: Debug>(
274 feature: &CachedFeature,
275 feature_enum: F,
276 context: &Context,
277 default: bool,
278 ) -> bool {
279 if feature.strategies.is_empty() && feature.known && !feature.feature_disabled {
280 trace!("is_enabled: feature {feature_enum:?} has no strategies: enabling");
281 return true;
282 }
283 for memo in feature.strategies.iter() {
284 if memo(context) {
285 debug!(
286 "is_enabled: feature {feature_enum:?} enabled by memo {memo:p}, context {context:?}"
287 );
288 return true;
289 } else {
290 trace!(
292 "is_enabled: feature {feature_enum:?} not enabled by memo {memo:p}, context {context:?}"
293 );
294 }
295 }
296 if !feature.known {
297 debug!("is_enabled: Unknown feature {feature_enum:?}, using default {default}");
298 default
299 } else {
300 debug!("is_enabled: feature {feature_enum:?} failed all strategies, disabling");
302 false
303 }
304 }
305
306 trace!("is_enabled: feature {feature_enum:?} default {default}, context {context:?}");
307 let feature = &self.features[feature_enum.clone()];
308 let default_context = &Default::default();
309 let context = context.unwrap_or(default_context);
310
311 let feature_enabled = raw_enabled(feature, feature_enum, context, default);
312
313 if feature_enabled {
314 feature.enabled.fetch_add(1, Ordering::Relaxed);
315 true
316 } else {
317 feature.disabled.fetch_add(1, Ordering::Relaxed);
318 false
319 }
320 }
321
322 fn is_enabled_str(
323 &self,
324 feature_name: &str,
325 context: Option<&Context>,
326 default: bool,
327 cached_features: &ArcSwapOption<CachedState<F>>,
328 ) -> bool {
329 if let Some(feature) = &self.str_features.get(feature_name) {
330 let default_context: Context = Default::default();
331 let context = context.unwrap_or(&default_context);
332 if feature.strategies.is_empty() && feature.known && !feature.feature_disabled {
333 trace!("is_enabled: feature {feature_name} has no strategies: enabling");
334 feature.enabled.fetch_add(1, Ordering::Relaxed);
335 return true;
336 }
337 for memo in feature.strategies.iter() {
338 if memo(context) {
339 debug!(
340 "is_enabled: feature {feature_name} enabled by memo {memo:p}, context {context:?}"
341 );
342 feature.enabled.fetch_add(1, Ordering::Relaxed);
343 return true;
344 } else {
345 trace!(
347 "is_enabled: feature {feature_name} not enabled by memo {memo:p}, context {context:?}"
348 );
349 }
350 }
351 if !feature.known {
352 trace!("is_enabled: Unknown feature {feature_name}, using default {default}");
353 if default {
354 feature.enabled.fetch_add(1, Ordering::Relaxed);
355 } else {
356 feature.disabled.fetch_add(1, Ordering::Relaxed);
357 }
358 default
359 } else {
360 false
361 }
362 } else {
363 debug!("is_enabled: Unknown feature {feature_name}, using default {default}");
364 cached_features.rcu(|cached_state: &Option<Arc<CachedState<F>>>| {
366 if let Some(cached_state) = cached_state {
368 let cached_state = cached_state.clone();
369 if let Some(feature) = cached_state.str_features.get(feature_name) {
370 if default {
376 feature.enabled.fetch_add(1, Ordering::Relaxed);
377 } else {
378 feature.disabled.fetch_add(1, Ordering::Relaxed);
379 }
380 Some(cached_state)
381 } else {
382 let mut new_state = CachedState {
385 start: cached_state.start,
386 features: EnumMap::default(),
387 str_features: HashMap::new(),
388 };
389 fn cloned_feature(feature: &CachedFeature) -> CachedFeature {
390 CachedFeature {
391 disabled: AtomicU64::new(feature.disabled.load(Ordering::Relaxed)),
392 enabled: AtomicU64::new(feature.enabled.load(Ordering::Relaxed)),
393 disabled_variant_count: AtomicU64::new(
394 feature.disabled_variant_count.load(Ordering::Relaxed),
395 ),
396 known: feature.known,
397 feature_disabled: feature.feature_disabled,
398 strategies: feature.strategies.clone(),
399 variants: feature.variants.clone(),
400 }
401 }
402 for (key, feature) in &cached_state.features {
403 new_state.features[key] = cloned_feature(feature);
404 }
405 for (name, feature) in &cached_state.str_features {
406 new_state
407 .str_features
408 .insert(name.clone(), cloned_feature(feature));
409 }
410 let stub_feature = CachedFeature {
411 disabled: AtomicU64::new(if default { 0 } else { 1 }),
412 enabled: AtomicU64::new(if default { 1 } else { 0 }),
413 disabled_variant_count: AtomicU64::new(0),
414 known: false,
415 feature_disabled: false,
416 strategies: vec![],
417 variants: vec![],
418 };
419 new_state
420 .str_features
421 .insert(feature_name.into(), stub_feature);
422 Some(Arc::new(new_state))
423 }
424 } else {
425 None
426 }
427 });
428 default
429 }
430 }
431}
432
433impl<F, C> Client<F, C>
434where
435 F: EnumArray<CachedFeature> + Clone + Debug + DeserializeOwned + Serialize,
436 C: HttpClient + Default,
437{
438 pub fn cached_state(&self) -> arc_swap::Guard<Option<Arc<CachedState<F>>>> {
442 let cache = self.cached_state.load();
443 if cache.is_none() {
444 trace!("is_enabled: No API state");
446 }
447 cache
448 }
449
450 pub fn get_variant(&self, feature_enum: F, context: &Context) -> Variant {
458 trace!("get_variant: feature {feature_enum:?} context {context:?}");
459 let cache = self.cached_state();
460 let cache = match cache.as_ref() {
461 None => {
462 trace!("get_variant: feature {feature_enum:?} no cached state");
463 return Variant::disabled();
464 }
465 Some(cache) => cache,
466 };
467 let enabled = cache.is_enabled(feature_enum.clone(), Some(context), false);
468 let feature = &cache.features[feature_enum.clone()];
469 if !enabled {
470 feature
471 .disabled_variant_count
472 .fetch_add(1, Ordering::Relaxed);
473 return Variant::disabled();
474 }
475 let str_f = EnumToString(&feature_enum);
476 self._get_variant(feature, str_f, context)
477 }
478
479 pub fn get_variant_str(&self, feature_name: &str, context: &Context) -> Variant {
487 trace!("get_variant_Str: feature {feature_name} context {context:?}");
488 assert!(
489 self.enable_str_features,
490 "String feature lookup not enabled"
491 );
492 let cache = self.cached_state();
493 let cache = match cache.as_ref() {
494 None => {
495 trace!("get_variant_str: feature {feature_name} no cached state");
496 return Variant::disabled();
497 }
498 Some(cache) => cache,
499 };
500 let enabled = cache.is_enabled_str(feature_name, Some(context), false, &self.cached_state);
501 let feature = &cache.str_features.get(feature_name);
502 if !enabled {
503 match feature {
505 Some(f) => {
506 f.disabled_variant_count.fetch_add(1, Ordering::Relaxed);
507 }
508 None => {
509 if let Some(fresh_cache) = self.cached_state().as_ref() {
510 let _ = &fresh_cache
511 .str_features
512 .get(feature_name)
513 .map(|f| f.disabled_variant_count.fetch_add(1, Ordering::Relaxed));
514 }
515 }
516 }
517 return Variant::disabled();
518 }
519 match feature {
520 None => {
521 trace!("get_variant_str: feature {feature_name} enabled but not in cache");
522 Variant::disabled()
523 }
524 Some(feature) => self._get_variant(feature, feature_name, context),
525 }
526 }
527
528 fn _get_variant<N: Debug + Display>(
529 &self,
530 feature: &CachedFeature,
531 feature_name: N,
532 context: &Context,
533 ) -> Variant {
534 if feature.variants.is_empty() {
535 trace!("get_variant: feature {feature_name:?} no variants");
536 feature
537 .disabled_variant_count
538 .fetch_add(1, Ordering::Relaxed);
539 return Variant::disabled();
540 }
541 let group = format!("{feature_name}");
542 let mut remote_address: Option<String> = None;
543 let identifier = context
544 .user_id
545 .as_ref()
546 .or(context.session_id.as_ref())
547 .or_else(|| {
548 context.remote_address.as_ref().and_then({
549 |addr| {
550 remote_address = Some(format!("{addr:?}"));
551 remote_address.as_ref()
552 }
553 })
554 });
555 if identifier.is_none() {
556 trace!(
557 "get_variant: feature {feature_name:?} context has no identifiers, selecting randomly"
558 );
559 let mut rng = rand::rng();
560 let picked = rng.random_range(0..feature.variants.len());
561 feature.variants[picked]
562 .count
563 .fetch_add(1, Ordering::Relaxed);
564 return (&feature.variants[picked]).into();
565 }
566 let identifier = identifier.unwrap();
567 let total_weight = feature.variants.iter().map(|v| v.value.weight as u32).sum();
568 strategy::normalised_variant_hash(&group, identifier, total_weight)
569 .map(|selected_weight| {
570 let mut counter: u32 = 0;
571 for variant in feature.variants.iter().as_ref() {
572 counter += variant.value.weight as u32;
573 if counter >= selected_weight {
574 variant.count.fetch_add(1, Ordering::Relaxed);
575 return variant.into();
576 }
577 }
578
579 feature
580 .disabled_variant_count
581 .fetch_add(1, Ordering::Relaxed);
582 Variant::disabled()
583 })
584 .unwrap_or_else(|_| {
585 feature
586 .disabled_variant_count
587 .fetch_add(1, Ordering::Relaxed);
588
589 Variant::disabled()
590 })
591 }
592
593 pub fn is_enabled(&self, feature_enum: F, context: Option<&Context>, default: bool) -> bool {
594 trace!("is_enabled: feature {feature_enum:?} default {default}, context {context:?}");
595 let cache = self.cached_state();
596 let cache = match cache.as_ref() {
597 None => {
598 trace!("is_enabled: feature {feature_enum:?} no cached state");
599 return false;
600 }
601 Some(cache) => cache,
602 };
603 cache.is_enabled(feature_enum, context, default)
604 }
605
606 pub fn is_enabled_str(
607 &self,
608 feature_name: &str,
609 context: Option<&Context>,
610 default: bool,
611 ) -> bool {
612 trace!("is_enabled: feature_str {feature_name:?} default {default}, context {context:?}");
613 assert!(
614 self.enable_str_features,
615 "String feature lookup not enabled"
616 );
617 let cache = self.cached_state();
618 let cache = match cache.as_ref() {
619 None => return false,
620 Some(cache) => cache,
621 };
622 cache.is_enabled_str(feature_name, context, default, &self.cached_state)
623 }
624
625 pub fn memoize(
632 &self,
633 features: Vec<Feature>,
634 ) -> Result<Option<Metrics>, Box<dyn std::error::Error + Send + Sync>> {
635 let now = Utc::now();
636 trace!("memoize: start with {} features", features.len());
637 let source_strategies = self.strategies.lock().unwrap();
638 let mut unenumerated_features: HashMap<String, CachedFeature> = HashMap::new();
639 let mut cached_features: EnumMap<F, CachedFeature> = EnumMap::default();
640 for feature in features {
642 let cached_feature = {
643 if !feature.enabled {
644 let strategies = vec![];
646 CachedFeature {
647 strategies,
648 disabled: AtomicU64::new(0),
649 enabled: AtomicU64::new(0),
650 disabled_variant_count: AtomicU64::new(0),
651 known: true,
652 feature_disabled: true,
653 variants: vec![],
654 }
655 } else {
656 let mut strategies = vec![];
658 for api_strategy in feature.strategies {
659 if let Some(code_strategy) = source_strategies.get(&api_strategy.name) {
660 strategies.push(strategy::constrain(
661 api_strategy.constraints,
662 code_strategy,
663 api_strategy.parameters,
664 ));
665 }
666 }
669 let variants = feature
671 .variants
672 .unwrap_or_default()
673 .into_iter()
674 .filter(|v| v.weight > 0)
675 .map(Into::into)
676 .collect();
677 CachedFeature {
678 strategies,
679 disabled: AtomicU64::new(0),
680 enabled: AtomicU64::new(0),
681 disabled_variant_count: AtomicU64::new(0),
682 known: true,
683 feature_disabled: false,
684 variants,
685 }
686 }
687 };
688 if let Ok(feature_enum) = serde_plain::from_str::<F>(feature.name.as_str()) {
689 cached_features[feature_enum] = cached_feature;
690 } else {
691 unenumerated_features.insert(feature.name.clone(), cached_feature);
692 }
693 }
694 let new_cache = CachedState {
695 start: now,
696 features: cached_features,
697 str_features: unenumerated_features,
698 };
699 let old = self.cached_state.swap(Some(Arc::new(new_cache)));
701 trace!("memoize: swapped memoized state in");
702 if let Some(old) = old {
703 let mut bucket = MetricsBucket {
705 start: old.start,
706 stop: now,
707 toggles: HashMap::new(),
708 };
709 for (key, feature) in &old.features {
710 bucket.toggles.insert(
711 serde_plain::to_string(&key).unwrap(),
713 feature.into(),
714 );
715 }
716 if self.enable_str_features {
718 for (name, feature) in &old.str_features {
719 if feature.enabled.load(Ordering::Relaxed) != 0
720 || feature.disabled.load(Ordering::Relaxed) != 0
721 {
722 bucket.toggles.insert(name.clone(), feature.into());
723 }
724 }
725 }
726 let metrics = Metrics {
727 app_name: self.app_name.clone(),
728 instance_id: self.instance_id.clone(),
729 connection_id: self.connection_id.clone(),
730 bucket,
731 };
732 Ok(Some(metrics))
733 } else {
734 Ok(None)
735 }
736 }
737
738 pub async fn poll_for_updates(&self) {
746 let endpoint = Features::endpoint(&self.api_url);
748 let metrics_endpoint = Metrics::endpoint(&self.api_url);
749 self.polling.store(true, Ordering::Relaxed);
750 loop {
751 debug!("poll: retrieving features");
752 match self
753 .http
754 .get_json::<Features>(&endpoint, Some(self.interval))
755 .await
756 {
757 Ok(features) => {
758 for feature in &features.features {
759 for strategy in &feature.strategies {
760 if let Some(constraints) = &strategy.constraints {
761 for constraint in constraints {
762 if matches!(
763 &constraint.expression,
764 ConstraintExpression::Unknown(..)
765 ) {
766 warn!("Unknown or invalid constraint expression {:?} detected in strategy '{}' in feature toggle '{}'",
767 serde_json::to_string(&constraint.expression),
768 strategy.name,
769 feature.name);
770 }
771 }
772 }
773 }
774 }
775
776 match self.memoize(features.features) {
777 Ok(None) => {}
778 Ok(Some(metrics)) => {
779 if !self.disable_metric_submission {
780 let mut metrics_uploaded = false;
781 let res = self
782 .http
783 .post_json(&metrics_endpoint, metrics, Some(self.interval))
784 .await;
785 if let Ok(successful) = res {
786 if successful {
787 metrics_uploaded = true;
788 debug!("poll: uploaded feature metrics")
789 }
790 }
791 if !metrics_uploaded {
792 warn!("poll: error uploading feature metrics");
793 }
794 }
795 }
796 Err(err) => {
797 warn!("poll: failed to memoize features: {err:?}");
798 }
799 }
800 }
801 Err(err) => {
802 warn!("poll: failed to retrieve features: {err:?}");
803 }
804 }
805
806 let duration = Duration::from_millis(self.interval);
807 debug!("poll: waiting {duration:?}");
808 Delay::new(duration).await;
809
810 if !self.polling.load(Ordering::Relaxed) {
811 return;
812 }
813 }
814 }
815
816 pub async fn register(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync + 'static>> {
818 let registration = Registration {
819 app_name: self.app_name.clone(),
820 instance_id: self.instance_id.clone(),
821 connection_id: self.connection_id.clone(),
822 interval: self.interval,
823 strategies: self
824 .strategies
825 .lock()
826 .unwrap()
827 .keys()
828 .map(|s| s.to_owned())
829 .collect(),
830 ..Default::default()
831 };
832 let success = self
833 .http
834 .post_json(&Registration::endpoint(&self.api_url), ®istration, None)
835 .await
836 .map_err(|err| anyhow::anyhow!(err))?;
837 if !success {
838 return Err(anyhow::anyhow!("Failed to register with unleash API server").into());
839 }
840 Ok(())
841 }
842
843 pub async fn stop_poll(&self) {
849 loop {
850 match self
851 .polling
852 .compare_exchange(true, false, Ordering::Relaxed, Ordering::Relaxed)
853 {
854 Ok(_) => {
855 return;
856 }
857 Err(_) => {
858 Delay::new(Duration::from_millis(50)).await;
859 }
860 }
861 }
862 }
863}
864
865struct EnumToString<T>(T)
870where
871 T: Debug;
872
873impl<T> Debug for EnumToString<T>
874where
875 T: Debug,
876{
877 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> {
878 self.0.fmt(formatter)
879 }
880}
881
882impl<T> Display for EnumToString<T>
883where
884 T: Debug,
885{
886 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> {
887 self.0.fmt(formatter)
888 }
889}
890
891#[cfg(test)]
892mod tests {
893 use std::collections::hash_map::HashMap;
894 use std::collections::hash_set::HashSet;
895 use std::default::Default;
896 use std::hash::BuildHasher;
897 use std::sync::atomic::AtomicU64;
898
899 use enum_map::Enum;
900 use maplit::hashmap;
901 use serde::{Deserialize, Serialize};
902
903 use super::{ClientBuilder, Variant};
904 use crate::api::{self, Feature, Features, Strategy, ToggleMetrics};
905 use crate::client::{CachedFeature, CachedVariant};
906 use crate::context::{Context, IPAddress};
907 use crate::strategy;
908
909 cfg_if::cfg_if! {
910 if #[cfg(feature = "reqwest")] {
911 use reqwest::Client as HttpClient;
912 } else if #[cfg(feature = "reqwest-11")] {
913 use reqwest_11::Client as HttpClient;
914 } else {
915 compile_error!("Cannot run test suite without a client enabled");
916 }
917 }
918
919 fn features() -> Features {
920 Features {
921 version: 1,
922 features: vec![
923 Feature {
924 description: Some("default".to_string()),
925 enabled: true,
926 created_at: None,
927 variants: None,
928 name: "default".into(),
929 strategies: vec![Strategy {
930 name: "default".into(),
931 ..Default::default()
932 }],
933 },
934 Feature {
935 description: Some("userWithId".to_string()),
936 enabled: true,
937 created_at: None,
938 variants: None,
939 name: "userWithId".into(),
940 strategies: vec![Strategy {
941 name: "userWithId".into(),
942 parameters: Some(hashmap!["userIds".into()=>"present".into()]),
943 ..Default::default()
944 }],
945 },
946 Feature {
947 description: Some("userWithId+default".to_string()),
948 enabled: true,
949 created_at: None,
950 variants: None,
951 name: "userWithId+default".into(),
952 strategies: vec![
953 Strategy {
954 name: "userWithId".into(),
955 parameters: Some(hashmap!["userIds".into()=>"present".into()]),
956 ..Default::default()
957 },
958 Strategy {
959 name: "default".into(),
960 ..Default::default()
961 },
962 ],
963 },
964 Feature {
965 description: Some("disabled".to_string()),
966 enabled: false,
967 created_at: None,
968 variants: None,
969 name: "disabled".into(),
970 strategies: vec![Strategy {
971 name: "default".into(),
972 ..Default::default()
973 }],
974 },
975 Feature {
976 description: Some("nostrategies".to_string()),
977 enabled: true,
978 created_at: None,
979 variants: None,
980 name: "nostrategies".into(),
981 strategies: vec![],
982 },
983 ],
984 }
985 }
986
987 #[test]
988 fn test_memoization_enum() {
989 let _ = simple_logger::SimpleLogger::new()
990 .with_utc_timestamps()
991 .with_module_level("isahc::agent", log::LevelFilter::Off)
992 .with_module_level("tracing::span", log::LevelFilter::Off)
993 .with_module_level("tracing::span::active", log::LevelFilter::Off)
994 .init();
995 let f = features();
996 #[allow(non_camel_case_types)]
998 #[derive(Debug, Deserialize, Serialize, Enum, Clone)]
999 enum UserFeatures {
1000 unknown,
1001 default,
1002 userWithId,
1003 #[serde(rename = "userWithId+default")]
1004 userWithId_Default,
1005 disabled,
1006 nostrategies,
1007 }
1008 let c = ClientBuilder::default()
1009 .into_client::<UserFeatures, HttpClient>("http://127.0.0.1:1234/", "foo", "test", None)
1010 .unwrap();
1011
1012 c.memoize(f.features).unwrap();
1013 let present: Context = Context {
1014 user_id: Some("present".into()),
1015 ..Default::default()
1016 };
1017 let missing: Context = Context {
1018 user_id: Some("missing".into()),
1019 ..Default::default()
1020 };
1021 assert!(!c.is_enabled(UserFeatures::unknown, None, false));
1023 assert!(c.is_enabled(UserFeatures::unknown, None, true));
1024 assert!(c.is_enabled(UserFeatures::default, None, false));
1026 assert!(c.is_enabled(UserFeatures::userWithId, Some(&present), false));
1028 assert!(!c.is_enabled(UserFeatures::userWithId, Some(&missing), false));
1030 assert!(c.is_enabled(UserFeatures::userWithId_Default, Some(&missing), false));
1032 assert!(!c.is_enabled(UserFeatures::disabled, None, true));
1034 assert!(c.is_enabled(UserFeatures::nostrategies, None, false));
1036 }
1037
1038 #[test]
1039 fn test_memoization_strs() {
1040 let _ = simple_logger::SimpleLogger::new()
1041 .with_utc_timestamps()
1042 .with_module_level("isahc::agent", log::LevelFilter::Off)
1043 .with_module_level("tracing::span", log::LevelFilter::Off)
1044 .with_module_level("tracing::span::active", log::LevelFilter::Off)
1045 .init();
1046 let f = features();
1047 #[derive(Debug, Deserialize, Serialize, Enum, Clone)]
1049 enum NoFeatures {}
1050 let c = ClientBuilder::default()
1051 .enable_string_features()
1052 .into_client::<NoFeatures, HttpClient>("http://127.0.0.1:1234/", "foo", "test", None)
1053 .unwrap();
1054
1055 c.memoize(f.features).unwrap();
1056 let present: Context = Context {
1057 user_id: Some("present".into()),
1058 ..Default::default()
1059 };
1060 let missing: Context = Context {
1061 user_id: Some("missing".into()),
1062 ..Default::default()
1063 };
1064 assert!(!c.is_enabled_str("unknown", None, false));
1066 assert!(c.is_enabled_str("unknown", None, true));
1067 assert!(c.is_enabled_str("default", None, false));
1069 assert!(c.is_enabled_str("userWithId", Some(&present), false));
1071 assert!(!c.is_enabled_str("userWithId", Some(&missing), false));
1073 assert!(c.is_enabled_str("userWithId+default", Some(&missing), false));
1075 assert!(!c.is_enabled_str("disabled", None, true));
1077 assert!(c.is_enabled_str("nostrategies", None, false));
1079 }
1080
1081 fn _reversed_uids<S: BuildHasher>(
1082 parameters: Option<HashMap<String, String, S>>,
1083 ) -> strategy::Evaluate {
1084 let mut uids: HashSet<String> = HashSet::new();
1085 if let Some(parameters) = parameters {
1086 if let Some(uids_list) = parameters.get("userIds") {
1087 for uid in uids_list.split(',') {
1088 uids.insert(uid.chars().rev().collect());
1089 }
1090 }
1091 }
1092 Box::new(move |context: &Context| -> bool {
1093 context
1094 .user_id
1095 .as_ref()
1096 .map(|uid| uids.contains(uid))
1097 .unwrap_or(false)
1098 })
1099 }
1100
1101 #[test]
1102 fn test_custom_strategy() {
1103 let _ = simple_logger::SimpleLogger::new()
1104 .with_utc_timestamps()
1105 .with_module_level("isahc::agent", log::LevelFilter::Off)
1106 .with_module_level("tracing::span", log::LevelFilter::Off)
1107 .with_module_level("tracing::span::active", log::LevelFilter::Off)
1108 .init();
1109 #[allow(non_camel_case_types)]
1110 #[derive(Debug, Deserialize, Serialize, Enum, Clone)]
1111 enum UserFeatures {
1112 default,
1113 reversed,
1114 }
1115 let client = ClientBuilder::default()
1116 .strategy("reversed", Box::new(&_reversed_uids))
1117 .into_client::<UserFeatures, HttpClient>("http://127.0.0.1:1234/", "foo", "test", None)
1118 .unwrap();
1119
1120 let f = Features {
1121 version: 1,
1122 features: vec![
1123 Feature {
1124 description: Some("default".to_string()),
1125 enabled: true,
1126 created_at: None,
1127 variants: None,
1128 name: "default".into(),
1129 strategies: vec![Strategy {
1130 name: "default".into(),
1131 ..Default::default()
1132 }],
1133 },
1134 Feature {
1135 description: Some("reversed".to_string()),
1136 enabled: true,
1137 created_at: None,
1138 variants: None,
1139 name: "reversed".into(),
1140 strategies: vec![Strategy {
1141 name: "reversed".into(),
1142 parameters: Some(hashmap!["userIds".into()=>"abc".into()]),
1143 ..Default::default()
1144 }],
1145 },
1146 ],
1147 };
1148 client.memoize(f.features).unwrap();
1149 let present: Context = Context {
1150 user_id: Some("cba".into()),
1151 ..Default::default()
1152 };
1153 let missing: Context = Context {
1154 user_id: Some("abc".into()),
1155 ..Default::default()
1156 };
1157 assert!(client.is_enabled(UserFeatures::reversed, Some(&present), false));
1159 assert!(!client.is_enabled(UserFeatures::reversed, Some(&missing), false));
1161 assert!(client.is_enabled(UserFeatures::default, None, false));
1164 }
1165
1166 fn variant_features() -> Features {
1167 Features {
1168 version: 1,
1169 features: vec![
1170 Feature {
1171 description: Some("disabled".to_string()),
1172 enabled: false,
1173 created_at: None,
1174 variants: None,
1175 name: "disabled".into(),
1176 strategies: vec![],
1177 },
1178 Feature {
1179 description: Some("novariants".to_string()),
1180 enabled: true,
1181 created_at: None,
1182 variants: None,
1183 name: "novariants".into(),
1184 strategies: vec![Strategy {
1185 name: "default".into(),
1186 ..Default::default()
1187 }],
1188 },
1189 Feature {
1190 description: Some("one".to_string()),
1191 enabled: true,
1192 created_at: None,
1193 variants: Some(vec![api::Variant {
1194 name: "variantone".into(),
1195 weight: 100,
1196 payload: Some(hashmap![
1197 "type".into() => "string".into(),
1198 "value".into() => "val1".into()]),
1199 overrides: None,
1200 }]),
1201 name: "one".into(),
1202 strategies: vec![],
1203 },
1204 Feature {
1205 description: Some("two".to_string()),
1206 enabled: true,
1207 created_at: None,
1208 variants: Some(vec![
1209 api::Variant {
1210 name: "variantone".into(),
1211 weight: 50,
1212 payload: Some(hashmap![
1213 "type".into() => "string".into(),
1214 "value".into() => "val1".into()]),
1215 overrides: None,
1216 },
1217 api::Variant {
1218 name: "varianttwo".into(),
1219 weight: 50,
1220 payload: Some(hashmap![
1221 "type".into() => "string".into(),
1222 "value".into() => "val2".into()]),
1223 overrides: None,
1224 },
1225 ]),
1226 name: "two".into(),
1227 strategies: vec![],
1228 },
1229 Feature {
1230 description: Some("nostrategies".to_string()),
1231 enabled: true,
1232 created_at: None,
1233 variants: None,
1234 name: "nostrategies".into(),
1235 strategies: vec![],
1236 },
1237 ],
1238 }
1239 }
1240
1241 #[test]
1242 fn variants_enum() {
1243 let _ = simple_logger::SimpleLogger::new()
1244 .with_utc_timestamps()
1245 .with_module_level("isahc::agent", log::LevelFilter::Off)
1246 .with_module_level("tracing::span", log::LevelFilter::Off)
1247 .with_module_level("tracing::span::active", log::LevelFilter::Off)
1248 .init();
1249 let f = variant_features();
1250 #[allow(non_camel_case_types)]
1252 #[derive(Debug, Deserialize, Serialize, Enum, Clone)]
1253 enum UserFeatures {
1254 disabled,
1255 novariants,
1256 one,
1257 two,
1258 }
1259 let c = ClientBuilder::default()
1260 .into_client::<UserFeatures, HttpClient>("http://127.0.0.1:1234/", "foo", "test", None)
1261 .unwrap();
1262
1263 c.memoize(f.features).unwrap();
1264
1265 let variant = Variant::disabled();
1267 assert_eq!(
1268 variant,
1269 c.get_variant(UserFeatures::disabled, &Context::default())
1270 );
1271
1272 let variant = Variant::disabled();
1274 assert_eq!(
1275 variant,
1276 c.get_variant(UserFeatures::novariants, &Context::default())
1277 );
1278
1279 let variant = Variant {
1281 name: "variantone".to_string(),
1282 payload: hashmap![
1283 "type".into()=>"string".into(),
1284 "value".into()=>"val1".into()
1285 ],
1286 enabled: true,
1287 };
1288 assert_eq!(
1289 variant,
1290 c.get_variant(UserFeatures::one, &Context::default())
1291 );
1292
1293 let uid1: Context = Context {
1295 user_id: Some("user1".into()),
1296 ..Default::default()
1297 };
1298 let session1: Context = Context {
1299 session_id: Some("session1".into()),
1300 ..Default::default()
1301 };
1302 let host1: Context = Context {
1303 remote_address: Some(IPAddress("10.10.10.10".parse().unwrap())),
1304 ..Default::default()
1305 };
1306 let variant1 = Variant {
1307 name: "variantone".to_string(),
1308 payload: hashmap![
1309 "type".into()=>"string".into(),
1310 "value".into()=>"val1".into()
1311 ],
1312 enabled: true,
1313 };
1314 let variant2 = Variant {
1315 name: "varianttwo".to_string(),
1316 payload: hashmap![
1317 "type".into()=>"string".into(),
1318 "value".into()=>"val2".into()
1319 ],
1320 enabled: true,
1321 };
1322 assert_eq!(variant1, c.get_variant(UserFeatures::two, &uid1));
1323 assert_eq!(variant2, c.get_variant(UserFeatures::two, &session1));
1324 assert_eq!(variant1, c.get_variant(UserFeatures::two, &host1));
1325 }
1326
1327 #[test]
1328 fn variants_str() {
1329 let _ = simple_logger::SimpleLogger::new()
1330 .with_utc_timestamps()
1331 .with_module_level("isahc::agent", log::LevelFilter::Off)
1332 .with_module_level("tracing::span", log::LevelFilter::Off)
1333 .with_module_level("tracing::span::active", log::LevelFilter::Off)
1334 .init();
1335 let f = variant_features();
1336 #[derive(Debug, Deserialize, Serialize, Enum, Clone)]
1338 enum NoFeatures {}
1339 let c = ClientBuilder::default()
1340 .enable_string_features()
1341 .into_client::<NoFeatures, HttpClient>("http://127.0.0.1:1234/", "foo", "test", None)
1342 .unwrap();
1343
1344 c.memoize(f.features).unwrap();
1345
1346 let variant = Variant::disabled();
1348 assert_eq!(variant, c.get_variant_str("disabled", &Context::default()));
1349
1350 let variant = Variant::disabled();
1352 assert_eq!(
1353 variant,
1354 c.get_variant_str("novariants", &Context::default())
1355 );
1356
1357 let variant = Variant {
1359 name: "variantone".to_string(),
1360 payload: hashmap![
1361 "type".into()=>"string".into(),
1362 "value".into()=>"val1".into()
1363 ],
1364 enabled: true,
1365 };
1366 assert_eq!(variant, c.get_variant_str("one", &Context::default()));
1367
1368 let uid1: Context = Context {
1370 user_id: Some("user1".into()),
1371 ..Default::default()
1372 };
1373 let session1: Context = Context {
1374 session_id: Some("session1".into()),
1375 ..Default::default()
1376 };
1377 let host1: Context = Context {
1378 remote_address: Some(IPAddress("10.10.10.10".parse().unwrap())),
1379 ..Default::default()
1380 };
1381 let variant1 = Variant {
1382 name: "variantone".to_string(),
1383 payload: hashmap![
1384 "type".into()=>"string".into(),
1385 "value".into()=>"val1".into()
1386 ],
1387 enabled: true,
1388 };
1389 let variant2 = Variant {
1390 name: "varianttwo".to_string(),
1391 payload: hashmap![
1392 "type".into()=>"string".into(),
1393 "value".into()=>"val2".into()
1394 ],
1395 enabled: true,
1396 };
1397 assert_eq!(variant1, c.get_variant_str("two", &uid1));
1398 assert_eq!(variant2, c.get_variant_str("two", &session1));
1399 assert_eq!(variant1, c.get_variant_str("two", &host1));
1400 }
1401
1402 #[test]
1403 fn variant_metrics() {
1404 let _ = simple_logger::SimpleLogger::new()
1405 .with_utc_timestamps()
1406 .with_module_level("isahc::agent", log::LevelFilter::Off)
1407 .with_module_level("tracing::span", log::LevelFilter::Off)
1408 .with_module_level("tracing::span::active", log::LevelFilter::Off)
1409 .init();
1410 let f = variant_features();
1411 #[allow(non_camel_case_types)]
1413 #[derive(Debug, Deserialize, Serialize, Enum, Clone)]
1414 enum UserFeatures {
1415 disabled,
1416 novariants,
1417 one,
1418 two,
1419 }
1420 let c = ClientBuilder::default()
1421 .into_client::<UserFeatures, HttpClient>("http://127.0.0.1:1234/", "foo", "test", None)
1422 .unwrap();
1423
1424 c.memoize(f.features).unwrap();
1425
1426 let disabled_variant_count = |feature_name| -> u64 {
1427 *c.cached_state().clone().expect("No cached state").features[feature_name]
1428 .variant_metrics()
1429 .get("disabled")
1430 .unwrap()
1431 };
1432
1433 c.get_variant(UserFeatures::disabled, &Context::default());
1434 assert_eq!(disabled_variant_count(UserFeatures::disabled), 1);
1435
1436 c.get_variant(UserFeatures::novariants, &Context::default());
1437 assert_eq!(disabled_variant_count(UserFeatures::novariants), 1);
1438
1439 let session1: Context = Context {
1440 session_id: Some("session1".into()),
1441 ..Default::default()
1442 };
1443
1444 let host1: Context = Context {
1445 remote_address: Some(IPAddress("10.10.10.10".parse().unwrap())),
1446 ..Default::default()
1447 };
1448 c.get_variant(UserFeatures::two, &session1);
1449 c.get_variant(UserFeatures::two, &host1);
1450
1451 let variant_count = |feature_name, variant_name| -> u64 {
1452 *c.cached_state().clone().expect("No cached state").features[feature_name]
1453 .variant_metrics()
1454 .get(variant_name)
1455 .unwrap()
1456 };
1457
1458 assert_eq!(variant_count(UserFeatures::two, "variantone"), 1);
1459 assert_eq!(variant_count(UserFeatures::two, "varianttwo"), 1);
1460 }
1461
1462 #[test]
1463 fn variant_metrics_str() {
1464 let _ = simple_logger::SimpleLogger::new()
1465 .with_utc_timestamps()
1466 .with_module_level("isahc::agent", log::LevelFilter::Off)
1467 .with_module_level("tracing::span", log::LevelFilter::Off)
1468 .with_module_level("tracing::span::active", log::LevelFilter::Off)
1469 .init();
1470 let f = variant_features();
1471 #[allow(non_camel_case_types)]
1473 #[derive(Debug, Deserialize, Serialize, Enum, Clone)]
1474 enum NoFeatures {}
1475 let c = ClientBuilder::default()
1476 .enable_string_features()
1477 .into_client::<NoFeatures, HttpClient>("http://127.0.0.1:1234/", "foo", "test", None)
1478 .unwrap();
1479
1480 c.memoize(f.features).unwrap();
1481
1482 let disabled_variant_count = |feature_name| -> u64 {
1483 *c.cached_state()
1484 .clone()
1485 .expect("No cached state")
1486 .str_features
1487 .get(feature_name)
1488 .expect("No feature named {feature_name}")
1489 .variant_metrics()
1490 .get("disabled")
1491 .unwrap()
1492 };
1493
1494 c.get_variant_str("disabled", &Context::default());
1495 assert_eq!(disabled_variant_count("disabled"), 1);
1496
1497 c.get_variant_str("novariants", &Context::default());
1498 assert_eq!(disabled_variant_count("novariants"), 1);
1499
1500 let session1: Context = Context {
1501 session_id: Some("session1".into()),
1502 ..Default::default()
1503 };
1504
1505 let host1: Context = Context {
1506 remote_address: Some(IPAddress("10.10.10.10".parse().unwrap())),
1507 ..Default::default()
1508 };
1509 c.get_variant_str("two", &session1);
1510 c.get_variant_str("two", &host1);
1511
1512 let variant_count = |feature_name, variant_name| -> u64 {
1513 *c.cached_state()
1514 .clone()
1515 .expect("No cached state")
1516 .str_features
1517 .get(feature_name)
1518 .expect("No feature named {feature_name}")
1519 .variant_metrics()
1520 .get(variant_name)
1521 .unwrap()
1522 };
1523
1524 assert_eq!(variant_count("two", "variantone"), 1);
1525 assert_eq!(variant_count("two", "varianttwo"), 1);
1526
1527 c.get_variant_str("nonexistent-feature", &Context::default());
1529 assert_eq!(variant_count("nonexistent-feature", "disabled"), 1);
1530
1531 c.get_variant_str("nonexistent-feature", &Context::default());
1532 assert_eq!(variant_count("nonexistent-feature", "disabled"), 2);
1533
1534 c.is_enabled_str("bogus-feature", None, false);
1536 assert_eq!(variant_count("bogus-feature", "disabled"), 0);
1537 }
1538
1539 #[test]
1540 fn cached_feature_into_toggle_metrics() {
1541 let variant_counts = [("a", 36), ("b", 16), ("c", 42)];
1542
1543 let variants = variant_counts
1544 .iter()
1545 .map(|(name, count)| CachedVariant {
1546 count: AtomicU64::from(*count),
1547 value: api::Variant {
1548 name: (*name).into(),
1549 weight: 0,
1550 payload: None,
1551 overrides: None,
1552 },
1553 })
1554 .collect();
1555
1556 let yes_count = 85;
1557 let no_count = 364;
1558 let disabled_variant_count = 56;
1559
1560 let feature = CachedFeature {
1561 strategies: vec![],
1562 disabled: AtomicU64::new(no_count),
1563 enabled: AtomicU64::new(yes_count),
1564 known: true,
1565 feature_disabled: true,
1566 variants,
1567 disabled_variant_count: AtomicU64::new(disabled_variant_count),
1568 };
1569
1570 let metrics: ToggleMetrics = (&feature).into();
1571
1572 assert_eq!(metrics.yes, yes_count);
1573 assert_eq!(metrics.no, no_count);
1574
1575 let converted_metrics = metrics.variants;
1576 for (variant, count) in variant_counts {
1577 assert_eq!(*converted_metrics.get(variant).unwrap(), count);
1578 }
1579
1580 assert_eq!(
1581 *converted_metrics.get("disabled").unwrap(),
1582 disabled_variant_count
1583 )
1584 }
1585}