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

Unleash / unleash-types-rs / #239

08 Apr 2025 12:00PM UTC coverage: 81.223% (+0.5%) from 80.751%
#239

push

web-flow
feat: looser frontend context deserialization to match unleash/api/frontend (#87)

18 of 20 new or added lines in 1 file covered. (90.0%)

1 existing line in 1 file now uncovered.

186 of 229 relevant lines covered (81.22%)

2.13 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::Value;
12
#[cfg(feature = "hashes")]
13
use xxhash_rust::xxh3::xxh3_128;
14

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

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

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

55
#[derive(Deserialize, Serialize, Debug, Clone)]
56
#[cfg_attr(feature = "openapi", derive(ToSchema, IntoParams))]
57
#[cfg_attr(feature = "openapi", into_params(style = Form, parameter_in = Query))]
58
#[serde(rename_all = "camelCase")]
59
pub struct Context {
60
    #[serde(default)]
61
    #[serde(
62
        deserialize_with = "stringify_numbers_and_booleans",
63
        skip_serializing_if = "Option::is_none"
64
    )]
65
    pub user_id: Option<String>,
66
    #[serde(default)]
67
    #[serde(
68
        deserialize_with = "stringify_numbers_and_booleans",
69
        skip_serializing_if = "Option::is_none"
70
    )]
71
    pub session_id: Option<String>,
72
    #[serde(default)]
73
    #[serde(
74
        deserialize_with = "stringify_numbers_and_booleans",
75
        skip_serializing_if = "Option::is_none"
76
    )]
77
    pub environment: Option<String>,
78
    #[serde(default)]
79
    #[serde(
80
        deserialize_with = "stringify_numbers_and_booleans",
81
        skip_serializing_if = "Option::is_none"
82
    )]
83
    pub app_name: Option<String>,
84
    #[serde(default)]
85
    #[serde(
86
        deserialize_with = "stringify_numbers_and_booleans",
87
        skip_serializing_if = "Option::is_none"
88
    )]
89
    pub current_time: Option<String>,
90
    #[serde(default)]
91
    #[serde(
92
        deserialize_with = "stringify_numbers_and_booleans",
93
        skip_serializing_if = "Option::is_none"
94
    )]
95
    pub remote_address: Option<String>,
96
    #[serde(default)]
97
    #[serde(
98
        deserialize_with = "stringify_numbers_and_booleans_remove_nulls_and_non_strings",
99
        serialize_with = "optional_ordered_map",
100
        skip_serializing_if = "Option::is_none"
101
    )]
102
    #[cfg_attr(feature = "openapi", param(style = Form, explode = false, value_type = Object))]
103
    pub properties: Option<HashMap<String, String>>,
104
}
105

106
// I know this looks silly but it's also important for two reasons:
107
// The first is that the client spec tests have a test case that has a context defined like:
108
// {
109
//   "properties": {
110
//      "someValue": null
111
//    }
112
// }
113
// Passing around an Option<HashMap<String, Option<String>>> is awful and unnecessary, we should scrub ingested data
114
// before trying to execute our logic, so we scrub out those empty values instead, they do nothing useful for us.
115
// The second reason is that we can't shield the Rust code from consumers using the FFI layers and potentially doing
116
// exactly the same thing in languages that allow it. They should not do that. But if they do we have enough information
117
// to understand the intent of the executed code clearly and there's no reason to fail.
118
// This also maps numbers + booleans to strings, and disregards other types without failing
119
fn stringify_numbers_and_booleans_remove_nulls_and_non_strings<'de, D>(
2✔
120
    deserializer: D,
121
) -> Result<Option<HashMap<String, String>>, D::Error>
122
where
123
    D: Deserializer<'de>,
124
{
125
    let props: Option<HashMap<String, Option<Value>>> = Option::deserialize(deserializer)?;
2✔
126
    Ok(props.map(|props| {
6✔
127
        props
2✔
128
            .into_iter()
×
129
            .filter_map(|(k, v)| match v {
4✔
130
                Some(Value::String(s)) => {
2✔
131
                    if s.is_empty() {
6✔
NEW
132
                        None
×
133
                    } else {
134
                        Some((k, s))
2✔
135
                    }
136
                },
137
                Some(Value::Number(n)) => Some((k, n.to_string())),
1✔
138
                Some(Value::Bool(b)) => Some((k, b.to_string())),
1✔
139
                _ => None,
1✔
140
            })
UNCOV
141
            .collect()
×
142
    }))
143
}
144

145
// Allowing a looser deserialization for the contexts properties to match Unleash behavior
146
fn stringify_numbers_and_booleans<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
2✔
147
where
148
    D: Deserializer<'de>,
149
{
150
    let prop: Option<Value> = Option::deserialize(deserializer)?;
2✔
151
    Ok(match prop {
6✔
152
        Some(Value::String(s)) => {
2✔
153
            if s.is_empty() {
6✔
NEW
154
                None
×
155
            } else {
156
                Some(s)
2✔
157
            }
158
        }
159
        Some(Value::Number(n)) => Some(n.to_string()),
2✔
160
        Some(Value::Bool(b)) => Some(b.to_string()),
2✔
161
        _ => None,
1✔
162
    })
163
}
164

165
///
166
/// We need this to ensure that ClientFeatures gets a deterministic serialization.
167
fn optional_ordered_map<S>(
1✔
168
    value: &Option<HashMap<String, String>>,
169
    serializer: S,
170
) -> Result<S::Ok, S::Error>
171
where
172
    S: Serializer,
173
{
174
    match value {
1✔
175
        Some(m) => {
1✔
176
            let ordered: BTreeMap<_, _> = m.iter().collect();
2✔
177
            ordered.serialize(serializer)
2✔
178
        }
179
        None => serializer.serialize_none(),
×
180
    }
181
}
182

183
impl Default for Context {
184
    fn default() -> Self {
×
185
        Self {
186
            user_id: None,
187
            session_id: None,
188
            environment: None,
189
            current_time: None,
190
            app_name: None,
191
            remote_address: None,
192
            properties: Some(HashMap::new()),
×
193
        }
194
    }
195
}
196

197
impl<'de> Deserialize<'de> for Operator {
198
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
4✔
199
    where
200
        D: Deserializer<'de>,
201
    {
202
        let s = String::deserialize(deserializer)?;
4✔
203
        Ok(match s.as_str() {
12✔
204
            "NOT_IN" => Operator::NotIn,
6✔
205
            "IN" => Operator::In,
12✔
206
            "STR_ENDS_WITH" => Operator::StrEndsWith,
9✔
207
            "STR_STARTS_WITH" => Operator::StrStartsWith,
10✔
208
            "STR_CONTAINS" => Operator::StrContains,
12✔
209
            "NUM_EQ" => Operator::NumEq,
11✔
210
            "NUM_GT" => Operator::NumGt,
12✔
211
            "NUM_GTE" => Operator::NumGte,
14✔
212
            "NUM_LT" => Operator::NumLt,
14✔
213
            "NUM_LTE" => Operator::NumLte,
14✔
214
            "DATE_AFTER" => Operator::DateAfter,
15✔
215
            "DATE_BEFORE" => Operator::DateBefore,
14✔
216
            "SEMVER_EQ" => Operator::SemverEq,
13✔
217
            "SEMVER_LT" => Operator::SemverLt,
13✔
218
            "SEMVER_GT" => Operator::SemverGt,
10✔
219
            _ => Operator::Unknown(s),
2✔
220
        })
221
    }
222
}
223

224
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
225
#[cfg_attr(feature = "openapi", derive(ToSchema))]
226
#[serde(rename_all = "camelCase")]
227
pub struct Constraint {
228
    pub context_name: String,
229
    pub operator: Operator,
230
    #[serde(default)]
231
    pub case_insensitive: bool,
232
    #[serde(default)]
233
    pub inverted: bool,
234
    #[serde(skip_serializing_if = "Option::is_none")]
235
    pub values: Option<Vec<String>>,
236
    #[serde(skip_serializing_if = "Option::is_none")]
237
    pub value: Option<String>,
238
}
239

240
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
241
#[cfg_attr(feature = "openapi", derive(ToSchema))]
242
#[serde(rename_all = "camelCase")]
243
pub enum WeightType {
244
    Fix,
245
    Variable,
246
}
247

248
#[derive(Serialize, Deserialize, Debug, Clone, Eq)]
249
#[cfg_attr(feature = "openapi", derive(ToSchema))]
250
#[serde(rename_all = "camelCase")]
251
pub struct Strategy {
252
    pub name: String,
253
    #[serde(skip_serializing_if = "Option::is_none")]
254
    pub sort_order: Option<i32>,
255
    #[serde(skip_serializing_if = "Option::is_none")]
256
    pub segments: Option<Vec<i32>>,
257
    #[serde(skip_serializing_if = "Option::is_none")]
258
    pub constraints: Option<Vec<Constraint>>,
259
    #[serde(
260
        serialize_with = "optional_ordered_map",
261
        skip_serializing_if = "Option::is_none"
262
    )]
263
    pub parameters: Option<HashMap<String, String>>,
264
    #[serde(serialize_with = "serialize_option_vec")]
265
    pub variants: Option<Vec<StrategyVariant>>,
266
}
267

268
fn serialize_option_vec<S, T>(value: &Option<Vec<T>>, serializer: S) -> Result<S::Ok, S::Error>
1✔
269
where
270
    S: Serializer,
271
    T: Serialize,
272
{
273
    match value {
1✔
274
        Some(ref v) => v.serialize(serializer),
2✔
275
        None => Vec::<T>::new().serialize(serializer),
2✔
276
    }
277
}
278

279
impl PartialEq for Strategy {
280
    fn eq(&self, other: &Self) -> bool {
×
281
        self.name == other.name
×
282
            && self.sort_order == other.sort_order
×
283
            && self.segments == other.segments
×
284
            && self.constraints == other.constraints
×
285
            && self.parameters == other.parameters
×
286
    }
287
}
288
impl PartialOrd for Strategy {
289
    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
×
290
        Some(self.cmp(other))
×
291
    }
292
}
293
impl Ord for Strategy {
294
    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
×
295
        match self.sort_order.cmp(&other.sort_order) {
×
296
            Ordering::Equal => self.name.cmp(&other.name),
×
297
            ord => ord,
×
298
        }
299
    }
300
}
301

302
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
303
#[cfg_attr(feature = "openapi", derive(ToSchema))]
304
#[serde(rename_all = "camelCase")]
305
pub struct Override {
306
    pub context_name: String,
307
    pub values: Vec<String>,
308
}
309

310
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
311
#[cfg_attr(feature = "openapi", derive(ToSchema))]
312
pub struct Payload {
313
    #[serde(rename = "type")]
314
    pub payload_type: String,
315
    pub value: String,
316
}
317
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
318
#[cfg_attr(feature = "openapi", derive(ToSchema))]
319
#[serde(rename_all = "camelCase")]
320
pub struct Variant {
321
    pub name: String,
322
    pub weight: i32,
323
    #[serde(skip_serializing_if = "Option::is_none")]
324
    pub weight_type: Option<WeightType>,
325
    #[serde(skip_serializing_if = "Option::is_none")]
326
    pub stickiness: Option<String>,
327
    #[serde(skip_serializing_if = "Option::is_none")]
328
    pub payload: Option<Payload>,
329
    #[serde(skip_serializing_if = "Option::is_none")]
330
    pub overrides: Option<Vec<Override>>,
331
}
332

333
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
334
#[cfg_attr(feature = "openapi", derive(ToSchema))]
335
#[serde(rename_all = "camelCase")]
336
pub struct StrategyVariant {
337
    pub name: String,
338
    pub weight: i32,
339
    #[serde(skip_serializing_if = "Option::is_none")]
340
    pub payload: Option<Payload>,
341
    #[serde(skip_serializing_if = "Option::is_none")]
342
    pub stickiness: Option<String>,
343
}
344

345
impl PartialOrd for Variant {
346
    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
×
347
        Some(self.cmp(other))
×
348
    }
349
}
350
impl Ord for Variant {
351
    fn cmp(&self, other: &Self) -> Ordering {
×
352
        self.name.cmp(&other.name)
×
353
    }
354
}
355

356
#[derive(Serialize, Deserialize, Debug, Clone, Eq)]
357
#[cfg_attr(feature = "openapi", derive(ToSchema))]
358
#[serde(rename_all = "camelCase")]
359
pub struct Segment {
360
    pub id: i32,
361
    pub constraints: Vec<Constraint>,
362
}
363

364
impl PartialEq for Segment {
365
    fn eq(&self, other: &Self) -> bool {
1✔
366
        self.id == other.id
1✔
367
    }
368
}
369

370
impl PartialOrd for Segment {
371
    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
1✔
372
        Some(self.cmp(other))
1✔
373
    }
374
}
375

376
impl Ord for Segment {
377
    fn cmp(&self, other: &Self) -> Ordering {
1✔
378
        self.id.cmp(&other.id)
1✔
379
    }
380
}
381

382
impl Hash for Segment {
383
    fn hash<H: Hasher>(&self, state: &mut H) {
1✔
384
        self.id.hash(state);
1✔
385
    }
386
}
387

388
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
389
#[cfg_attr(feature = "openapi", derive(ToSchema))]
390
#[serde(rename_all = "camelCase")]
391
pub struct FeatureDependency {
392
    pub feature: String,
393
    #[serde(skip_serializing_if = "Option::is_none")]
394
    pub enabled: Option<bool>,
395
    #[serde(skip_serializing_if = "Option::is_none")]
396
    pub variants: Option<Vec<String>>,
397
}
398

399
#[derive(Serialize, Deserialize, Debug, Clone, Eq, Default)]
400
#[cfg_attr(feature = "openapi", derive(ToSchema))]
401
#[serde(rename_all = "camelCase")]
402
pub struct ClientFeature {
403
    pub name: String,
404
    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
405
    pub feature_type: Option<String>,
406
    #[serde(skip_serializing_if = "Option::is_none")]
407
    pub description: Option<String>,
408
    #[serde(skip_serializing_if = "Option::is_none")]
409
    pub created_at: Option<DateTime<Utc>>,
410
    #[serde(skip_serializing_if = "Option::is_none")]
411
    pub last_seen_at: Option<DateTime<Utc>>,
412
    pub enabled: bool,
413
    #[serde(skip_serializing_if = "Option::is_none")]
414
    pub stale: Option<bool>,
415
    #[serde(skip_serializing_if = "Option::is_none")]
416
    pub impression_data: Option<bool>,
417
    #[serde(skip_serializing_if = "Option::is_none")]
418
    pub project: Option<String>,
419
    #[serde(skip_serializing_if = "Option::is_none")]
420
    pub strategies: Option<Vec<Strategy>>,
421
    #[serde(skip_serializing_if = "Option::is_none")]
422
    pub variants: Option<Vec<Variant>>,
423
    #[serde(skip_serializing_if = "Option::is_none")]
424
    pub dependencies: Option<Vec<FeatureDependency>>,
425
}
426

427
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default)]
428
#[cfg_attr(feature = "openapi", derive(ToSchema))]
429
#[serde(rename_all = "camelCase")]
430
pub struct Meta {
431
    pub etag: Option<String>,
432
    pub revision_id: Option<usize>,
433
    pub query_hash: Option<String>,
434
}
435

436
impl Merge for ClientFeatures {
437
    fn merge(self, other: Self) -> Self {
1✔
438
        let mut features = self.features.merge(other.features);
2✔
439
        features.sort();
2✔
440
        let segments = match (self.segments, other.segments) {
1✔
441
            (Some(mut s), Some(o)) => {
×
442
                s.extend(o);
×
443
                Some(s.deduplicate())
×
444
            }
445
            (Some(s), None) => Some(s),
×
446
            (None, Some(o)) => Some(o),
×
447
            (None, None) => None,
1✔
448
        };
449
        ClientFeatures {
450
            version: self.version.max(other.version),
1✔
451
            features,
452
            segments: segments.map(|mut s| {
1✔
453
                s.sort();
454
                s
455
            }),
456
            query: self.query.or(other.query),
1✔
457
            meta: other.meta.or(self.meta),
1✔
458
        }
459
    }
460
}
461

462
impl Upsert for ClientFeatures {
463
    fn upsert(self, other: Self) -> Self {
1✔
464
        let mut features = self.features.upsert(other.features);
2✔
465
        features.sort();
2✔
466
        let segments = match (self.segments, other.segments) {
1✔
467
            (Some(s), Some(mut o)) => {
1✔
468
                o.extend(s);
1✔
469
                Some(o.deduplicate())
1✔
470
            }
471
            (Some(s), None) => Some(s),
×
472
            (None, Some(o)) => Some(o),
×
473
            (None, None) => None,
1✔
474
        };
475
        ClientFeatures {
476
            version: self.version.max(other.version),
1✔
477
            features,
478
            segments: segments.map(|mut s| {
2✔
479
                s.sort();
480
                s
481
            }),
482
            query: self.query.or(other.query),
1✔
483
            meta: other.meta.or(self.meta),
1✔
484
        }
485
    }
486
}
487

488
impl PartialOrd for ClientFeature {
489
    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
1✔
490
        Some(self.cmp(other))
1✔
491
    }
492
}
493

494
impl Ord for ClientFeature {
495
    fn cmp(&self, other: &Self) -> Ordering {
1✔
496
        self.name.cmp(&other.name)
1✔
497
    }
498
}
499

500
impl PartialEq for ClientFeature {
501
    fn eq(&self, other: &Self) -> bool {
1✔
502
        self.name == other.name
1✔
503
    }
504
}
505

506
impl Hash for ClientFeature {
507
    fn hash<H: Hasher>(&self, state: &mut H) {
1✔
508
        self.name.hash(state);
1✔
509
    }
510
}
511

512
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
513
#[cfg_attr(feature = "openapi", derive(ToSchema))]
514
pub struct ClientFeatures {
515
    pub version: u32,
516
    pub features: Vec<ClientFeature>,
517
    #[serde(skip_serializing_if = "Option::is_none")]
518
    pub segments: Option<Vec<Segment>>,
519
    pub query: Option<Query>,
520
    #[serde(skip_serializing_if = "Option::is_none")]
521
    pub meta: Option<Meta>,
522
}
523

524
#[cfg(feature = "hashes")]
525
impl ClientFeatures {
526
    ///
527
    /// Returns a base64 encoded xx3_128 hash for the json representation of ClientFeatures
528
    ///
529
    pub fn xx3_hash(&self) -> Result<String, serde_json::Error> {
1✔
530
        serde_json::to_string(self)
1✔
531
            .map(|s| xxh3_128(s.as_bytes()))
2✔
532
            .map(|xxh_hash| base64::prelude::BASE64_URL_SAFE.encode(xxh_hash.to_le_bytes()))
3✔
533
    }
534
}
535

536
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
537
#[serde(tag = "type", rename_all = "kebab-case")]
538
#[cfg_attr(feature = "openapi", derive(ToSchema))]
539
pub enum DeltaEvent {
540
    /// Event for a feature update.
541
    FeatureUpdated {
542
        #[serde(rename = "eventId")]
543
        event_id: u32,
544
        feature: ClientFeature,
545
    },
546
    /// Event for a feature removal.
547
    #[serde(rename_all = "camelCase")]
548
    FeatureRemoved {
549
        event_id: u32,
550
        feature_name: String,
551
        project: String,
552
    },
553
    /// Event for a segment update.
554
    SegmentUpdated {
555
        #[serde(rename = "eventId")]
556
        event_id: u32,
557
        segment: Segment,
558
    },
559
    /// Event for a segment removal.
560
    #[serde(rename_all = "camelCase")]
561
    SegmentRemoved { event_id: u32, segment_id: i32 },
562
    /// Hydration event for features and segments.
563
    Hydration {
564
        #[serde(rename = "eventId")]
565
        event_id: u32,
566
        features: Vec<ClientFeature>,
567
        segments: Vec<Segment>,
568
    },
569
}
570

571
impl DeltaEvent {
572
    pub fn get_event_id(&self) -> u32 {
×
573
        match self {
×
574
            DeltaEvent::FeatureUpdated { event_id, .. }
×
575
            | DeltaEvent::FeatureRemoved { event_id, .. }
576
            | DeltaEvent::SegmentUpdated { event_id, .. }
577
            | DeltaEvent::SegmentRemoved { event_id, .. }
578
            | DeltaEvent::Hydration { event_id, .. } => *event_id,
579
        }
580
    }
581
}
582

583
/// Schema for delta updates of feature configurations.
584
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
585
#[serde(rename_all = "camelCase")]
586
#[cfg_attr(feature = "openapi", derive(ToSchema))]
587
pub struct ClientFeaturesDelta {
588
    /// A list of delta events.
589
    pub events: Vec<DeltaEvent>,
590
}
591

592
impl ClientFeatures {
593
    /// Modifies the current ClientFeatures instance by applying the events.
594
    pub fn apply_delta(&mut self, delta: &ClientFeaturesDelta) {
1✔
595
        self.apply_delta_events(delta);
1✔
596
    }
597

598
    /// Returns a new ClientFeatures instance with the events applied.
599
    pub fn create_from_delta(delta: &ClientFeaturesDelta) -> ClientFeatures {
1✔
600
        let mut client_features = ClientFeatures::default();
1✔
601
        client_features.apply_delta_events(delta);
1✔
602
        client_features
1✔
603
    }
604

605
    fn apply_delta_events(&mut self, delta: &ClientFeaturesDelta) {
1✔
606
        let segments = &mut self.segments;
1✔
607
        let features = &mut self.features;
1✔
608
        for event in &delta.events {
2✔
609
            match event {
1✔
610
                DeltaEvent::FeatureUpdated { feature, .. } => {
1✔
611
                    if let Some(existing) = features.iter_mut().find(|f| f.name == feature.name) {
5✔
612
                        *existing = feature.clone();
1✔
613
                    } else {
614
                        features.push(feature.clone());
1✔
615
                    }
616
                }
617
                DeltaEvent::FeatureRemoved { feature_name, .. } => {
1✔
618
                    features.retain(|f| f.name != *feature_name);
3✔
619
                }
620
                DeltaEvent::SegmentUpdated { segment, .. } => {
1✔
621
                    let segments_list = segments.get_or_insert_with(Vec::new);
1✔
622
                    if let Some(existing) = segments_list.iter_mut().find(|s| s.id == segment.id) {
5✔
623
                        *existing = segment.clone();
1✔
624
                    } else {
625
                        segments_list.push(segment.clone());
1✔
626
                    }
627
                }
628
                DeltaEvent::SegmentRemoved { segment_id, .. } => {
×
629
                    if let Some(segments_list) = segments {
×
630
                        segments_list.retain(|s| s.id != *segment_id);
×
631
                    }
632
                }
633
                DeltaEvent::Hydration {
×
634
                    features: new_features,
635
                    segments: new_segments,
636
                    ..
637
                } => {
638
                    *features = new_features.clone();
×
639
                    *segments = Some(new_segments.clone());
×
640
                }
641
            }
642
        }
643

644
        features.sort();
1✔
645
    }
646
}
647

648
impl Default for ClientFeatures {
649
    fn default() -> Self {
1✔
650
        Self {
651
            version: 2,
652
            features: vec![],
1✔
653
            segments: None,
654
            query: None,
655
            meta: None,
656
        }
657
    }
658
}
659

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

666
impl From<&ClientFeaturesDelta> for ClientFeatures {
667
    fn from(value: &ClientFeaturesDelta) -> Self {
×
668
        ClientFeatures::create_from_delta(value)
×
669
    }
670
}
671

672
#[cfg(test)]
673
mod tests {
674
    use crate::{
675
        client_features::{ClientFeature, ClientFeaturesDelta},
676
        Merge, Upsert,
677
    };
678
    use serde_json::{from_reader, to_string};
679
    use serde_qs::Config;
680
    use std::{fs::File, io::BufReader, path::PathBuf};
681

682
    use super::{ClientFeatures, Constraint, DeltaEvent, Operator, Segment, Strategy};
683
    use crate::client_features::Context;
684
    use test_case::test_case;
685

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

© 2026 Coveralls, Inc