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

Unleash / unleash-types-rs / #238

08 Apr 2025 11:59AM UTC coverage: 81.223% (+0.5%) from 80.751%
#238

push

186 of 229 relevant lines covered (81.22%)

2.17 hits per line

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

72.61
/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
    RegexMatch,
56
    Unknown(String),
57
}
58

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

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

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

91
        Context::from_map(raw)
92
    }
93
}
94

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1040
        client_features.apply_delta(&delta);
1041

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

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

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

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

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

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

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

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