1use 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 DateAfter {
64 value: DateTime<Utc>,
65 },
66 DateBefore {
67 value: DateTime<Utc>,
68 },
69
70 In {
72 values: Vec<String>,
73 },
74 NotIn {
75 values: Vec<String>,
76 },
77
78 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 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 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}