• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

Unleash / unleash-types-rs / #292

31 Jul 2025 02:12PM UTC coverage: 79.344%. Remained the same
#292

push

242 of 305 relevant lines covered (79.34%)

1.91 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

73.26
/src/client_features.rs
1
#[cfg(feature = "hashes")]
2
use base64::Engine;
3
use std::collections::HashMap;
4
use std::hash::{Hash, Hasher};
5
use std::{cmp::Ordering, collections::BTreeMap};
6
#[cfg(feature = "openapi")]
7
use utoipa::{IntoParams, ToSchema};
8

9
use chrono::{DateTime, Utc};
10
use serde::{Deserialize, Deserializer, Serialize, Serializer};
11
use serde_json::{Map, Value};
12
#[cfg(feature = "hashes")]
13
use xxhash_rust::xxh3::xxh3_128;
14

15
use crate::{Deduplicate, Merge, Upsert};
16

17
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash)]
18
#[cfg_attr(feature = "openapi", derive(ToSchema, IntoParams))]
19
#[serde(rename_all = "camelCase")]
20
pub struct Query {
21
    #[serde(skip_serializing_if = "Option::is_none")]
22
    pub tags: Option<Vec<Vec<String>>>,
23
    #[serde(skip_serializing_if = "Option::is_none")]
24
    pub projects: Option<Vec<String>>,
25
    #[serde(skip_serializing_if = "Option::is_none")]
26
    pub name_prefix: Option<String>,
27
    #[serde(skip_serializing_if = "Option::is_none")]
28
    pub environment: Option<String>,
29
    #[serde(skip_serializing_if = "Option::is_none")]
30
    pub inline_segment_constraints: Option<bool>,
31
}
32

33
#[derive(Serialize, Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
34
#[cfg_attr(feature = "openapi", derive(ToSchema))]
35
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
36
pub enum Operator {
37
    NotIn,
38
    In,
39
    StrEndsWith,
40
    StrStartsWith,
41
    StrContains,
42
    NumEq,
43
    NumGt,
44
    NumGte,
45
    NumLt,
46
    NumLte,
47
    DateAfter,
48
    DateBefore,
49
    SemverEq,
50
    SemverLt,
51
    SemverGt,
52
    Unknown(String),
53
}
54

55
#[derive(Serialize, Debug, Clone)]
56
#[cfg_attr(feature = "openapi", derive(ToSchema, IntoParams))]
57
#[cfg_attr(feature = "openapi", into_params(style = Form, parameter_in = Query))]
58
#[serde(rename_all = "camelCase")]
59
pub struct Context {
60
    pub user_id: Option<String>,
61
    pub session_id: Option<String>,
62
    pub environment: Option<String>,
63
    pub app_name: Option<String>,
64
    pub current_time: Option<String>,
65
    pub remote_address: Option<String>,
66
    #[cfg_attr(feature = "openapi", param(style = Form, explode = false, value_type = Object))]
67
    pub properties: Option<HashMap<String, String>>,
68
}
69

70
impl<'de> Deserialize<'de> for Context {
71
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
3✔
72
    where
73
        D: Deserializer<'de>,
74
    {
75
        let mut raw: Map<String, Value> = Deserialize::deserialize(deserializer)?;
3✔
76

77
        if let Some(context_val) = raw.remove("context") {
6✔
78
            if let Value::Object(inner) = context_val {
×
79
                return Context::from_map(inner);
×
80
            } else {
81
                return Err(serde::de::Error::custom(
×
82
                    "Expected 'context' to be an object",
×
83
                ));
84
            }
85
        }
86

87
        Context::from_map(raw)
3✔
88
    }
89
}
90

91
impl Context {
92
    fn from_map<E: serde::de::Error>(mut raw: Map<String, Value>) -> Result<Self, E> {
3✔
93
        fn parse_value(v: Value) -> Option<String> {
1✔
94
            match v {
2✔
95
                Value::String(s) => Some(s),
1✔
96
                Value::Number(n) => Some(n.to_string()),
2✔
97
                Value::Bool(b) => Some(b.to_string()),
2✔
98
                _ => None,
1✔
99
            }
100
        }
101

102
        fn extract_property(
1✔
103
            raw: &mut Map<String, Value>,
104
            props: &mut HashMap<String, String>,
105
            key: &str,
106
        ) -> Option<String> {
107
            raw.remove(key)
2✔
108
                .or_else(|| props.remove(key).map(Value::String))
3✔
109
                .and_then(parse_value)
×
110
        }
111

112
        let mut props: HashMap<String, String> = raw
6✔
113
            .remove("properties")
114
            .and_then(|v| v.as_object().cloned())
6✔
115
            .unwrap_or_default()
116
            .into_iter()
117
            .filter_map(|(k, v)| parse_value(v).map(|s| (k, s)))
10✔
118
            .collect();
119

120
        let user_id = extract_property(&mut raw, &mut props, "userId");
4✔
121
        let session_id = extract_property(&mut raw, &mut props, "sessionId");
5✔
122
        let environment = extract_property(&mut raw, &mut props, "environment");
6✔
123
        let app_name = extract_property(&mut raw, &mut props, "appName");
6✔
124
        let current_time = extract_property(&mut raw, &mut props, "currentTime");
6✔
125
        let remote_address = extract_property(&mut raw, &mut props, "remoteAddress");
5✔
126

127
        // Whatever's left in `raw` now is junk, it can get moved to properties
128
        for (k, v) in raw.into_iter() {
6✔
129
            if let Some(s) = v.as_str() {
×
130
                props.insert(k, s.to_string());
×
131
            }
132
        }
133

134
        Ok(Context {
3✔
135
            user_id,
2✔
136
            session_id,
2✔
137
            environment,
2✔
138
            app_name,
3✔
139
            current_time,
2✔
140
            remote_address,
3✔
141
            properties: if props.is_empty() { None } else { Some(props) },
6✔
142
        })
143
    }
144
}
145

146
/// We need this to ensure that ClientFeatures gets a deterministic serialization.
147
fn optional_ordered_map<S>(
1✔
148
    value: &Option<HashMap<String, String>>,
149
    serializer: S,
150
) -> Result<S::Ok, S::Error>
151
where
152
    S: Serializer,
153
{
154
    match value {
2✔
155
        Some(m) => {
2✔
156
            let ordered: BTreeMap<_, _> = m.iter().collect();
4✔
157
            ordered.serialize(serializer)
3✔
158
        }
159
        None => serializer.serialize_none(),
×
160
    }
161
}
162

163
impl Default for Context {
164
    fn default() -> Self {
×
165
        Self {
166
            user_id: None,
167
            session_id: None,
168
            environment: None,
169
            current_time: None,
170
            app_name: None,
171
            remote_address: None,
172
            properties: Some(HashMap::new()),
×
173
        }
174
    }
175
}
176

177
impl<'de> Deserialize<'de> for Operator {
178
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
4✔
179
    where
180
        D: Deserializer<'de>,
181
    {
182
        let s = String::deserialize(deserializer)?;
8✔
183
        Ok(match s.as_str() {
12✔
184
            "NOT_IN" => Operator::NotIn,
6✔
185
            "IN" => Operator::In,
12✔
186
            "STR_ENDS_WITH" => Operator::StrEndsWith,
8✔
187
            "STR_STARTS_WITH" => Operator::StrStartsWith,
8✔
188
            "STR_CONTAINS" => Operator::StrContains,
9✔
189
            "NUM_EQ" => Operator::NumEq,
9✔
190
            "NUM_GT" => Operator::NumGt,
10✔
191
            "NUM_GTE" => Operator::NumGte,
10✔
192
            "NUM_LT" => Operator::NumLt,
10✔
193
            "NUM_LTE" => Operator::NumLte,
10✔
194
            "DATE_AFTER" => Operator::DateAfter,
9✔
195
            "DATE_BEFORE" => Operator::DateBefore,
8✔
196
            "SEMVER_EQ" => Operator::SemverEq,
10✔
197
            "SEMVER_LT" => Operator::SemverLt,
10✔
198
            "SEMVER_GT" => Operator::SemverGt,
9✔
199
            _ => Operator::Unknown(s),
2✔
200
        })
201
    }
202
}
203

204
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
205
#[cfg_attr(feature = "openapi", derive(ToSchema))]
206
#[serde(rename_all = "camelCase")]
207
pub struct Constraint {
208
    pub context_name: String,
209
    pub operator: Operator,
210
    #[serde(default)]
211
    pub case_insensitive: bool,
212
    #[serde(default)]
213
    pub inverted: bool,
214
    #[serde(skip_serializing_if = "Option::is_none")]
215
    pub values: Option<Vec<String>>,
216
    #[serde(skip_serializing_if = "Option::is_none")]
217
    pub value: Option<String>,
218
}
219

220
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
221
#[cfg_attr(feature = "openapi", derive(ToSchema))]
222
#[serde(rename_all = "camelCase")]
223
pub enum WeightType {
224
    Fix,
225
    Variable,
226
}
227

228
#[derive(Serialize, Deserialize, Debug, Clone, Eq)]
229
#[cfg_attr(feature = "openapi", derive(ToSchema))]
230
#[serde(rename_all = "camelCase")]
231
pub struct Strategy {
232
    pub name: String,
233
    #[serde(skip_serializing_if = "Option::is_none")]
234
    pub sort_order: Option<i32>,
235
    #[serde(skip_serializing_if = "Option::is_none")]
236
    pub segments: Option<Vec<i32>>,
237
    #[serde(skip_serializing_if = "Option::is_none")]
238
    pub constraints: Option<Vec<Constraint>>,
239
    #[serde(
240
        serialize_with = "optional_ordered_map",
241
        skip_serializing_if = "Option::is_none"
242
    )]
243
    pub parameters: Option<HashMap<String, String>>,
244
    #[serde(serialize_with = "serialize_option_vec")]
245
    pub variants: Option<Vec<StrategyVariant>>,
246
}
247

248
fn serialize_option_vec<S, T>(value: &Option<Vec<T>>, serializer: S) -> Result<S::Ok, S::Error>
1✔
249
where
250
    S: Serializer,
251
    T: Serialize,
252
{
253
    match value {
1✔
254
        Some(ref v) => v.serialize(serializer),
2✔
255
        None => Vec::<T>::new().serialize(serializer),
2✔
256
    }
257
}
258

259
impl PartialEq for Strategy {
260
    fn eq(&self, other: &Self) -> bool {
×
261
        self.name == other.name
×
262
            && self.sort_order == other.sort_order
×
263
            && self.segments == other.segments
×
264
            && self.constraints == other.constraints
×
265
            && self.parameters == other.parameters
×
266
    }
267
}
268
impl PartialOrd for Strategy {
269
    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
×
270
        Some(self.cmp(other))
×
271
    }
272
}
273
impl Ord for Strategy {
274
    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
×
275
        match self.sort_order.cmp(&other.sort_order) {
×
276
            Ordering::Equal => self.name.cmp(&other.name),
×
277
            ord => ord,
×
278
        }
279
    }
280
}
281

282
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
283
#[cfg_attr(feature = "openapi", derive(ToSchema))]
284
#[serde(rename_all = "camelCase")]
285
pub struct Override {
286
    pub context_name: String,
287
    pub values: Vec<String>,
288
}
289

290
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
291
#[cfg_attr(feature = "openapi", derive(ToSchema))]
292
pub struct Payload {
293
    #[serde(rename = "type")]
294
    pub payload_type: String,
295
    pub value: String,
296
}
297
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
298
#[cfg_attr(feature = "openapi", derive(ToSchema))]
299
#[serde(rename_all = "camelCase")]
300
pub struct Variant {
301
    pub name: String,
302
    pub weight: i32,
303
    #[serde(skip_serializing_if = "Option::is_none")]
304
    pub weight_type: Option<WeightType>,
305
    #[serde(skip_serializing_if = "Option::is_none")]
306
    pub stickiness: Option<String>,
307
    #[serde(skip_serializing_if = "Option::is_none")]
308
    pub payload: Option<Payload>,
309
    #[serde(skip_serializing_if = "Option::is_none")]
310
    pub overrides: Option<Vec<Override>>,
311
}
312

313
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
314
#[cfg_attr(feature = "openapi", derive(ToSchema))]
315
#[serde(rename_all = "camelCase")]
316
pub struct StrategyVariant {
317
    pub name: String,
318
    pub weight: i32,
319
    #[serde(skip_serializing_if = "Option::is_none")]
320
    pub payload: Option<Payload>,
321
    #[serde(skip_serializing_if = "Option::is_none")]
322
    pub stickiness: Option<String>,
323
}
324

325
impl PartialOrd for Variant {
326
    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
×
327
        Some(self.cmp(other))
×
328
    }
329
}
330
impl Ord for Variant {
331
    fn cmp(&self, other: &Self) -> Ordering {
×
332
        self.name.cmp(&other.name)
×
333
    }
334
}
335

336
#[derive(Serialize, Deserialize, Debug, Clone, Eq)]
337
#[cfg_attr(feature = "openapi", derive(ToSchema))]
338
#[serde(rename_all = "camelCase")]
339
pub struct Segment {
340
    pub id: i32,
341
    pub constraints: Vec<Constraint>,
342
}
343

344
impl PartialEq for Segment {
345
    fn eq(&self, other: &Self) -> bool {
1✔
346
        self.id == other.id
1✔
347
    }
348
}
349

350
impl PartialOrd for Segment {
351
    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
1✔
352
        Some(self.cmp(other))
1✔
353
    }
354
}
355

356
impl Ord for Segment {
357
    fn cmp(&self, other: &Self) -> Ordering {
1✔
358
        self.id.cmp(&other.id)
1✔
359
    }
360
}
361

362
impl Hash for Segment {
363
    fn hash<H: Hasher>(&self, state: &mut H) {
1✔
364
        self.id.hash(state);
1✔
365
    }
366
}
367

368
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
369
#[cfg_attr(feature = "openapi", derive(ToSchema))]
370
#[serde(rename_all = "camelCase")]
371
pub struct FeatureDependency {
372
    pub feature: String,
373
    #[serde(skip_serializing_if = "Option::is_none")]
374
    pub enabled: Option<bool>,
375
    #[serde(skip_serializing_if = "Option::is_none")]
376
    pub variants: Option<Vec<String>>,
377
}
378

379
#[derive(Serialize, Deserialize, Debug, Clone, Eq, Default)]
380
#[cfg_attr(feature = "openapi", derive(ToSchema))]
381
#[serde(rename_all = "camelCase")]
382
pub struct ClientFeature {
383
    pub name: String,
384
    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
385
    pub feature_type: Option<String>,
386
    #[serde(skip_serializing_if = "Option::is_none")]
387
    pub description: Option<String>,
388
    #[serde(skip_serializing_if = "Option::is_none")]
389
    pub created_at: Option<DateTime<Utc>>,
390
    #[serde(skip_serializing_if = "Option::is_none")]
391
    pub last_seen_at: Option<DateTime<Utc>>,
392
    pub enabled: bool,
393
    #[serde(skip_serializing_if = "Option::is_none")]
394
    pub stale: Option<bool>,
395
    #[serde(skip_serializing_if = "Option::is_none")]
396
    pub impression_data: Option<bool>,
397
    #[serde(skip_serializing_if = "Option::is_none")]
398
    pub project: Option<String>,
399
    #[serde(skip_serializing_if = "Option::is_none")]
400
    pub strategies: Option<Vec<Strategy>>,
401
    #[serde(skip_serializing_if = "Option::is_none")]
402
    pub variants: Option<Vec<Variant>>,
403
    #[serde(skip_serializing_if = "Option::is_none")]
404
    pub dependencies: Option<Vec<FeatureDependency>>,
405
}
406

407
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default)]
408
#[cfg_attr(feature = "openapi", derive(ToSchema))]
409
#[serde(rename_all = "camelCase")]
410
pub struct Meta {
411
    pub etag: Option<String>,
412
    pub revision_id: Option<usize>,
413
    pub query_hash: Option<String>,
414
}
415

416
impl Merge for ClientFeatures {
417
    fn merge(self, other: Self) -> Self {
1✔
418
        let mut features = self.features.merge(other.features);
2✔
419
        features.sort();
2✔
420
        let segments = match (self.segments, other.segments) {
1✔
421
            (Some(mut s), Some(o)) => {
×
422
                s.extend(o);
×
423
                Some(s.deduplicate())
×
424
            }
425
            (Some(s), None) => Some(s),
×
426
            (None, Some(o)) => Some(o),
×
427
            (None, None) => None,
1✔
428
        };
429
        ClientFeatures {
430
            version: self.version.max(other.version),
1✔
431
            features,
432
            segments: segments.map(|mut s| {
1✔
433
                s.sort();
434
                s
435
            }),
436
            query: self.query.or(other.query),
1✔
437
            meta: other.meta.or(self.meta),
1✔
438
        }
439
    }
440
}
441

442
impl Upsert for ClientFeatures {
443
    fn upsert(self, other: Self) -> Self {
1✔
444
        let mut features = self.features.upsert(other.features);
2✔
445
        features.sort();
2✔
446
        let segments = match (self.segments, other.segments) {
1✔
447
            (Some(s), Some(mut o)) => {
1✔
448
                o.extend(s);
1✔
449
                Some(o.deduplicate())
1✔
450
            }
451
            (Some(s), None) => Some(s),
×
452
            (None, Some(o)) => Some(o),
×
453
            (None, None) => None,
1✔
454
        };
455
        ClientFeatures {
456
            version: self.version.max(other.version),
1✔
457
            features,
458
            segments: segments.map(|mut s| {
2✔
459
                s.sort();
460
                s
461
            }),
462
            query: self.query.or(other.query),
1✔
463
            meta: other.meta.or(self.meta),
1✔
464
        }
465
    }
466
}
467

468
impl PartialOrd for ClientFeature {
469
    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
1✔
470
        Some(self.cmp(other))
1✔
471
    }
472
}
473

474
impl Ord for ClientFeature {
475
    fn cmp(&self, other: &Self) -> Ordering {
1✔
476
        self.name.cmp(&other.name)
1✔
477
    }
478
}
479

480
impl PartialEq for ClientFeature {
481
    fn eq(&self, other: &Self) -> bool {
2✔
482
        self.name == other.name
2✔
483
    }
484
}
485

486
impl Hash for ClientFeature {
487
    fn hash<H: Hasher>(&self, state: &mut H) {
1✔
488
        self.name.hash(state);
1✔
489
    }
490
}
491

492
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
493
#[cfg_attr(feature = "openapi", derive(ToSchema))]
494
pub struct ClientFeatures {
495
    pub version: u32,
496
    pub features: Vec<ClientFeature>,
497
    #[serde(skip_serializing_if = "Option::is_none")]
498
    pub segments: Option<Vec<Segment>>,
499
    pub query: Option<Query>,
500
    #[serde(skip_serializing_if = "Option::is_none")]
501
    pub meta: Option<Meta>,
502
}
503

504
#[cfg(feature = "hashes")]
505
impl ClientFeatures {
506
    ///
507
    /// Returns a base64 encoded xx3_128 hash for the json representation of ClientFeatures
508
    ///
509
    pub fn xx3_hash(&self) -> Result<String, serde_json::Error> {
1✔
510
        serde_json::to_string(self)
1✔
511
            .map(|s| xxh3_128(s.as_bytes()))
2✔
512
            .map(|xxh_hash| base64::prelude::BASE64_URL_SAFE.encode(xxh_hash.to_le_bytes()))
2✔
513
    }
514
}
515

516
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
517
#[serde(tag = "type", rename_all = "kebab-case")]
518
#[cfg_attr(feature = "openapi", derive(ToSchema))]
519
pub enum DeltaEvent {
520
    /// Event for a feature update.
521
    FeatureUpdated {
522
        #[serde(rename = "eventId")]
523
        event_id: u32,
524
        feature: ClientFeature,
525
    },
526
    /// Event for a feature removal.
527
    #[serde(rename_all = "camelCase")]
528
    FeatureRemoved {
529
        event_id: u32,
530
        feature_name: String,
531
        project: String,
532
    },
533
    /// Event for a segment update.
534
    SegmentUpdated {
535
        #[serde(rename = "eventId")]
536
        event_id: u32,
537
        segment: Segment,
538
    },
539
    /// Event for a segment removal.
540
    #[serde(rename_all = "camelCase")]
541
    SegmentRemoved { event_id: u32, segment_id: i32 },
542
    /// Hydration event for features and segments.
543
    Hydration {
544
        #[serde(rename = "eventId")]
545
        event_id: u32,
546
        features: Vec<ClientFeature>,
547
        segments: Vec<Segment>,
548
    },
549
}
550

551
impl DeltaEvent {
552
    pub fn get_event_id(&self) -> u32 {
×
553
        match self {
×
554
            DeltaEvent::FeatureUpdated { event_id, .. }
×
555
            | DeltaEvent::FeatureRemoved { event_id, .. }
556
            | DeltaEvent::SegmentUpdated { event_id, .. }
557
            | DeltaEvent::SegmentRemoved { event_id, .. }
558
            | DeltaEvent::Hydration { event_id, .. } => *event_id,
559
        }
560
    }
561
}
562

563
/// Schema for delta updates of feature configurations.
564
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
565
#[serde(rename_all = "camelCase")]
566
#[cfg_attr(feature = "openapi", derive(ToSchema))]
567
pub struct ClientFeaturesDelta {
568
    /// A list of delta events.
569
    pub events: Vec<DeltaEvent>,
570
}
571

572
impl ClientFeatures {
573
    /// Modifies the current ClientFeatures instance by applying the events.
574
    pub fn apply_delta(&mut self, delta: &ClientFeaturesDelta) {
1✔
575
        self.apply_delta_events(delta);
1✔
576
    }
577

578
    /// Returns a new ClientFeatures instance with the events applied.
579
    pub fn create_from_delta(delta: &ClientFeaturesDelta) -> ClientFeatures {
1✔
580
        let mut client_features = ClientFeatures::default();
1✔
581
        client_features.apply_delta_events(delta);
1✔
582
        client_features
1✔
583
    }
584

585
    fn apply_delta_events(&mut self, delta: &ClientFeaturesDelta) {
1✔
586
        let segments = &mut self.segments;
1✔
587
        let features = &mut self.features;
1✔
588
        for event in &delta.events {
2✔
589
            match event {
1✔
590
                DeltaEvent::FeatureUpdated { feature, .. } => {
1✔
591
                    if let Some(existing) = features.iter_mut().find(|f| f.name == feature.name) {
5✔
592
                        *existing = feature.clone();
1✔
593
                    } else {
594
                        features.push(feature.clone());
1✔
595
                    }
596
                }
597
                DeltaEvent::FeatureRemoved { feature_name, .. } => {
1✔
598
                    features.retain(|f| f.name != *feature_name);
3✔
599
                }
600
                DeltaEvent::SegmentUpdated { segment, .. } => {
1✔
601
                    let segments_list = segments.get_or_insert_with(Vec::new);
1✔
602
                    if let Some(existing) = segments_list.iter_mut().find(|s| s.id == segment.id) {
5✔
603
                        *existing = segment.clone();
1✔
604
                    } else {
605
                        segments_list.push(segment.clone());
1✔
606
                    }
607
                }
608
                DeltaEvent::SegmentRemoved { segment_id, .. } => {
×
609
                    if let Some(segments_list) = segments {
×
610
                        segments_list.retain(|s| s.id != *segment_id);
×
611
                    }
612
                }
613
                DeltaEvent::Hydration {
×
614
                    features: new_features,
615
                    segments: new_segments,
616
                    ..
617
                } => {
618
                    *features = new_features.clone();
×
619
                    *segments = Some(new_segments.clone());
×
620
                }
621
            }
622
        }
623

624
        features.sort();
1✔
625
    }
626
}
627

628
impl Default for ClientFeatures {
629
    fn default() -> Self {
1✔
630
        Self {
631
            version: 2,
632
            features: vec![],
1✔
633
            segments: None,
634
            query: None,
635
            meta: None,
636
        }
637
    }
638
}
639

640
impl From<ClientFeaturesDelta> for ClientFeatures {
641
    fn from(value: ClientFeaturesDelta) -> Self {
×
642
        ClientFeatures::create_from_delta(&value)
×
643
    }
644
}
645

646
impl From<&ClientFeaturesDelta> for ClientFeatures {
647
    fn from(value: &ClientFeaturesDelta) -> Self {
×
648
        ClientFeatures::create_from_delta(value)
×
649
    }
650
}
651

652
#[cfg(test)]
653
mod tests {
654
    use crate::{
655
        client_features::{ClientFeature, ClientFeaturesDelta},
656
        Merge, Upsert,
657
    };
658
    use serde_json::{from_reader, to_string};
659
    use serde_qs::Config;
660
    use std::{fs::File, io::BufReader, path::PathBuf};
661

662
    use super::{ClientFeatures, Constraint, DeltaEvent, Operator, Segment, Strategy};
663
    use crate::client_features::Context;
664
    use test_case::test_case;
665

666
    #[derive(Debug)]
667
    pub enum EdgeError {
668
        SomethingWentWrong,
669
    }
670
    #[test]
671
    pub fn can_deserialize_numbers_to_strings() {
672
        let json = serde_json::json!({
673
            "context": {
674
                "userId": 123123,
675
                "sessionId": false,
676
                "environment": {
677
                    "aKey": "aValue",
678
                },
679
                "appName": "name",
680
                "currentTime": null,
681
                "properties": {
682
                    "someValue": 123,
683
                    "otherValue": null,
684
                    "anotherValue": {
685
                        "someKey": 123,
686
                    },
687
                    "boolProp": true,
688
                }
689
            },
690
        });
691
        let context: Context = serde_json::from_value(json["context"].clone()).unwrap();
692
        assert_eq!(context.user_id.unwrap(), "123123");
693
        assert_eq!(context.session_id.unwrap(), "false");
694
        assert_eq!(context.app_name.unwrap(), "name");
695
        assert!(context.current_time.is_none());
696
        assert!(context.environment.is_none());
697
        assert!(context.remote_address.is_none());
698
        assert_eq!(
699
            context
700
                .properties
701
                .clone()
702
                .unwrap()
703
                .get("someValue")
704
                .unwrap(),
705
            "123"
706
        );
707
        assert_eq!(
708
            context.properties.clone().unwrap().get("boolProp").unwrap(),
709
            "true"
710
        );
711
        assert!(!context
712
            .properties
713
            .clone()
714
            .unwrap()
715
            .contains_key("otherValue"));
716
        assert!(!context
717
            .properties
718
            .clone()
719
            .unwrap()
720
            .contains_key("anotherValue"));
721
    }
722

723
    #[test]
724
    fn base_level_properties_in_properties_map_are_moved_to_base_level() {
725
        let json = serde_json::json!({
726
            "properties": {
727
                "userId": "promote-me",
728
                "someOtherProp": "stay-in-properties"
729
            },
730
            "appName": "edge-client"
731
        });
732

733
        let context: Context = serde_json::from_value(json).unwrap();
734

735
        assert_eq!(context.user_id.as_deref(), Some("promote-me"));
736
        assert_eq!(context.app_name.as_deref(), Some("edge-client"));
737

738
        let props = context.properties.unwrap();
739
        assert_eq!(props.get("someOtherProp").unwrap(), "stay-in-properties");
740
        assert!(!props.contains_key("userId"));
741
    }
742

743
    #[test]
744
    pub fn ordering_is_stable_for_constraints() {
745
        let c1 = Constraint {
746
            context_name: "acontext".into(),
747
            operator: super::Operator::DateAfter,
748
            case_insensitive: true,
749
            inverted: false,
750
            values: Some(vec![]),
751
            value: None,
752
        };
753
        let c2 = Constraint {
754
            context_name: "acontext".into(),
755
            operator: super::Operator::DateBefore,
756
            case_insensitive: false,
757
            inverted: false,
758
            values: None,
759
            value: Some("value".into()),
760
        };
761
        let c3 = Constraint {
762
            context_name: "bcontext".into(),
763
            operator: super::Operator::NotIn,
764
            case_insensitive: false,
765
            inverted: false,
766
            values: None,
767
            value: None,
768
        };
769
        let mut v = vec![c3.clone(), c1.clone(), c2.clone()];
770
        v.sort();
771
        assert_eq!(v, vec![c1, c2, c3]);
772
    }
773

774
    fn read_file(path: PathBuf) -> Result<BufReader<File>, EdgeError> {
775
        File::open(path)
776
            .map_err(|_| EdgeError::SomethingWentWrong)
777
            .map(BufReader::new)
778
    }
779

780
    #[test_case("./examples/features_with_variantType.json".into() ; "features with variantType")]
781
    #[test_case("./examples/15-global-constraints.json".into(); "global-constraints")]
782
    pub fn client_features_parsing_is_stable(path: PathBuf) {
783
        let client_features: ClientFeatures =
784
            serde_json::from_reader(read_file(path).unwrap()).unwrap();
785

786
        let to_string = serde_json::to_string(&client_features).unwrap();
787
        let reparsed_to_string: ClientFeatures = serde_json::from_str(to_string.as_str()).unwrap();
788
        assert_eq!(client_features, reparsed_to_string);
789
    }
790

791
    #[cfg(feature = "hashes")]
792
    #[test_case("./examples/features_with_variantType.json".into() ; "features with variantType")]
793
    #[cfg(feature = "hashes")]
794
    #[test_case("./examples/15-global-constraints.json".into(); "global-constraints")]
795
    pub fn client_features_hashing_is_stable(path: PathBuf) {
796
        let client_features: ClientFeatures =
797
            serde_json::from_reader(read_file(path.clone()).unwrap()).unwrap();
798

799
        let second_read: ClientFeatures =
800
            serde_json::from_reader(read_file(path).unwrap()).unwrap();
801

802
        let first_hash = client_features.xx3_hash().unwrap();
803
        let second_hash = client_features.xx3_hash().unwrap();
804
        assert_eq!(first_hash, second_hash);
805

806
        let first_hash_from_second_read = second_read.xx3_hash().unwrap();
807
        assert_eq!(first_hash, first_hash_from_second_read);
808
    }
809

810
    #[test]
811
    fn merging_two_client_features_takes_both_feature_sets() {
812
        let client_features_one = ClientFeatures {
813
            version: 2,
814
            features: vec![
815
                ClientFeature {
816
                    name: "feature1".into(),
817
                    ..ClientFeature::default()
818
                },
819
                ClientFeature {
820
                    name: "feature2".into(),
821
                    ..ClientFeature::default()
822
                },
823
            ],
824
            segments: None,
825
            query: None,
826
            meta: None,
827
        };
828

829
        let client_features_two = ClientFeatures {
830
            version: 2,
831
            features: vec![ClientFeature {
832
                name: "feature3".into(),
833
                ..ClientFeature::default()
834
            }],
835
            segments: None,
836
            query: None,
837
            meta: None,
838
        };
839

840
        let merged = client_features_one.merge(client_features_two);
841
        assert_eq!(merged.features.len(), 3);
842
    }
843

844
    #[test]
845
    fn upserting_client_features_prioritizes_new_data_but_keeps_uniques() {
846
        let client_features_one = ClientFeatures {
847
            version: 2,
848
            features: vec![
849
                ClientFeature {
850
                    name: "feature1".into(),
851
                    ..ClientFeature::default()
852
                },
853
                ClientFeature {
854
                    name: "feature2".into(),
855
                    ..ClientFeature::default()
856
                },
857
            ],
858
            segments: None,
859
            query: None,
860
            meta: None,
861
        };
862
        let mut updated_strategies = client_features_one.clone();
863
        let updated_feature_one_with_strategy = ClientFeature {
864
            name: "feature1".into(),
865
            strategies: Some(vec![Strategy {
866
                name: "default".into(),
867
                sort_order: Some(124),
868
                segments: None,
869
                constraints: None,
870
                parameters: None,
871
                variants: None,
872
            }]),
873
            ..ClientFeature::default()
874
        };
875
        let feature_the_third = ClientFeature {
876
            name: "feature3".into(),
877
            strategies: Some(vec![Strategy {
878
                name: "default".into(),
879
                sort_order: Some(124),
880
                segments: None,
881
                constraints: None,
882
                parameters: None,
883
                variants: None,
884
            }]),
885
            ..ClientFeature::default()
886
        };
887
        updated_strategies.features = vec![updated_feature_one_with_strategy, feature_the_third];
888
        let updated_features = client_features_one.upsert(updated_strategies);
889
        let client_features = updated_features.features;
890
        assert_eq!(client_features.len(), 3);
891
        let updated_feature_one = client_features
892
            .iter()
893
            .find(|f| f.name == "feature1")
894
            .unwrap();
895
        assert_eq!(updated_feature_one.strategies.as_ref().unwrap().len(), 1);
896
        assert!(client_features.iter().any(|f| f.name == "feature3"));
897
        assert!(client_features.iter().any(|f| f.name == "feature2"));
898
    }
899

900
    #[test]
901
    pub fn can_parse_properties_map_from_get_query_string() {
902
        let config = Config::new(5, false);
903
        let query_string =
904
            "userId=123123&properties[email]=test@test.com&properties%5BcompanyId%5D=bricks&properties%5Bhello%5D=world";
905
        let context: Context = config
906
            .deserialize_str(query_string)
907
            .expect("Could not parse query string");
908
        assert_eq!(context.user_id, Some("123123".to_string()));
909
        let prop_map = context.properties.unwrap();
910
        assert_eq!(prop_map.len(), 3);
911
        assert!(prop_map.contains_key("companyId"));
912
        assert!(prop_map.contains_key("hello"));
913
        assert!(prop_map.contains_key("email"));
914
    }
915

916
    #[test_case("./examples/delta_base.json".into(), "./examples/delta_patch.json".into(); "Base and delta")]
917
    pub fn can_take_delta_updates(base: PathBuf, delta: PathBuf) {
918
        let base_delta: ClientFeaturesDelta = from_reader(read_file(base).unwrap()).unwrap();
919
        let mut features = ClientFeatures {
920
            version: 2,
921
            features: vec![],
922
            segments: None,
923
            query: None,
924
            meta: None,
925
        };
926
        features.apply_delta(&base_delta);
927
        assert_eq!(features.features.len(), 3);
928
        let delta: ClientFeaturesDelta = from_reader(read_file(delta).unwrap()).unwrap();
929
        features.apply_delta(&delta);
930
        assert_eq!(features.features.len(), 2);
931
    }
932

933
    #[test_case("./examples/delta_base.json".into(), "./examples/delta_patch.json".into(); "Base and delta")]
934
    pub fn validate_delta_updates(base_path: PathBuf, delta_path: PathBuf) {
935
        let base_delta: ClientFeaturesDelta = from_reader(read_file(base_path).unwrap()).unwrap();
936

937
        let mut updated_features = ClientFeatures::create_from_delta(&base_delta);
938
        let expected_feature_count = base_delta
939
            .events
940
            .iter()
941
            .filter(|event| matches!(event, DeltaEvent::FeatureUpdated { .. }))
942
            .count();
943
        assert_eq!(updated_features.features.len(), expected_feature_count);
944

945
        let delta_update: ClientFeaturesDelta =
946
            from_reader(read_file(delta_path).unwrap()).unwrap();
947
        updated_features.apply_delta(&delta_update);
948

949
        let mut sorted_delta_features: Vec<ClientFeature> = delta_update
950
            .events
951
            .iter()
952
            .filter_map(|event| {
953
                if let DeltaEvent::FeatureUpdated { feature, .. } = event {
954
                    Some(feature.clone())
955
                } else {
956
                    None
957
                }
958
            })
959
            .collect();
960
        sorted_delta_features.sort();
961

962
        let serialized_delta_updates = to_string(&sorted_delta_features).unwrap();
963
        let serialized_final_features = to_string(&updated_features.features).unwrap();
964

965
        assert_eq!(serialized_delta_updates, serialized_final_features);
966
    }
967

968
    #[test]
969
    pub fn when_strategy_variants_is_none_default_to_empty_vec() {
970
        let client_features = ClientFeatures {
971
            version: 2,
972
            features: vec![ClientFeature {
973
                name: "feature1".into(),
974
                strategies: Some(vec![Strategy {
975
                    name: "default".into(),
976
                    sort_order: Some(124),
977
                    segments: None,
978
                    constraints: None,
979
                    parameters: None,
980
                    variants: None,
981
                }]),
982
                ..ClientFeature::default()
983
            }],
984
            segments: None,
985
            query: None,
986
            meta: None,
987
        };
988
        let client_features_json = serde_json::to_string(&client_features).unwrap();
989
        let client_features_parsed: ClientFeatures =
990
            serde_json::from_str(&client_features_json).unwrap();
991
        let strategy = client_features_parsed
992
            .features
993
            .first()
994
            .unwrap()
995
            .strategies
996
            .as_ref()
997
            .unwrap()
998
            .first()
999
            .unwrap();
1000
        assert_eq!(strategy.variants.as_ref().unwrap().len(), 0);
1001
    }
1002

1003
    #[test]
1004
    pub fn upserting_features_with_segments_overrides_constraints_on_segments_with_same_id_but_keeps_non_overlapping_segments(
1005
    ) {
1006
        let client_features_one = ClientFeatures {
1007
            version: 2,
1008
            features: vec![],
1009
            segments: Some(vec![
1010
                Segment {
1011
                    constraints: vec![Constraint {
1012
                        case_insensitive: false,
1013
                        values: None,
1014
                        context_name: "location".into(),
1015
                        inverted: false,
1016
                        operator: Operator::In,
1017
                        value: Some("places".into()),
1018
                    }],
1019
                    id: 1,
1020
                },
1021
                Segment {
1022
                    constraints: vec![Constraint {
1023
                        case_insensitive: false,
1024
                        values: None,
1025
                        context_name: "hometown".into(),
1026
                        inverted: false,
1027
                        operator: Operator::In,
1028
                        value: Some("somewhere_nice".into()),
1029
                    }],
1030
                    id: 2,
1031
                },
1032
            ]),
1033
            query: None,
1034
            meta: None,
1035
        };
1036
        let client_features_two = ClientFeatures {
1037
            version: 2,
1038
            features: vec![],
1039
            segments: Some(vec![
1040
                Segment {
1041
                    constraints: vec![Constraint {
1042
                        case_insensitive: false,
1043
                        values: None,
1044
                        context_name: "location".into(),
1045
                        inverted: false,
1046
                        operator: Operator::In,
1047
                        value: Some("other-places".into()),
1048
                    }],
1049
                    id: 1,
1050
                },
1051
                Segment {
1052
                    constraints: vec![Constraint {
1053
                        case_insensitive: false,
1054
                        values: None,
1055
                        context_name: "origin".into(),
1056
                        inverted: false,
1057
                        operator: Operator::In,
1058
                        value: Some("africa".into()),
1059
                    }],
1060
                    id: 3,
1061
                },
1062
            ]),
1063
            query: None,
1064
            meta: None,
1065
        };
1066

1067
        let expected = vec![
1068
            Constraint {
1069
                case_insensitive: false,
1070
                values: None,
1071
                context_name: "hometown".into(),
1072
                inverted: false,
1073
                operator: Operator::In,
1074
                value: Some("somewhere_nice".into()),
1075
            },
1076
            Constraint {
1077
                case_insensitive: false,
1078
                values: None,
1079
                context_name: "location".into(),
1080
                inverted: false,
1081
                operator: Operator::In,
1082
                value: Some("other-places".into()),
1083
            },
1084
            Constraint {
1085
                case_insensitive: false,
1086
                values: None,
1087
                context_name: "origin".into(),
1088
                inverted: false,
1089
                operator: Operator::In,
1090
                value: Some("africa".into()),
1091
            },
1092
        ];
1093

1094
        let upserted = client_features_one
1095
            .clone()
1096
            .upsert(client_features_two.clone());
1097
        let mut new_constraints = upserted
1098
            .segments
1099
            .unwrap()
1100
            .iter()
1101
            .flat_map(|segment| segment.constraints.clone())
1102
            .collect::<Vec<Constraint>>();
1103
        new_constraints.sort_by(|a, b| a.context_name.cmp(&b.context_name));
1104

1105
        assert_eq!(new_constraints, expected);
1106
    }
1107

1108
    #[test]
1109
    pub fn when_meta_is_in_client_features_it_is_serialized() {
1110
        let client_features = ClientFeatures {
1111
            version: 2,
1112
            features: vec![],
1113
            segments: None,
1114
            query: None,
1115
            meta: Some(super::Meta {
1116
                etag: Some("123:wqeqwe".into()),
1117
                revision_id: Some(123),
1118
                query_hash: Some("wqeqwe".into()),
1119
            }),
1120
        };
1121
        let serialized = serde_json::to_string(&client_features).unwrap();
1122
        assert!(serialized.contains("meta"));
1123
    }
1124

1125
    #[test_case("./examples/nuno-response.json".into() ; "features with meta tag")]
1126
    pub fn can_parse_meta_from_upstream(path: PathBuf) {
1127
        let features: ClientFeatures = serde_json::from_reader(read_file(path).unwrap()).unwrap();
1128
        assert!(features.meta.is_some());
1129
        let meta = features.meta.unwrap();
1130
        assert_eq!(meta.etag, Some("\"537b2ba0:3726\"".into()));
1131
        assert_eq!(meta.revision_id, Some(3726));
1132
        assert_eq!(meta.query_hash, Some("537b2ba0".into()));
1133
    }
1134
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc