• 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
    SemverLte,
53
    InCidr,
54
    SemverGte,
55
    #[serde(rename = "REGEX")]
56
    RegexMatch,
57
    Unknown(String),
58
}
59

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

75
impl<'de> Deserialize<'de> for Context {
3✔
76
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
77
    where
6✔
78
        D: Deserializer<'de>,
×
79
    {
×
80
        let mut raw: Map<String, Value> = Deserialize::deserialize(deserializer)?;
81

×
82
        if let Some(context_val) = raw.remove("context") {
×
83
            if let Value::Object(inner) = context_val {
84
                return Context::from_map(inner);
85
            } else {
86
                return Err(serde::de::Error::custom(
87
                    "Expected 'context' to be an object",
3✔
88
                ));
89
            }
90
        }
91

92
        Context::from_map(raw)
3✔
93
    }
1✔
94
}
2✔
95

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

107
        fn extract_property(
2✔
108
            raw: &mut Map<String, Value>,
3✔
109
            props: &mut HashMap<String, String>,
×
110
            key: &str,
111
        ) -> Option<String> {
112
            raw.remove(key)
6✔
113
                .or_else(|| props.remove(key).map(Value::String))
114
                .and_then(parse_value)
6✔
115
        }
116

117
        let mut props: HashMap<String, String> = raw
10✔
118
            .remove("properties")
119
            .and_then(|v| v.as_object().cloned())
120
            .unwrap_or_default()
4✔
121
            .into_iter()
5✔
122
            .filter_map(|(k, v)| parse_value(v).map(|s| (k, s)))
6✔
123
            .collect();
6✔
124

6✔
125
        let user_id = extract_property(&mut raw, &mut props, "userId");
5✔
126
        let session_id = extract_property(&mut raw, &mut props, "sessionId");
127
        let environment = extract_property(&mut raw, &mut props, "environment");
128
        let app_name = extract_property(&mut raw, &mut props, "appName");
6✔
129
        let current_time = extract_property(&mut raw, &mut props, "currentTime");
×
130
        let remote_address = extract_property(&mut raw, &mut props, "remoteAddress");
×
131

132
        // Whatever's left in `raw` now is junk, it can get moved to properties
133
        for (k, v) in raw.into_iter() {
134
            if let Some(s) = v.as_str() {
3✔
135
                props.insert(k, s.to_string());
2✔
136
            }
2✔
137
        }
2✔
138

3✔
139
        Ok(Context {
2✔
140
            user_id,
3✔
141
            session_id,
6✔
142
            environment,
143
            app_name,
144
            current_time,
145
            remote_address,
146
            properties: if props.is_empty() { None } else { Some(props) },
147
        })
1✔
148
    }
149
}
150

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

168
impl Default for Context {
169
    fn default() -> Self {
170
        Self {
171
            user_id: None,
172
            session_id: None,
×
173
            environment: None,
174
            current_time: None,
175
            app_name: None,
176
            remote_address: None,
177
            properties: Some(HashMap::new()),
178
        }
4✔
179
    }
180
}
181

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

213
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
214
#[cfg_attr(feature = "openapi", derive(ToSchema))]
215
#[serde(rename_all = "camelCase")]
216
pub struct Constraint {
217
    pub context_name: String,
218
    pub operator: Operator,
219
    #[serde(default)]
220
    pub case_insensitive: bool,
221
    #[serde(default)]
222
    pub inverted: bool,
223
    #[serde(skip_serializing_if = "Option::is_none")]
224
    pub values: Option<Vec<String>>,
225
    #[serde(skip_serializing_if = "Option::is_none")]
226
    pub value: Option<String>,
227
}
228

229
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
230
#[cfg_attr(feature = "openapi", derive(ToSchema))]
231
#[serde(rename_all = "camelCase")]
232
pub enum WeightType {
233
    Fix,
234
    Variable,
235
}
236

237
#[derive(Serialize, Deserialize, Debug, Clone, Eq)]
238
#[cfg_attr(feature = "openapi", derive(ToSchema))]
239
#[serde(rename_all = "camelCase")]
240
pub struct Strategy {
241
    pub name: String,
242
    #[serde(skip_serializing_if = "Option::is_none")]
243
    pub sort_order: Option<i32>,
244
    #[serde(skip_serializing_if = "Option::is_none")]
245
    pub segments: Option<Vec<i32>>,
246
    #[serde(skip_serializing_if = "Option::is_none")]
247
    pub constraints: Option<Vec<Constraint>>,
248
    #[serde(
1✔
249
        serialize_with = "optional_ordered_map",
250
        skip_serializing_if = "Option::is_none"
251
    )]
252
    pub parameters: Option<HashMap<String, String>>,
253
    #[serde(serialize_with = "serialize_option_vec")]
1✔
254
    pub variants: Option<Vec<StrategyVariant>>,
2✔
255
}
2✔
256

257
fn serialize_option_vec<S, T>(value: &Option<Vec<T>>, serializer: S) -> Result<S::Ok, S::Error>
258
where
259
    S: Serializer,
260
    T: Serialize,
×
261
{
×
262
    match value {
×
263
        Some(ref v) => v.serialize(serializer),
×
264
        None => Vec::<T>::new().serialize(serializer),
×
265
    }
×
266
}
267

268
impl PartialEq for Strategy {
269
    fn eq(&self, other: &Self) -> bool {
×
270
        self.name == other.name
×
271
            && self.sort_order == other.sort_order
272
            && self.segments == other.segments
273
            && self.constraints == other.constraints
274
            && self.parameters == other.parameters
×
275
    }
×
276
}
×
277
impl PartialOrd for Strategy {
×
278
    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
279
        Some(self.cmp(other))
280
    }
281
}
282
impl Ord for Strategy {
283
    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
284
        match self.sort_order.cmp(&other.sort_order) {
285
            Ordering::Equal => self.name.cmp(&other.name),
286
            ord => ord,
287
        }
288
    }
289
}
290

291
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
292
#[cfg_attr(feature = "openapi", derive(ToSchema))]
293
#[serde(rename_all = "camelCase")]
294
pub struct Override {
295
    pub context_name: String,
296
    pub values: Vec<String>,
297
}
298

299
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
300
#[cfg_attr(feature = "openapi", derive(ToSchema))]
301
pub struct Payload {
302
    #[serde(rename = "type")]
303
    pub payload_type: String,
304
    pub value: String,
305
}
306
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
307
#[cfg_attr(feature = "openapi", derive(ToSchema))]
308
#[serde(rename_all = "camelCase")]
309
pub struct Variant {
310
    pub name: String,
311
    pub weight: i32,
312
    #[serde(skip_serializing_if = "Option::is_none")]
313
    pub weight_type: Option<WeightType>,
314
    #[serde(skip_serializing_if = "Option::is_none")]
315
    pub stickiness: Option<String>,
316
    #[serde(skip_serializing_if = "Option::is_none")]
317
    pub payload: Option<Payload>,
318
    #[serde(skip_serializing_if = "Option::is_none")]
319
    pub overrides: Option<Vec<Override>>,
320
}
321

322
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
323
#[cfg_attr(feature = "openapi", derive(ToSchema))]
324
#[serde(rename_all = "camelCase")]
325
pub struct StrategyVariant {
326
    pub name: String,
×
327
    pub weight: i32,
×
328
    #[serde(skip_serializing_if = "Option::is_none")]
329
    pub payload: Option<Payload>,
330
    #[serde(skip_serializing_if = "Option::is_none")]
331
    pub stickiness: Option<String>,
×
332
}
×
333

334
impl PartialOrd for Variant {
335
    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
336
        Some(self.cmp(other))
337
    }
338
}
339
impl Ord for Variant {
340
    fn cmp(&self, other: &Self) -> Ordering {
341
        self.name.cmp(&other.name)
342
    }
343
}
344

345
#[derive(Serialize, Deserialize, Debug, Clone, Eq)]
1✔
346
#[cfg_attr(feature = "openapi", derive(ToSchema))]
1✔
347
#[serde(rename_all = "camelCase")]
348
pub struct Segment {
349
    pub id: i32,
350
    pub constraints: Vec<Constraint>,
351
}
1✔
352

1✔
353
impl PartialEq for Segment {
354
    fn eq(&self, other: &Self) -> bool {
355
        self.id == other.id
356
    }
357
}
1✔
358

1✔
359
impl PartialOrd for Segment {
360
    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
361
        Some(self.cmp(other))
362
    }
363
}
1✔
364

1✔
365
impl Ord for Segment {
366
    fn cmp(&self, other: &Self) -> Ordering {
367
        self.id.cmp(&other.id)
368
    }
369
}
370

371
impl Hash for Segment {
372
    fn hash<H: Hasher>(&self, state: &mut H) {
373
        self.id.hash(state);
374
    }
375
}
376

377
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
378
#[cfg_attr(feature = "openapi", derive(ToSchema))]
379
#[serde(rename_all = "camelCase")]
380
pub struct FeatureDependency {
381
    pub feature: String,
382
    #[serde(skip_serializing_if = "Option::is_none")]
383
    pub enabled: Option<bool>,
384
    #[serde(skip_serializing_if = "Option::is_none")]
385
    pub variants: Option<Vec<String>>,
386
}
387

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

416
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default)]
417
#[cfg_attr(feature = "openapi", derive(ToSchema))]
1✔
418
#[serde(rename_all = "camelCase")]
2✔
419
pub struct Meta {
2✔
420
    pub etag: Option<String>,
1✔
421
    pub revision_id: Option<usize>,
×
422
    pub query_hash: Option<String>,
×
423
}
×
424

425
impl Merge for ClientFeatures {
×
426
    fn merge(self, other: Self) -> Self {
×
427
        let mut features = self.features.merge(other.features);
1✔
428
        features.sort();
429
        let segments = match (self.segments, other.segments) {
430
            (Some(mut s), Some(o)) => {
1✔
431
                s.extend(o);
432
                Some(s.deduplicate())
1✔
433
            }
434
            (Some(s), None) => Some(s),
435
            (None, Some(o)) => Some(o),
436
            (None, None) => None,
1✔
437
        };
1✔
438
        ClientFeatures {
439
            version: self.version.max(other.version),
440
            features,
441
            segments: segments.map(|mut s| {
442
                s.sort();
443
                s
1✔
444
            }),
2✔
445
            query: self.query.or(other.query),
2✔
446
            meta: other.meta.or(self.meta),
1✔
447
        }
1✔
448
    }
1✔
449
}
1✔
450

451
impl Upsert for ClientFeatures {
×
452
    fn upsert(self, other: Self) -> Self {
×
453
        let mut features = self.features.upsert(other.features);
1✔
454
        features.sort();
455
        let segments = match (self.segments, other.segments) {
456
            (Some(s), Some(mut o)) => {
1✔
457
                o.extend(s);
458
                Some(o.deduplicate())
2✔
459
            }
460
            (Some(s), None) => Some(s),
461
            (None, Some(o)) => Some(o),
462
            (None, None) => None,
1✔
463
        };
1✔
464
        ClientFeatures {
465
            version: self.version.max(other.version),
466
            features,
467
            segments: segments.map(|mut s| {
468
                s.sort();
469
                s
1✔
470
            }),
1✔
471
            query: self.query.or(other.query),
472
            meta: other.meta.or(self.meta),
473
        }
474
    }
475
}
1✔
476

1✔
477
impl PartialOrd for ClientFeature {
478
    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
479
        Some(self.cmp(other))
480
    }
481
}
2✔
482

2✔
483
impl Ord for ClientFeature {
484
    fn cmp(&self, other: &Self) -> Ordering {
485
        self.name.cmp(&other.name)
486
    }
487
}
1✔
488

1✔
489
impl PartialEq for ClientFeature {
490
    fn eq(&self, other: &Self) -> bool {
491
        self.name == other.name
492
    }
493
}
494

495
impl Hash for ClientFeature {
496
    fn hash<H: Hasher>(&self, state: &mut H) {
497
        self.name.hash(state);
498
    }
499
}
500

501
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
502
#[cfg_attr(feature = "openapi", derive(ToSchema))]
503
pub struct ClientFeatures {
504
    pub version: u32,
505
    pub features: Vec<ClientFeature>,
506
    #[serde(skip_serializing_if = "Option::is_none")]
507
    pub segments: Option<Vec<Segment>>,
508
    pub query: Option<Query>,
509
    #[serde(skip_serializing_if = "Option::is_none")]
1✔
510
    pub meta: Option<Meta>,
1✔
511
}
2✔
512

2✔
513
#[cfg(feature = "hashes")]
514
impl ClientFeatures {
515
    ///
516
    /// Returns a base64 encoded xx3_128 hash for the json representation of ClientFeatures
517
    ///
518
    pub fn xx3_hash(&self) -> Result<String, serde_json::Error> {
519
        serde_json::to_string(self)
520
            .map(|s| xxh3_128(s.as_bytes()))
521
            .map(|xxh_hash| base64::prelude::BASE64_URL_SAFE.encode(xxh_hash.to_le_bytes()))
522
    }
523
}
524

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

560
impl DeltaEvent {
561
    pub fn get_event_id(&self) -> u32 {
562
        match self {
563
            DeltaEvent::FeatureUpdated { event_id, .. }
564
            | DeltaEvent::FeatureRemoved { event_id, .. }
565
            | DeltaEvent::SegmentUpdated { event_id, .. }
566
            | DeltaEvent::SegmentRemoved { event_id, .. }
567
            | DeltaEvent::Hydration { event_id, .. } => *event_id,
568
        }
569
    }
570
}
571

572
/// Schema for delta updates of feature configurations.
573
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
574
#[serde(rename_all = "camelCase")]
1✔
575
#[cfg_attr(feature = "openapi", derive(ToSchema))]
1✔
576
pub struct ClientFeaturesDelta {
577
    /// A list of delta events.
578
    pub events: Vec<DeltaEvent>,
579
}
1✔
580

1✔
581
impl ClientFeatures {
1✔
582
    /// Modifies the current ClientFeatures instance by applying the events.
1✔
583
    pub fn apply_delta(&mut self, delta: &ClientFeaturesDelta) {
584
        self.apply_delta_events(delta);
585
    }
1✔
586

1✔
587
    /// Returns a new ClientFeatures instance with the events applied.
1✔
588
    pub fn create_from_delta(delta: &ClientFeaturesDelta) -> ClientFeatures {
2✔
589
        let mut client_features = ClientFeatures::default();
1✔
590
        client_features.apply_delta_events(delta);
1✔
591
        client_features
5✔
592
    }
1✔
593

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

1✔
633
        features.sort();
634
        if let Some(s) = segments.as_mut() {
635
            s.sort();
636
        }
637
    }
638
}
639

640
impl Default for ClientFeatures {
641
    fn default() -> Self {
×
642
        Self {
×
643
            version: 2,
644
            features: vec![],
645
            segments: None,
646
            query: None,
647
            meta: None,
×
648
        }
×
649
    }
650
}
651

652
impl From<ClientFeaturesDelta> for ClientFeatures {
653
    fn from(value: ClientFeaturesDelta) -> Self {
654
        ClientFeatures::create_from_delta(&value)
655
    }
656
}
657

658
impl From<&ClientFeaturesDelta> for ClientFeatures {
659
    fn from(value: &ClientFeaturesDelta) -> Self {
660
        ClientFeatures::create_from_delta(value)
661
    }
662
}
663

664
#[cfg(test)]
665
mod tests {
666
    use crate::{
667
        client_features::{ClientFeature, ClientFeaturesDelta},
668
        Merge, Upsert,
669
    };
670
    use serde_json::{from_reader, to_string};
671
    use serde_qs::Config;
672
    use std::{fs::File, io::BufReader, path::PathBuf};
673

674
    use super::{ClientFeatures, Constraint, DeltaEvent, Operator, Segment, Strategy};
675
    use crate::client_features::Context;
676
    use test_case::test_case;
677

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

735
    #[test]
736
    fn base_level_properties_in_properties_map_are_moved_to_base_level() {
737
        let json = serde_json::json!({
738
            "properties": {
739
                "userId": "promote-me",
740
                "someOtherProp": "stay-in-properties"
741
            },
742
            "appName": "edge-client"
743
        });
744

745
        let context: Context = serde_json::from_value(json).unwrap();
746

747
        assert_eq!(context.user_id.as_deref(), Some("promote-me"));
748
        assert_eq!(context.app_name.as_deref(), Some("edge-client"));
749

750
        let props = context.properties.unwrap();
751
        assert_eq!(props.get("someOtherProp").unwrap(), "stay-in-properties");
752
        assert!(!props.contains_key("userId"));
753
    }
754

755
    #[test]
756
    pub fn ordering_is_stable_for_constraints() {
757
        let c1 = Constraint {
758
            context_name: "acontext".into(),
759
            operator: super::Operator::DateAfter,
760
            case_insensitive: true,
761
            inverted: false,
762
            values: Some(vec![]),
763
            value: None,
764
        };
765
        let c2 = Constraint {
766
            context_name: "acontext".into(),
767
            operator: super::Operator::DateBefore,
768
            case_insensitive: false,
769
            inverted: false,
770
            values: None,
771
            value: Some("value".into()),
772
        };
773
        let c3 = Constraint {
774
            context_name: "bcontext".into(),
775
            operator: super::Operator::NotIn,
776
            case_insensitive: false,
777
            inverted: false,
778
            values: None,
779
            value: None,
780
        };
781
        let mut v = vec![c3.clone(), c1.clone(), c2.clone()];
782
        v.sort();
783
        assert_eq!(v, vec![c1, c2, c3]);
784
    }
785

786
    fn read_file(path: PathBuf) -> Result<BufReader<File>, EdgeError> {
787
        File::open(path)
788
            .map_err(|_| EdgeError::SomethingWentWrong)
789
            .map(BufReader::new)
790
    }
791

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

798
        let to_string = serde_json::to_string(&client_features).unwrap();
799
        let reparsed_to_string: ClientFeatures = serde_json::from_str(to_string.as_str()).unwrap();
800
        assert_eq!(client_features, reparsed_to_string);
801
    }
802

803
    #[cfg(feature = "hashes")]
804
    #[test_case("./examples/features_with_variantType.json".into() ; "features with variantType")]
805
    #[cfg(feature = "hashes")]
806
    #[test_case("./examples/15-global-constraints.json".into(); "global-constraints")]
807
    pub fn client_features_hashing_is_stable(path: PathBuf) {
808
        let client_features: ClientFeatures =
809
            serde_json::from_reader(read_file(path.clone()).unwrap()).unwrap();
810

811
        let second_read: ClientFeatures =
812
            serde_json::from_reader(read_file(path).unwrap()).unwrap();
813

814
        let first_hash = client_features.xx3_hash().unwrap();
815
        let second_hash = client_features.xx3_hash().unwrap();
816
        assert_eq!(first_hash, second_hash);
817

818
        let first_hash_from_second_read = second_read.xx3_hash().unwrap();
819
        assert_eq!(first_hash, first_hash_from_second_read);
820
    }
821

822
    #[test]
823
    fn merging_two_client_features_takes_both_feature_sets() {
824
        let client_features_one = ClientFeatures {
825
            version: 2,
826
            features: vec![
827
                ClientFeature {
828
                    name: "feature1".into(),
829
                    ..ClientFeature::default()
830
                },
831
                ClientFeature {
832
                    name: "feature2".into(),
833
                    ..ClientFeature::default()
834
                },
835
            ],
836
            segments: None,
837
            query: None,
838
            meta: None,
839
        };
840

841
        let client_features_two = ClientFeatures {
842
            version: 2,
843
            features: vec![ClientFeature {
844
                name: "feature3".into(),
845
                ..ClientFeature::default()
846
            }],
847
            segments: None,
848
            query: None,
849
            meta: None,
850
        };
851

852
        let merged = client_features_one.merge(client_features_two);
853
        assert_eq!(merged.features.len(), 3);
854
    }
855

856
    #[test]
857
    fn upserting_client_features_prioritizes_new_data_but_keeps_uniques() {
858
        let client_features_one = ClientFeatures {
859
            version: 2,
860
            features: vec![
861
                ClientFeature {
862
                    name: "feature1".into(),
863
                    ..ClientFeature::default()
864
                },
865
                ClientFeature {
866
                    name: "feature2".into(),
867
                    ..ClientFeature::default()
868
                },
869
            ],
870
            segments: None,
871
            query: None,
872
            meta: None,
873
        };
874
        let mut updated_strategies = client_features_one.clone();
875
        let updated_feature_one_with_strategy = ClientFeature {
876
            name: "feature1".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
        let feature_the_third = ClientFeature {
888
            name: "feature3".into(),
889
            strategies: Some(vec![Strategy {
890
                name: "default".into(),
891
                sort_order: Some(124),
892
                segments: None,
893
                constraints: None,
894
                parameters: None,
895
                variants: None,
896
            }]),
897
            ..ClientFeature::default()
898
        };
899
        updated_strategies.features = vec![updated_feature_one_with_strategy, feature_the_third];
900
        let updated_features = client_features_one.upsert(updated_strategies);
901
        let client_features = updated_features.features;
902
        assert_eq!(client_features.len(), 3);
903
        let updated_feature_one = client_features
904
            .iter()
905
            .find(|f| f.name == "feature1")
906
            .unwrap();
907
        assert_eq!(updated_feature_one.strategies.as_ref().unwrap().len(), 1);
908
        assert!(client_features.iter().any(|f| f.name == "feature3"));
909
        assert!(client_features.iter().any(|f| f.name == "feature2"));
910
    }
911

912
    #[test]
913
    pub fn can_parse_properties_map_from_get_query_string() {
914
        let config = Config::new(5, false);
915
        let query_string =
916
            "userId=123123&properties[email]=test@test.com&properties%5BcompanyId%5D=bricks&properties%5Bhello%5D=world";
917
        let context: Context = config
918
            .deserialize_str(query_string)
919
            .expect("Could not parse query string");
920
        assert_eq!(context.user_id, Some("123123".to_string()));
921
        let prop_map = context.properties.unwrap();
922
        assert_eq!(prop_map.len(), 3);
923
        assert!(prop_map.contains_key("companyId"));
924
        assert!(prop_map.contains_key("hello"));
925
        assert!(prop_map.contains_key("email"));
926
    }
927

928
    #[test_case("./examples/delta_base.json".into(), "./examples/delta_patch.json".into(); "Base and delta")]
929
    pub fn can_take_delta_updates(base: PathBuf, delta: PathBuf) {
930
        let base_delta: ClientFeaturesDelta = from_reader(read_file(base).unwrap()).unwrap();
931
        let mut features = ClientFeatures {
932
            version: 2,
933
            features: vec![],
934
            segments: None,
935
            query: None,
936
            meta: None,
937
        };
938
        features.apply_delta(&base_delta);
939
        assert_eq!(features.features.len(), 3);
940
        let delta: ClientFeaturesDelta = from_reader(read_file(delta).unwrap()).unwrap();
941
        features.apply_delta(&delta);
942
        assert_eq!(features.features.len(), 2);
943
    }
944

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

949
        let mut updated_features = ClientFeatures::create_from_delta(&base_delta);
950
        let expected_feature_count = base_delta
951
            .events
952
            .iter()
953
            .filter(|event| matches!(event, DeltaEvent::FeatureUpdated { .. }))
954
            .count();
955
        assert_eq!(updated_features.features.len(), expected_feature_count);
956

957
        let delta_update: ClientFeaturesDelta =
958
            from_reader(read_file(delta_path).unwrap()).unwrap();
959
        updated_features.apply_delta(&delta_update);
960

961
        let mut sorted_delta_features: Vec<ClientFeature> = delta_update
962
            .events
963
            .iter()
964
            .filter_map(|event| {
965
                if let DeltaEvent::FeatureUpdated { feature, .. } = event {
966
                    Some(feature.clone())
967
                } else {
968
                    None
969
                }
970
            })
971
            .collect();
972
        sorted_delta_features.sort();
973

974
        let serialized_delta_updates = to_string(&sorted_delta_features).unwrap();
975
        let serialized_final_features = to_string(&updated_features.features).unwrap();
976

977
        assert_eq!(serialized_delta_updates, serialized_final_features);
978
    }
979

980
    #[test]
981
    pub fn apply_delta_sorts_segments() {
982
        let delta = ClientFeaturesDelta {
983
            events: vec![
984
                DeltaEvent::SegmentUpdated {
985
                    event_id: 2,
986
                    segment: Segment {
987
                        id: 2,
988
                        constraints: vec![],
989
                    },
990
                },
991
                DeltaEvent::SegmentUpdated {
992
                    event_id: 1,
993
                    segment: Segment {
994
                        id: 1,
995
                        constraints: vec![],
996
                    },
997
                },
998
            ],
999
        };
1000

1001
        let mut client_features = ClientFeatures::default();
1002
        client_features.apply_delta(&delta);
1003

1004
        let segments = client_features
1005
            .segments
1006
            .expect("segments should be present");
1007
        assert_eq!(segments.len(), 2);
1008
        assert_eq!(segments[0].id, 1);
1009
        assert_eq!(segments[1].id, 2);
1010
    }
1011

1012
    #[test]
1013
    pub fn apply_delta_sorts_existing_segments_after_update() {
1014
        let mut client_features = ClientFeatures {
1015
            version: 2,
1016
            features: vec![],
1017
            segments: Some(vec![
1018
                Segment {
1019
                    id: 3,
1020
                    constraints: vec![],
1021
                },
1022
                Segment {
1023
                    id: 1,
1024
                    constraints: vec![],
1025
                },
1026
            ]),
1027
            query: None,
1028
            meta: None,
1029
        };
1030

1031
        let delta = ClientFeaturesDelta {
1032
            events: vec![DeltaEvent::SegmentUpdated {
1033
                event_id: 1,
1034
                segment: Segment {
1035
                    id: 2,
1036
                    constraints: vec![],
1037
                },
1038
            }],
1039
        };
1040

1041
        client_features.apply_delta(&delta);
1042

1043
        let segments = client_features
1044
            .segments
1045
            .expect("segments should be present");
1046
        assert_eq!(segments.len(), 3);
1047
        assert_eq!(segments[0].id, 1);
1048
        assert_eq!(segments[1].id, 2);
1049
        assert_eq!(segments[2].id, 3);
1050
    }
1051

1052
    #[test]
1053
    pub fn when_strategy_variants_is_none_default_to_empty_vec() {
1054
        let client_features = ClientFeatures {
1055
            version: 2,
1056
            features: vec![ClientFeature {
1057
                name: "feature1".into(),
1058
                strategies: Some(vec![Strategy {
1059
                    name: "default".into(),
1060
                    sort_order: Some(124),
1061
                    segments: None,
1062
                    constraints: None,
1063
                    parameters: None,
1064
                    variants: None,
1065
                }]),
1066
                ..ClientFeature::default()
1067
            }],
1068
            segments: None,
1069
            query: None,
1070
            meta: None,
1071
        };
1072
        let client_features_json = serde_json::to_string(&client_features).unwrap();
1073
        let client_features_parsed: ClientFeatures =
1074
            serde_json::from_str(&client_features_json).unwrap();
1075
        let strategy = client_features_parsed
1076
            .features
1077
            .first()
1078
            .unwrap()
1079
            .strategies
1080
            .as_ref()
1081
            .unwrap()
1082
            .first()
1083
            .unwrap();
1084
        assert_eq!(strategy.variants.as_ref().unwrap().len(), 0);
1085
    }
1086

1087
    #[test]
1088
    pub fn upserting_features_with_segments_overrides_constraints_on_segments_with_same_id_but_keeps_non_overlapping_segments(
1089
    ) {
1090
        let client_features_one = ClientFeatures {
1091
            version: 2,
1092
            features: vec![],
1093
            segments: Some(vec![
1094
                Segment {
1095
                    constraints: vec![Constraint {
1096
                        case_insensitive: false,
1097
                        values: None,
1098
                        context_name: "location".into(),
1099
                        inverted: false,
1100
                        operator: Operator::In,
1101
                        value: Some("places".into()),
1102
                    }],
1103
                    id: 1,
1104
                },
1105
                Segment {
1106
                    constraints: vec![Constraint {
1107
                        case_insensitive: false,
1108
                        values: None,
1109
                        context_name: "hometown".into(),
1110
                        inverted: false,
1111
                        operator: Operator::In,
1112
                        value: Some("somewhere_nice".into()),
1113
                    }],
1114
                    id: 2,
1115
                },
1116
            ]),
1117
            query: None,
1118
            meta: None,
1119
        };
1120
        let client_features_two = ClientFeatures {
1121
            version: 2,
1122
            features: vec![],
1123
            segments: Some(vec![
1124
                Segment {
1125
                    constraints: vec![Constraint {
1126
                        case_insensitive: false,
1127
                        values: None,
1128
                        context_name: "location".into(),
1129
                        inverted: false,
1130
                        operator: Operator::In,
1131
                        value: Some("other-places".into()),
1132
                    }],
1133
                    id: 1,
1134
                },
1135
                Segment {
1136
                    constraints: vec![Constraint {
1137
                        case_insensitive: false,
1138
                        values: None,
1139
                        context_name: "origin".into(),
1140
                        inverted: false,
1141
                        operator: Operator::In,
1142
                        value: Some("africa".into()),
1143
                    }],
1144
                    id: 3,
1145
                },
1146
            ]),
1147
            query: None,
1148
            meta: None,
1149
        };
1150

1151
        let expected = vec![
1152
            Constraint {
1153
                case_insensitive: false,
1154
                values: None,
1155
                context_name: "hometown".into(),
1156
                inverted: false,
1157
                operator: Operator::In,
1158
                value: Some("somewhere_nice".into()),
1159
            },
1160
            Constraint {
1161
                case_insensitive: false,
1162
                values: None,
1163
                context_name: "location".into(),
1164
                inverted: false,
1165
                operator: Operator::In,
1166
                value: Some("other-places".into()),
1167
            },
1168
            Constraint {
1169
                case_insensitive: false,
1170
                values: None,
1171
                context_name: "origin".into(),
1172
                inverted: false,
1173
                operator: Operator::In,
1174
                value: Some("africa".into()),
1175
            },
1176
        ];
1177

1178
        let upserted = client_features_one
1179
            .clone()
1180
            .upsert(client_features_two.clone());
1181
        let mut new_constraints = upserted
1182
            .segments
1183
            .unwrap()
1184
            .iter()
1185
            .flat_map(|segment| segment.constraints.clone())
1186
            .collect::<Vec<Constraint>>();
1187
        new_constraints.sort_by(|a, b| a.context_name.cmp(&b.context_name));
1188

1189
        assert_eq!(new_constraints, expected);
1190
    }
1191

1192
    #[test]
1193
    pub fn when_meta_is_in_client_features_it_is_serialized() {
1194
        let client_features = ClientFeatures {
1195
            version: 2,
1196
            features: vec![],
1197
            segments: None,
1198
            query: None,
1199
            meta: Some(super::Meta {
1200
                etag: Some("123:wqeqwe".into()),
1201
                revision_id: Some(123),
1202
                query_hash: Some("wqeqwe".into()),
1203
            }),
1204
        };
1205
        let serialized = serde_json::to_string(&client_features).unwrap();
1206
        assert!(serialized.contains("meta"));
1207
    }
1208

1209
    #[test_case("./examples/nuno-response.json".into() ; "features with meta tag")]
1210
    pub fn can_parse_meta_from_upstream(path: PathBuf) {
1211
        let features: ClientFeatures = serde_json::from_reader(read_file(path).unwrap()).unwrap();
1212
        assert!(features.meta.is_some());
1213
        let meta = features.meta.unwrap();
1214
        assert_eq!(meta.etag, Some("\"537b2ba0:3726\"".into()));
1215
        assert_eq!(meta.revision_id, Some(3726));
1216
        assert_eq!(meta.query_hash, Some("537b2ba0".into()));
1217
    }
1218

1219
    #[test]
1220
    pub fn regex_constraint_serializes_and_deserializes_symmetrically() {
1221
        let constraint = Constraint {
1222
            context_name: "test".into(),
1223
            operator: Operator::RegexMatch,
1224
            case_insensitive: false,
1225
            inverted: false,
1226
            values: None,
1227
            value: Some("^[a-z]+$".into()),
1228
        };
1229
        let serialized = serde_json::to_string(&constraint).unwrap();
1230
        assert!(serialized.contains("REGEX"));
1231
        let deserialized: Constraint = serde_json::from_str(&serialized).unwrap();
1232
        assert_eq!(deserialized.operator, Operator::RegexMatch);
1233
    }
1234
}
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