unleash_api_client/
api.rs

1// Copyright 2020 Cognite AS
2//! <https://docs.getunleash.io/api/client/features>
3use std::collections::HashMap;
4use std::default::Default;
5
6use crate::version::get_sdk_version;
7use chrono::{DateTime, Utc};
8use semver::Version;
9use serde::{Deserialize, Serialize};
10use serde_json::Value;
11
12#[derive(Serialize, Deserialize, Debug)]
13#[cfg_attr(feature = "strict", serde(deny_unknown_fields))]
14pub struct Features {
15    pub version: u8,
16    pub features: Vec<Feature>,
17}
18
19impl Features {
20    pub fn endpoint(api_url: &str) -> String {
21        format!("{}/client/features", api_url.trim_end_matches('/'))
22    }
23}
24
25#[derive(Clone, Serialize, Deserialize, Debug)]
26#[cfg_attr(feature = "strict", serde(deny_unknown_fields))]
27pub struct Feature {
28    pub name: String,
29    #[serde(default)]
30    pub description: Option<String>,
31    pub enabled: bool,
32    pub strategies: Vec<Strategy>,
33    pub variants: Option<Vec<Variant>>,
34    #[serde(rename = "createdAt")]
35    pub created_at: Option<chrono::DateTime<chrono::Utc>>,
36}
37
38#[derive(Clone, Default, Serialize, Deserialize, Debug)]
39#[cfg_attr(feature = "strict", serde(deny_unknown_fields))]
40pub struct Strategy {
41    pub constraints: Option<Vec<Constraint>>,
42    pub name: String,
43    pub parameters: Option<HashMap<String, String>>,
44}
45
46#[derive(Clone, Serialize, Deserialize, Debug, PartialEq)]
47#[serde(rename_all = "camelCase")]
48pub struct Constraint {
49    pub context_name: String,
50    #[serde(default)]
51    pub case_insensitive: bool,
52    #[serde(default)]
53    pub inverted: bool,
54    #[serde(flatten)]
55    pub expression: ConstraintExpression,
56}
57
58#[derive(Clone, Serialize, Deserialize, Debug, PartialEq)]
59#[serde(tag = "operator")]
60#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
61pub enum ConstraintExpression {
62    // Dates
63    DateAfter {
64        value: DateTime<Utc>,
65    },
66    DateBefore {
67        value: DateTime<Utc>,
68    },
69
70    // In
71    In {
72        values: Vec<String>,
73    },
74    NotIn {
75        values: Vec<String>,
76    },
77
78    // Numbers
79    NumEq {
80        #[serde(deserialize_with = "deserialize_number_from_string")]
81        value: f64,
82    },
83    #[serde(rename = "NUM_GT")]
84    NumGT {
85        #[serde(deserialize_with = "deserialize_number_from_string")]
86        value: f64,
87    },
88    #[serde(rename = "NUM_GTE")]
89    NumGTE {
90        #[serde(deserialize_with = "deserialize_number_from_string")]
91        value: f64,
92    },
93    #[serde(rename = "NUM_LT")]
94    NumLT {
95        #[serde(deserialize_with = "deserialize_number_from_string")]
96        value: f64,
97    },
98    #[serde(rename = "NUM_LTE")]
99    NumLTE {
100        #[serde(deserialize_with = "deserialize_number_from_string")]
101        value: f64,
102    },
103
104    // Semver
105    SemverEq {
106        value: Version,
107    },
108    #[serde(rename = "SEMVER_GT")]
109    SemverGT {
110        value: Version,
111    },
112    #[serde(rename = "SEMVER_LT")]
113    SemverLT {
114        value: Version,
115    },
116
117    // String
118    StrContains {
119        values: Vec<String>,
120    },
121    StrStartsWith {
122        values: Vec<String>,
123    },
124    StrEndsWith {
125        values: Vec<String>,
126    },
127
128    #[serde(untagged)]
129    Unknown(Value),
130}
131
132#[derive(Clone, Serialize, Deserialize, Debug)]
133#[cfg_attr(feature = "strict", serde(deny_unknown_fields))]
134pub struct Variant {
135    pub name: String,
136    #[serde(deserialize_with = "deserialize_number_from_string")]
137    pub weight: u16,
138    pub payload: Option<HashMap<String, String>>,
139    pub overrides: Option<Vec<VariantOverride>>,
140}
141
142#[derive(Clone, Serialize, Deserialize, Debug)]
143#[cfg_attr(feature = "strict", serde(deny_unknown_fields))]
144pub struct VariantOverride {
145    #[serde(rename = "contextName")]
146    pub context_name: String,
147    pub values: Vec<String>,
148}
149
150#[derive(Serialize, Deserialize, Debug)]
151pub struct Registration {
152    #[serde(rename = "appName")]
153    pub app_name: String,
154    #[serde(rename = "instanceId")]
155    pub instance_id: String,
156    #[serde(rename = "connectionId")]
157    pub connection_id: String,
158    #[serde(rename = "sdkVersion")]
159    pub sdk_version: String,
160    pub strategies: Vec<String>,
161    pub started: chrono::DateTime<chrono::Utc>,
162    pub interval: u64,
163}
164
165impl Registration {
166    pub fn endpoint(api_url: &str) -> String {
167        format!("{}/client/register", api_url.trim_end_matches('/'))
168    }
169}
170
171impl Default for Registration {
172    fn default() -> Self {
173        Self {
174            app_name: "".into(),
175            instance_id: "".into(),
176            connection_id: "".into(),
177            sdk_version: get_sdk_version().into(),
178            strategies: vec![],
179            started: Utc::now(),
180            interval: 15 * 1000,
181        }
182    }
183}
184
185#[derive(Serialize, Deserialize, Debug)]
186pub struct Metrics {
187    #[serde(rename = "appName")]
188    pub app_name: String,
189    #[serde(rename = "instanceId")]
190    pub instance_id: String,
191    #[serde(rename = "connectionId")]
192    pub connection_id: String,
193    pub bucket: MetricsBucket,
194}
195
196impl Metrics {
197    pub fn endpoint(api_url: &str) -> String {
198        format!("{}/client/metrics", api_url.trim_end_matches('/'))
199    }
200}
201
202#[derive(Serialize, Deserialize, Debug)]
203pub struct ToggleMetrics {
204    pub yes: u64,
205    pub no: u64,
206    pub variants: HashMap<String, u64>,
207}
208
209#[derive(Serialize, Deserialize, Debug)]
210pub struct MetricsBucket {
211    pub start: chrono::DateTime<chrono::Utc>,
212    pub stop: chrono::DateTime<chrono::Utc>,
213    pub toggles: HashMap<String, ToggleMetrics>,
214}
215
216fn deserialize_number_from_string<'de, T, D>(deserializer: D) -> Result<T, D::Error>
217where
218    D: serde::Deserializer<'de>,
219    T: std::str::FromStr + serde::Deserialize<'de>,
220    <T as std::str::FromStr>::Err: std::fmt::Display,
221{
222    #[derive(Deserialize)]
223    #[serde(untagged)]
224    enum StringOrInt<T> {
225        String(String),
226        Number(T),
227    }
228
229    match StringOrInt::<T>::deserialize(deserializer)? {
230        StringOrInt::String(s) => s.parse::<T>().map_err(serde::de::Error::custom),
231        StringOrInt::Number(i) => Ok(i),
232    }
233}
234
235#[cfg(test)]
236mod tests {
237
238    use std::str::FromStr;
239
240    use chrono::{DateTime, FixedOffset};
241    use semver::Version;
242
243    use super::{Constraint, Features, Metrics, Registration};
244
245    #[test]
246    fn parse_reference_doc() -> Result<(), serde_json::Error> {
247        let data = r#"
248    {
249      "version": 1,
250      "features": [
251      {
252        "name": "F1",
253        "description": "Default Strategy, enabledoff, variants",
254        "enabled": false,
255        "strategies": [
256        {
257          "name": "default"
258        }
259        ],
260        "variants":[
261        {"name":"Foo","weight":50,"payload":{"type":"string","value":"bar"}},
262        {"name":"Bar","weight":50,"overrides":[{"contextName":"userId","values":["robert"]}]}
263        ],
264        "createdAt": "2020-04-28T07:26:27.366Z"
265      },
266      {
267        "name": "F2",
268        "description": "customStrategy+params, enabled",
269        "enabled": true,
270        "strategies": [
271        {
272          "name": "customStrategy",
273          "parameters": {
274            "strategyParameter": "data,goes,here"
275          }
276        }
277        ],
278        "variants": null,
279        "createdAt": "2020-01-12T15:05:11.462Z"
280      },
281      {
282        "name": "F3",
283        "description": "two strategies",
284        "enabled": true,
285        "strategies": [
286        {
287          "name": "customStrategy",
288          "parameters": {
289            "strategyParameter": "data,goes,here"
290          }
291        },
292        {
293          "name": "default",
294          "parameters": {}
295        }
296        ],
297        "variants": null,
298        "createdAt": "2019-09-30T09:00:39.282Z"
299      },
300      {
301        "name": "F4",
302        "description": "Multiple params",
303        "enabled": true,
304        "strategies": [
305        {
306          "name": "customStrategy",
307          "parameters": {
308            "p1": "foo",
309            "p2": "bar"
310          }
311        }
312        ],
313        "variants": null,
314        "createdAt": "2020-03-17T01:07:25.713Z"
315      }
316      ]
317    }
318    "#;
319        let parsed: super::Features = serde_json::from_str(data)?;
320        assert_eq!(1, parsed.version);
321        Ok(())
322    }
323
324    #[test]
325    fn parse_null_feature_doc() -> Result<(), serde_json::Error> {
326        let data = r#"
327    {
328      "version": 1,
329      "features": [
330      {
331        "name": "F1",
332        "description": null,
333        "enabled": false,
334        "strategies": [
335        {
336          "name": "default"
337        }
338        ],
339        "variants":[
340        {"name":"Foo","weight":50,"payload":{"type":"string","value":"bar"}},
341        {"name":"Bar","weight":50,"overrides":[{"contextName":"userId","values":["robert"]}]}
342        ],
343        "createdAt": "2020-04-28T07:26:27.366Z"
344      }
345      ]
346    }
347    "#;
348        let parsed: super::Features = serde_json::from_str(data)?;
349        assert_eq!(1, parsed.version);
350        Ok(())
351    }
352
353    #[test]
354    fn test_parse_variant_with_str_weight() -> Result<(), serde_json::Error> {
355        let data = r#"
356      {"name":"Foo","weight":"50","payload":{"type":"string","value":"bar"}}
357      "#;
358        let parsed: super::Variant = serde_json::from_str(data)?;
359        assert_eq!(50, parsed.weight);
360        Ok(())
361    }
362
363    #[test]
364    fn test_parse_constraint() -> Result<(), serde_json::Error> {
365        use super::ConstraintExpression::*;
366        let data = r#"[
367          {
368            "contextName": "appId",
369            "operator": "IN",
370            "values": [
371                "app.known.name"
372            ],
373            "caseInsensitive": false,
374            "inverted": false
375          },
376          {
377            "contextName": "currentTime",
378            "operator": "DATE_AFTER",
379            "caseInsensitive": false,
380            "inverted": false,
381            "value": "2025-07-17T23:59:00.000Z"
382          },
383          {
384            "contextName": "remoteAddress",
385            "operator": "NUM_GTE",
386            "caseInsensitive": false,
387            "inverted": false,
388            "value": "3333"
389          },
390          {
391            "contextName": "appId",
392            "operator": "NUM_EQ",
393            "caseInsensitive": false,
394            "inverted": false,
395            "value": "888"
396          },
397          {
398            "contextName": "appId",
399            "operator": "SEMVER_EQ",
400            "caseInsensitive": false,
401            "inverted": false,
402            "value": "1.2.3",
403            "values": []
404          }
405        ]"#;
406
407        let parsed: Vec<Constraint> = serde_json::from_str(data)?;
408
409        assert_eq!(
410            parsed,
411            vec![
412                Constraint {
413                    context_name: "appId".to_string(),
414                    case_insensitive: false,
415                    inverted: false,
416                    expression: In {
417                        values: vec!["app.known.name".to_string(),],
418                    },
419                },
420                Constraint {
421                    context_name: "currentTime".to_string(),
422                    case_insensitive: false,
423                    inverted: false,
424                    expression: DateAfter {
425                        value: DateTime::<FixedOffset>::parse_from_rfc3339("2025-07-17T23:59:00Z")
426                            .unwrap()
427                            .to_utc(),
428                    },
429                },
430                Constraint {
431                    context_name: "remoteAddress".to_string(),
432                    case_insensitive: false,
433                    inverted: false,
434                    expression: NumGTE { value: 3333.0 },
435                },
436                Constraint {
437                    context_name: "appId".to_string(),
438                    case_insensitive: false,
439                    inverted: false,
440                    expression: NumEq { value: 888.0 },
441                },
442                Constraint {
443                    context_name: "appId".to_string(),
444                    case_insensitive: false,
445                    inverted: false,
446                    expression: SemverEq {
447                        value: Version::from_str("1.2.3").unwrap(),
448                    },
449                },
450            ]
451        );
452        Ok(())
453    }
454
455    #[test]
456    fn test_registration_customisation() {
457        Registration {
458            app_name: "test-suite".into(),
459            instance_id: "test".into(),
460            connection_id: "test".into(),
461            strategies: vec!["default".into()],
462            interval: 5000,
463            ..Default::default()
464        };
465    }
466
467    #[test]
468    fn test_endpoints_handle_trailing_slashes() {
469        assert_eq!(
470            Registration::endpoint("https://localhost:4242/api"),
471            "https://localhost:4242/api/client/register"
472        );
473        assert_eq!(
474            Registration::endpoint("https://localhost:4242/api/"),
475            "https://localhost:4242/api/client/register"
476        );
477
478        assert_eq!(
479            Features::endpoint("https://localhost:4242/api"),
480            "https://localhost:4242/api/client/features"
481        );
482        assert_eq!(
483            Features::endpoint("https://localhost:4242/api/"),
484            "https://localhost:4242/api/client/features"
485        );
486
487        assert_eq!(
488            Metrics::endpoint("https://localhost:4242/api"),
489            "https://localhost:4242/api/client/metrics"
490        );
491        assert_eq!(
492            Metrics::endpoint("https://localhost:4242/api/"),
493            "https://localhost:4242/api/client/metrics"
494        );
495    }
496}