flagsmith_flag_engine/features/
mod.rs

1use super::utils;
2use super::utils::hashing;
3use serde::{Deserialize, Serialize};
4
5use super::types::FlagsmithValue;
6
7#[derive(Eq, PartialEq, Hash, Serialize, Deserialize, Clone, Debug)]
8pub struct Feature {
9    pub id: u32,
10    pub name: String,
11    #[serde(rename = "type")]
12    feature_type: Option<String>,
13}
14
15#[derive(Serialize, Deserialize, Clone, Debug)]
16pub struct MultivariateFeatureOption {
17    pub value: FlagsmithValue,
18    pub id: Option<u32>,
19}
20
21#[derive(Serialize, Deserialize, Clone, Debug)]
22pub struct MultivariateFeatureStateValue {
23    pub multivariate_feature_option: MultivariateFeatureOption,
24    pub percentage_allocation: f32,
25    pub id: Option<u32>,
26
27    #[serde(default = "utils::get_uuid")]
28    pub mv_fs_value_uuid: String,
29}
30
31#[derive(Serialize, Deserialize, Clone, Debug)]
32pub struct FeatureSegment {
33    pub priority: u32,
34}
35
36#[derive(Serialize, Deserialize, Clone, Debug)]
37pub struct FeatureState {
38    pub feature: Feature,
39    pub enabled: bool,
40    pub django_id: Option<u32>,
41
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub feature_segment: Option<FeatureSegment>,
44
45    #[serde(default = "utils::get_uuid")]
46    pub featurestate_uuid: String,
47
48    #[serde(default)]
49    pub multivariate_feature_state_values: Vec<MultivariateFeatureStateValue>,
50
51    #[serde(rename = "feature_state_value")]
52    value: FlagsmithValue,
53}
54
55impl FeatureState {
56    pub fn get_value(&self, identity_id: Option<&str>) -> FlagsmithValue {
57        let value = match identity_id {
58            Some(id) if self.multivariate_feature_state_values.len() > 0 => {
59                self.get_multivariate_value(id)
60            }
61            _ => self.value.clone(),
62        };
63        return value;
64    }
65
66    // Returns `true` if `self` is higher segment priority than `other`
67    // (i.e. has lower value for feature_segment.priority)
68    // NOTE:
69    //     A segment will be considered higher priority only if:
70    //     1. `other` does not have a feature segment(i.e: it is an environment feature state or it's a
71    //     feature state with feature segment but from an old document that does not have `feature_segment.priority`)
72    //     but `self` does.
73
74    //     2. `other` have a feature segment but with lower priority
75    pub fn is_higher_segment_priority(&self, other: &FeatureState) -> bool {
76        match &other.feature_segment {
77            None if self.feature_segment.is_some() => true,
78            Some(feature_segment) if self.feature_segment.is_some() => {
79                self.feature_segment.as_ref().unwrap().priority < feature_segment.priority
80            }
81            _ => false,
82        }
83    }
84    fn get_multivariate_value(&self, identity_id: &str) -> FlagsmithValue {
85        let object_id = match self.django_id {
86            Some(django_id) => django_id.to_string(),
87            None => self.featurestate_uuid.clone(),
88        };
89        let percentage_value =
90            hashing::get_hashed_percentage_for_object_ids(vec![&object_id, identity_id], 1);
91        let mut start_percentage = 0.0;
92        // Iterate over the mv options in order of id (so we get the same value each
93        // time) to determine the correct value to return to the identity based on
94        // the percentage allocations of the multivariate options. This gives us a
95        // way to ensure that the same value is returned every time we use the same
96        // percentage value.
97        let mut mv_fs_values = self.multivariate_feature_state_values.clone();
98        mv_fs_values.sort_by_key(|mv_fs_value| match mv_fs_value.id {
99            Some(id) => id.to_string(),
100            _ => mv_fs_value.mv_fs_value_uuid.clone(),
101        });
102        for mv_value in mv_fs_values {
103            let limit = mv_value.percentage_allocation + start_percentage;
104            if start_percentage <= percentage_value && percentage_value < limit {
105                return mv_value.multivariate_feature_option.value;
106            }
107
108            start_percentage = limit
109        }
110        // default to return the control value if no MV values found, although this
111        // should never happen
112        return self.value.clone();
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119    use rstest::*;
120
121    #[test]
122    fn deserializing_fs_creates_default_uuid_if_not_present() {
123        let feature_state_json = r#"{
124            "multivariate_feature_state_values": [
125        {
126            "id": 3404,
127            "multivariate_feature_option": {
128              "value": "baz"
129            },
130            "percentage_allocation": 30
131          }
132            ],
133            "feature_state_value": 1,
134            "django_id": 1,
135            "feature": {
136                "name": "feature1",
137                "type": null,
138                "id": 1
139            },
140            "segment_id": null,
141            "enabled": false
142        }"#;
143
144        let feature_state: FeatureState = serde_json::from_str(feature_state_json).unwrap();
145        assert_eq!(feature_state.featurestate_uuid.is_empty(), false);
146        assert_eq!(
147            feature_state.multivariate_feature_state_values[0]
148                .mv_fs_value_uuid
149                .is_empty(),
150            false
151        );
152    }
153
154    #[test]
155    fn serialize_and_deserialize_feature_state() {
156        let feature_state_json = serde_json::json!(
157            {
158                "multivariate_feature_state_values": [],
159                "feature_state_value": 1,
160                "featurestate_uuid":"a6ff815f-63ed-4e72-99dc-9124c442ce4d",
161                "django_id": 1,
162                "feature": {
163                    "name": "feature1",
164                    "type": null,
165                    "id": 1
166                },
167                "enabled": false
168            }
169        );
170        let feature_state: FeatureState =
171            serde_json::from_value(feature_state_json.clone()).unwrap();
172
173        let given_json = serde_json::to_value(&feature_state).unwrap();
174        assert_eq!(given_json, feature_state_json)
175    }
176
177    #[test]
178    fn feature_state_is_higher_segment_priority() {
179        // Given
180        let feature_state_json = serde_json::json!(
181            {
182                "multivariate_feature_state_values": [],
183                "feature_state_value": 1,
184                "featurestate_uuid":"a6ff815f-63ed-4e72-99dc-9124c442ce4d",
185                "django_id": 1,
186                "feature": {
187                    "name": "feature1",
188                    "type": null,
189                    "id": 1
190                },
191                "enabled": false
192            }
193        );
194        let mut feature_state_1: FeatureState =
195            serde_json::from_value(feature_state_json.clone()).unwrap();
196        let mut feature_state_2: FeatureState =
197            serde_json::from_value(feature_state_json.clone()).unwrap();
198
199        // Firstly, since both fs do not have feature segment this should be false
200        assert_eq!(
201            feature_state_1.is_higher_segment_priority(&feature_state_2),
202            false
203        );
204        assert_eq!(
205            feature_state_2.is_higher_segment_priority(&feature_state_1),
206            false
207        );
208
209        // Now add feature_segment to feature_state_2
210        feature_state_2.feature_segment = Some(FeatureSegment { priority: 1 });
211
212        // Since `feature_state_2` have a feature segment this should be false as well
213        assert_eq!(
214            feature_state_1.is_higher_segment_priority(&feature_state_2),
215            false
216        );
217        // And, this true
218        assert_eq!(
219            feature_state_2.is_higher_segment_priority(&feature_state_1),
220            true
221        );
222
223        // Next, let's add a feature segment with higher priority to `feature_state_1`
224        feature_state_1.feature_segment = Some(FeatureSegment { priority: 0 });
225        assert_eq!(
226            feature_state_1.is_higher_segment_priority(&feature_state_2),
227            true
228        );
229        assert_eq!(
230            feature_state_2.is_higher_segment_priority(&feature_state_1),
231            false
232        );
233    }
234
235    #[rstest]
236    #[case("2", "foo".to_string())] // Generated hash percentage 26
237    #[case("8", "bar".to_string())] // Generated hash percentage 38
238    #[case("1", "control".to_string())] // Generated hash percentage 84
239    fn feature_state_get_value_mv_values(
240        #[case] identity_id: &str,
241        #[case] expected_value: String,
242    ) {
243        let mv_feature_value_1 = "foo";
244        let mv_feature_value_2 = "bar";
245        let feature_state_json = serde_json::json!(
246            {
247                "multivariate_feature_state_values": [
248                    {
249                        "id": 1,
250                        "multivariate_feature_option": {
251                            "id":1,
252                            "value": mv_feature_value_1
253                        },
254                        "percentage_allocation": 30
255                    },
256                    {
257                        "id": 2,
258                        "multivariate_feature_option": {
259                            "id":2,
260                            "value": mv_feature_value_2
261                        },
262                        "percentage_allocation": 30
263                    }
264                ],
265                "feature_state_value": "control",
266                "featurestate_uuid":"a6ff815f-63ed-4e72-99dc-9124c442ce4d",
267                "django_id": 1,
268                "feature": {
269                    "name": "feature1",
270                    "type": null,
271                    "id": 1
272                },
273                "enabled": true
274            }
275        );
276        let feature_state: FeatureState =
277            serde_json::from_value(feature_state_json.clone()).unwrap();
278        let value = feature_state.get_value(Some(identity_id));
279        assert_eq!(value.value, expected_value);
280    }
281
282    #[rstest]
283    fn feature_state_get_value_uses_django_id_for_multivariate_value_calculation_if_not_none() {
284        let mv_feature_value_1 = "foo";
285        let mv_feature_value_2 = "bar";
286        let fs_uuid = "a6ff815f-63ed-4e72-99dc-9124c442ce4d";
287        let feature_state_json = serde_json::json!(
288            {
289                "multivariate_feature_state_values": [
290                    {
291                        "id": 1,
292                        "multivariate_feature_option": {
293                            "id":1,
294                            "value": mv_feature_value_1
295                        },
296                        "percentage_allocation": 30
297                    },
298                    {
299                        "id": 2,
300                        "multivariate_feature_option": {
301                            "id":2,
302                            "value": mv_feature_value_2
303                        },
304                        "percentage_allocation": 30
305                    }
306                ],
307                "feature_state_value": "control",
308                "featurestate_uuid":fs_uuid,
309                "django_id": 1,
310                "feature": {
311                    "name": "feature1",
312                    "type": null,
313                    "id": 1
314                },
315                "enabled": true
316            }
317        );
318        // When
319        let feature_state: FeatureState =
320            serde_json::from_value(feature_state_json.clone()).unwrap();
321
322        // Then
323        let value = feature_state.get_value(Some("1"));
324        // Since hash percentage generated using fs_uuid and identity_id `13` is 9.7
325        // and hash percentage generated using mv_fs django id `1` and identity_id `1` is 84
326        // the value returned should be the control value if django id was used for the calculation
327        assert_eq!(value.value, "control".to_string());
328    }
329}