nimbus_fml/client/
mod.rs

1/* This Source Code Form is subject to the terms of the Mozilla Public
2* License, v. 2.0. If a copy of the MPL was not distributed with this
3* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4
5mod config;
6mod descriptor;
7mod inspector;
8#[cfg(test)]
9mod test_helper;
10
11pub use config::FmlLoaderConfig;
12cfg_if::cfg_if! {
13    if #[cfg(feature = "uniffi-bindings")] {
14    use crate::{editing::{CorrectionCandidate, CursorPosition, CursorSpan}, frontend::DocumentationLink};
15    use url::Url;
16    use std::str::FromStr;
17    use email_address::EmailAddress;
18    use descriptor::FmlFeatureDescriptor;
19    use inspector::{FmlEditorError, FmlFeatureExample, FmlFeatureInspector};
20    }
21}
22use serde_json::Value;
23
24use crate::{
25    error::{ClientError::JsonMergeError, FMLError, Result},
26    intermediate_representation::FeatureManifest,
27    parser::Parser,
28    util::loaders::{FileLoader, LoaderConfig},
29};
30use std::collections::HashMap;
31
32use std::sync::Arc;
33
34pub struct MergedJsonWithErrors {
35    pub json: String,
36    pub errors: Vec<FMLError>,
37}
38
39pub struct FmlClient {
40    pub(crate) manifest: Arc<FeatureManifest>,
41    pub(crate) default_json: serde_json::Map<String, serde_json::Value>,
42}
43
44fn get_default_json_for_manifest(manifest: &FeatureManifest) -> Result<JsonObject> {
45    if let Value::Object(json) = manifest.default_json() {
46        Ok(json)
47    } else {
48        Err(FMLError::ClientError(JsonMergeError(
49            "Manifest default json is not an object".to_string(),
50        )))
51    }
52}
53
54impl FmlClient {
55    /// Constructs a new FmlClient object.
56    ///
57    /// Definitions of the parameters are as follows:
58    /// - `manifest_path`: The path (relative to the current working directory) to the fml.yml that should be loaded.
59    /// - `channel`: The channel that should be loaded for the manifest.
60    pub fn new(manifest_path: String, channel: String) -> Result<Self> {
61        Self::new_with_ref(manifest_path, channel, None)
62    }
63
64    pub fn new_with_ref(
65        manifest_path: String,
66        channel: String,
67        ref_: Option<String>,
68    ) -> Result<Self> {
69        let config = Self::create_loader(&manifest_path, ref_.as_deref());
70        Self::new_with_config(manifest_path, channel, config)
71    }
72
73    pub fn new_with_config(
74        manifest_path: String,
75        channel: String,
76        config: FmlLoaderConfig,
77    ) -> Result<Self> {
78        let config: LoaderConfig = config.into();
79        let files = FileLoader::try_from(&config)?;
80        let path = files.file_path(&manifest_path)?;
81        let parser: Parser = Parser::new(files, path)?;
82        let ir = parser.get_intermediate_representation(Some(&channel))?;
83        ir.validate_manifest()?;
84
85        Ok(FmlClient {
86            default_json: get_default_json_for_manifest(&ir)?,
87            manifest: Arc::new(ir),
88        })
89    }
90
91    #[cfg(test)]
92    pub fn new_from_manifest(manifest: FeatureManifest) -> Self {
93        manifest.validate_manifest().ok();
94        Self {
95            default_json: get_default_json_for_manifest(&manifest).ok().unwrap(),
96            manifest: Arc::new(manifest),
97        }
98    }
99
100    fn create_loader(manifest_path: &str, ref_: Option<&str>) -> FmlLoaderConfig {
101        let mut refs: HashMap<_, _> = Default::default();
102        match (LoaderConfig::repo_and_path(manifest_path), ref_) {
103            (Some((repo, _)), Some(ref_)) => refs.insert(repo, ref_.to_string()),
104            _ => None,
105        };
106
107        FmlLoaderConfig {
108            refs,
109            ..Default::default()
110        }
111    }
112
113    /// Validates a supplied list of feature configurations. The valid configurations will be merged into the manifest's
114    /// default feature JSON, and invalid configurations will be returned as a list of their respective errors.
115    pub fn merge(
116        &self,
117        feature_configs: HashMap<String, JsonObject>,
118    ) -> Result<MergedJsonWithErrors> {
119        let mut json = self.default_json.clone();
120        let mut errors: Vec<FMLError> = Default::default();
121        for (feature_id, value) in feature_configs {
122            match self
123                .manifest
124                .validate_feature_config(&feature_id, serde_json::Value::Object(value))
125            {
126                Ok(fd) => {
127                    json.insert(feature_id, fd.default_json());
128                }
129                Err(e) => errors.push(e),
130            };
131        }
132        Ok(MergedJsonWithErrors {
133            json: serde_json::to_string(&json)?,
134            errors,
135        })
136    }
137
138    /// Returns the default feature JSON for the loaded FML's selected channel.
139    pub fn get_default_json(&self) -> Result<String> {
140        Ok(serde_json::to_string(&self.default_json)?)
141    }
142
143    /// Returns a list of feature ids that support coenrollment.
144    pub fn get_coenrolling_feature_ids(&self) -> Result<Vec<String>> {
145        Ok(self.manifest.get_coenrolling_feature_ids())
146    }
147}
148
149pub(crate) type JsonObject = serde_json::Map<String, serde_json::Value>;
150
151#[cfg(feature = "uniffi-bindings")]
152uniffi::custom_type!(JsonObject, String, {
153    remote,
154    try_lift: |val| {
155        let json: serde_json::Value = serde_json::from_str(&val)?;
156
157        match json.as_object() {
158            Some(obj) => Ok(obj.to_owned()),
159            _ => Err(uniffi::deps::anyhow::anyhow!(
160                "Unexpected JSON-non-object in the bagging area"
161            )),
162        }
163    },
164    lower: |obj| serde_json::Value::Object(obj).to_string(),
165});
166
167#[cfg(feature = "uniffi-bindings")]
168uniffi::custom_type!(Url, String, {
169    remote,
170    try_lift: |val| Ok(Self::from_str(&val)?),
171    lower: |obj| obj.as_str().to_string(),
172});
173
174#[cfg(feature = "uniffi-bindings")]
175uniffi::custom_type!(EmailAddress, String, {
176    remote,
177    try_lift: |val| Ok(Self::from_str(val.as_str())?),
178    lower: |obj| obj.as_str().to_string(),
179});
180
181#[cfg(feature = "uniffi-bindings")]
182uniffi::include_scaffolding!("fml");
183
184#[cfg(test)]
185mod unit_tests {
186    use super::*;
187    use crate::{
188        fixtures::intermediate_representation::get_feature_manifest,
189        intermediate_representation::{FeatureDef, ModuleId, PropDef, TypeRef},
190    };
191    use serde_json::{json, Value};
192    use std::collections::{BTreeMap, HashMap};
193
194    fn create_manifest() -> FeatureManifest {
195        let fm_i = get_feature_manifest(
196            vec![],
197            vec![],
198            vec![FeatureDef {
199                name: "feature_i".into(),
200                props: vec![PropDef::new(
201                    "prop_i_1",
202                    &TypeRef::String,
203                    &json!("prop_i_1_value"),
204                )],
205                metadata: Default::default(),
206                ..Default::default()
207            }],
208            BTreeMap::new(),
209        );
210
211        get_feature_manifest(
212            vec![],
213            vec![],
214            vec![FeatureDef {
215                name: "feature".into(),
216                props: vec![PropDef::new(
217                    "prop_1",
218                    &TypeRef::String,
219                    &json!("prop_1_value"),
220                )],
221                metadata: Default::default(),
222                allow_coenrollment: true,
223                ..Default::default()
224            }],
225            BTreeMap::from([(ModuleId::Local("test".into()), fm_i)]),
226        )
227    }
228
229    #[test]
230    fn test_get_default_json() -> Result<()> {
231        let json_result = get_default_json_for_manifest(&create_manifest())?;
232
233        assert_eq!(
234            Value::Object(json_result),
235            json!({
236                "feature": {
237                    "prop_1": "prop_1_value"
238                },
239                "feature_i": {
240                    "prop_i_1": "prop_i_1_value"
241                }
242            })
243        );
244
245        Ok(())
246    }
247
248    #[test]
249    fn test_validate_and_merge_feature_configs() -> Result<()> {
250        let client: FmlClient = create_manifest().into();
251
252        let result = client.merge(HashMap::from_iter([
253            (
254                "feature".to_string(),
255                json!({ "prop_1": "new value" })
256                    .as_object()
257                    .unwrap()
258                    .clone(),
259            ),
260            (
261                "feature_i".to_string(),
262                json!({"prop_i_1": 1}).as_object().unwrap().clone(),
263            ),
264        ]))?;
265
266        assert_eq!(
267            serde_json::from_str::<Value>(&result.json)?,
268            json!({
269                "feature": {
270                    "prop_1": "new value"
271                },
272                "feature_i": {
273                    "prop_i_1": "prop_i_1_value"
274                }
275            })
276        );
277        assert_eq!(result.errors.len(), 1);
278        assert_eq!(
279            result.errors[0].to_string(),
280            "Validation Error at features/feature_i.prop_i_1: Invalid value 1 for type String"
281                .to_string()
282        );
283
284        Ok(())
285    }
286
287    #[test]
288    fn test_get_coenrolling_feature_ids() -> Result<()> {
289        let client: FmlClient = create_manifest().into();
290        let result = client.get_coenrolling_feature_ids();
291
292        assert_eq!(result.unwrap(), vec!["feature"]);
293
294        Ok(())
295    }
296}
297
298#[cfg(test)]
299mod string_aliases {
300    use super::{test_helper::client, *};
301
302    #[test]
303    fn test_simple_feature() -> Result<()> {
304        let client = client("string-aliases.fml.yaml", "storms")?;
305        let inspector = {
306            let i = client.get_feature_inspector("my-simple-team".to_string());
307            assert!(i.is_some());
308            i.unwrap()
309        };
310
311        // -> feature my-sports:
312        //      player-availability: Map<PlayerName, Boolean> (PlayerName is the set of strings in this list)
313        //      captain: Option<PlayerName>
314        //      the-team: List<SportName> (SportName is the set of string that are keys in this map)
315
316        // Happy path. This configuration is internally consistent.
317        let errors = inspector.get_errors(
318            r#"{
319                "captain": "Babet",
320                "the-team": ["Babet", "Elin", "Isha"]
321            }"#
322            .to_string(),
323        );
324        assert_eq!(None, errors);
325
326        // ----------------------------
327        // Donkey cannot be the captain
328        // Donkey is not a key in the default) player-availability map.
329        let errors = inspector.get_errors(
330            r#"{
331                "captain": "Donkey",
332                "the-team": ["Babet", "Elin", "Isha"]
333            }"#
334            .to_string(),
335        );
336        let expected = r#"Invalid value "Donkey" for type PlayerName; did you mean one of "Agnes", "Babet", "Ciarán", "Debi", "Elin", "Fergus", "Gerrit", "Henk", "Isha", "Jocelyn", "Kathleen" or "Lilian"?"#;
337        assert!(errors.is_some());
338        let errors = errors.unwrap();
339        let err = errors.first().unwrap();
340        assert_eq!(Some("\"Donkey\""), err.highlight.as_deref());
341        assert_eq!(expected, err.message.as_str());
342
343        // -------------------------------------------
344        // Donkey cannot play as a member of the-team.
345        // Donkey is not a key in the default) player-availability map.
346        let errors = inspector.get_errors(
347            r#"{
348                "captain": "Gerrit",
349                "the-team": ["Babet", "Donkey", "Isha"]
350            }"#
351            .to_string(),
352        );
353        assert!(errors.is_some());
354        let errors = errors.unwrap();
355        let err = errors.first().unwrap();
356        assert_eq!(Some("\"Donkey\""), err.highlight.as_deref());
357        assert_eq!(expected, err.message.as_str());
358
359        // -----------------------------------------------------------
360        // Surprise! Donkey is now available!
361        // because we added them to the player-availability map.
362        let errors = inspector.get_errors(
363            r#"{
364                "player-availability": {
365                    "Donkey": true
366                },
367                "captain": "Donkey",
368                "the-team": ["Babet", "Elin", "Isha"]
369            }"#
370            .to_string(),
371        );
372        assert_eq!(None, errors);
373
374        Ok(())
375    }
376
377    #[test]
378    fn test_objects_in_a_feature() -> Result<()> {
379        let client = client("string-aliases.fml.yaml", "cyclones")?;
380        let inspector = {
381            let i = client.get_feature_inspector("my-sports".to_string());
382            assert!(i.is_some());
383            i.unwrap()
384        };
385
386        // -> feature my-sports:
387        //      available-players: List<PlayerName> (PlayerName is the set of strings in this list)
388        //      my-favourite-teams: Map<SportName, Team> (SportName is the set of string that are keys in this map)
389        // -> class Team:
390        //      sport: SportName
391        //      players: List<PlayerName>
392
393        // Happy path test.
394        // Note that neither KABADDI nor CHESS appeared in the manifest.
395        let errors = inspector.get_errors(
396            r#"{
397                "my-favorite-teams": {
398                    "KABADDI": {
399                        "sport": "KABADDI",
400                        "players": ["Aka", "Hene", "Lino"]
401                    },
402                    "CHESS": {
403                        "sport": "CHESS",
404                        "players": ["Mele", "Nona", "Pama"]
405                    }
406                }
407            }"#
408            .to_string(),
409        );
410        assert_eq!(None, errors);
411
412        // ----------------------------------------------------------------
413        // Only CHESS is a valid game in this configuration, not CONNECT-4.
414        let errors = inspector.get_errors(
415            r#"{
416                "my-favorite-teams": {
417                    "CHESS": {
418                        "sport": "CONNECT-4",
419                        "players": ["Mele", "Nona", "Pama"]
420                    }
421                }
422            }"#
423            .to_string(),
424        );
425        assert!(errors.is_some());
426        let errors = errors.unwrap();
427        let err = errors.first().unwrap();
428        assert_eq!(Some("\"CONNECT-4\""), err.highlight.as_deref());
429        assert_eq!(
430            "Invalid value \"CONNECT-4\" for type SportName; did you mean \"CHESS\"?",
431            err.message.as_str()
432        );
433
434        // ------------------------------------------------------------------
435        // Only CHESS is a valid game in this configuration, not the default,
436        // which is "MY_DEFAULT"
437        let errors = inspector.get_errors(
438            r#"{
439                "my-favorite-teams": {
440                    "CHESS": {
441                        "players": ["Mele", "Nona", "Pama"]
442                    }
443                }
444            }"#
445            .to_string(),
446        );
447        assert!(errors.is_some());
448        let errors = errors.unwrap();
449        let err = errors.first().unwrap();
450        assert_eq!(Some("{"), err.highlight.as_deref());
451        assert_eq!(
452            "A valid value for sport of type SportName is missing",
453            err.message.as_str()
454        );
455
456        // ----------------------------------------------------
457        // Now CONNECT-4 is a valid game, but Donkey can't play
458        let errors = inspector.get_errors(
459            r#"{
460                "my-favorite-teams": {
461                    "CONNECT-4": {
462                        "sport": "CONNECT-4",
463                        "players": ["Nona", "Pama", "Donkey"]
464                    }
465                }
466            }"#
467            .to_string(),
468        );
469        assert!(errors.is_some());
470        let errors = errors.unwrap();
471        let err = errors.first().unwrap();
472        assert_eq!(Some("\"Donkey\""), err.highlight.as_deref());
473
474        // ------------------------------------------------------------------
475        // Oh no! Donkey is the only available player, so Aka is highlighted
476        // as in error.
477        let errors = inspector.get_errors(
478            r#"{
479                "available-players": ["Donkey"],
480                "my-favorite-teams": {
481                    "CONNECT-4": {
482                        "sport": "CONNECT-4",
483                        "players": ["Donkey", "Aka"]
484                    }
485                }
486            }"#
487            .to_string(),
488        );
489        assert!(errors.is_some());
490        let errors = errors.unwrap();
491        let err = errors.first().unwrap();
492        assert_eq!(Some("\"Aka\""), err.highlight.as_deref());
493
494        // ------------------------------------------------------------
495        // Surprise! Donkey is the only available player, for all games,
496        let errors = inspector.get_errors(
497            r#"{
498                "available-players": ["Donkey"],
499                "my-favorite-teams": {
500                    "CONNECT-4": {
501                        "sport": "CONNECT-4",
502                        "players": ["Donkey", "Donkey", "Donkey"]
503                    },
504                    "CHESS": {
505                        "sport": "CONNECT-4",
506                        "players": ["Donkey", "Donkey"]
507                    },
508                    "GO": {
509                        "sport": "CONNECT-4",
510                        "players": ["Donkey"]
511                    }
512                }
513            }"#
514            .to_string(),
515        );
516        assert_eq!(None, errors);
517
518        Ok(())
519    }
520
521    #[test]
522    fn test_deeply_nested_objects_in_a_feature() -> Result<()> {
523        let client = client("string-aliases.fml.yaml", "cyclones")?;
524        let inspector = {
525            let i = client.get_feature_inspector("my-fixture".to_string());
526            assert!(i.is_some());
527            i.unwrap()
528        };
529
530        // -> feature my-fixture:
531        //      available-players: List<PlayerName> (PlayerName is the set of strings in this list)
532        //      the-sport: SportName (SportName is the set of string containing only this value)
533        //      the-match: Match
534        // -> class Match:
535        //      away: Team
536        //      home: Team
537        // -> class Team:
538        //      sport: SportName
539        //      players: List<PlayerName>
540
541        // Happy path test.
542        // All the sports match the-sport, and the players are all in the
543        // available-players list.
544        let errors = inspector.get_errors(
545            r#"{
546                "the-sport": "Archery",
547                "the-match": {
548                    "home": {
549                        "sport": "Archery",
550                        "players": ["Aka", "Hene", "Lino"]
551                    },
552                    "away": {
553                        "sport": "Archery",
554                        "players": ["Mele", "Nona", "Pama"]
555                    }
556                }
557            }"#
558            .to_string(),
559        );
560        assert_eq!(None, errors);
561
562        // ----------------------------------------------------------------
563        // All the sports need to match, because it's only set by the-sport.
564        let errors = inspector.get_errors(
565            r#"{
566                "the-sport": "Karate",
567                "the-match": {
568                    "home": {
569                        "sport": "Karate",
570                        "players": ["Aka", "Hene", "Lino"]
571                    },
572                    "away": {
573                        "sport": "Archery",
574                        "players": ["Mele", "Nona", "Pama"]
575                    }
576                }
577            }"#
578            .to_string(),
579        );
580        assert!(errors.is_some());
581        let errors = errors.unwrap();
582        let err = errors.first().unwrap();
583        assert_eq!(Some("\"Archery\""), err.highlight.as_deref());
584
585        Ok(())
586    }
587}
588
589#[cfg(test)]
590mod error_messages {
591    use crate::client::test_helper::client;
592
593    use super::*;
594
595    #[test]
596    fn test_string_aliases() -> Result<()> {
597        let client = client("string-aliases.fml.yaml", "cyclones")?;
598        let inspector = {
599            let i = client.get_feature_inspector("my-coverall-team".to_string());
600            assert!(i.is_some());
601            i.unwrap()
602        };
603
604        // An invalid boolean value of string alias type
605        let error = {
606            let errors = inspector.get_errors(
607                r#"{
608                    "players": ["George", "Mildred"],
609                    "top-player": true
610                }"#
611                .to_string(),
612            );
613            assert!(errors.is_some());
614            errors.unwrap().remove(0)
615        };
616        assert_eq!(
617            error.message.as_str(),
618            "Invalid value true for type PlayerName; did you mean \"George\" or \"Mildred\"?"
619        );
620
621        // An invalid string value of string alias type
622        let error = {
623            let errors = inspector.get_errors(
624                r#"{
625                    "players": ["George", "Mildred"],
626                    "top-player": "Donkey"
627                }"#
628                .to_string(),
629            );
630            assert!(errors.is_some());
631            errors.unwrap().remove(0)
632        };
633        assert_eq!(
634            error.message.as_str(),
635            "Invalid value \"Donkey\" for type PlayerName; did you mean \"George\" or \"Mildred\"?"
636        );
637
638        // An invalid key of string alias type should not suggest all values for the string-alias, just the unused ones.
639        let error = {
640            let errors = inspector.get_errors(
641                r#"{
642                    "players": ["George", "Mildred"],
643                    "availability": {
644                        "George": true,
645                        "Donkey": true
646                    }
647                }"#
648                .to_string(),
649            );
650            assert!(errors.is_some());
651            errors.unwrap().remove(0)
652        };
653        assert_eq!(
654            error.message.as_str(),
655            "Invalid key \"Donkey\" for type PlayerName; did you mean \"Mildred\"?"
656        );
657
658        Ok(())
659    }
660
661    #[test]
662    fn test_invalid_properties() -> Result<()> {
663        let client = client("enums.fml.yaml", "release")?;
664        let inspector = {
665            let i = client.get_feature_inspector("my-coverall-feature".to_string());
666            assert!(i.is_some());
667            i.unwrap()
668        };
669
670        // An invalid property
671        let error = {
672            let errors = inspector.get_errors(
673                r#"{
674                    "invalid-property": true
675                }"#
676                .to_string(),
677            );
678            assert!(errors.is_some());
679            errors.unwrap().remove(0)
680        };
681        assert_eq!(
682            error.message.as_str(),
683            "Invalid property \"invalid-property\"; did you mean one of \"list\", \"map\", \"optional\" or \"scalar\"?"
684        );
685
686        // An invalid property, with a suggestion missing out the ones already in use.
687        let error = {
688            let errors = inspector.get_errors(
689                r#"{
690                    "invalid-property": true,
691                    "optional": null
692                }"#
693                .to_string(),
694            );
695            assert!(errors.is_some());
696            errors.unwrap().remove(0)
697        };
698        assert_eq!(
699            error.message.as_str(),
700            "Invalid property \"invalid-property\"; did you mean one of \"list\", \"map\" or \"scalar\"?"
701        );
702        Ok(())
703    }
704
705    #[test]
706    fn test_enums() -> Result<()> {
707        let client = client("enums.fml.yaml", "release")?;
708        let inspector = {
709            let i = client.get_feature_inspector("my-coverall-feature".to_string());
710            assert!(i.is_some());
711            i.unwrap()
712        };
713
714        // An invalid boolean value of enum type
715        let error = {
716            let errors = inspector.get_errors(
717                r#"{
718                    "scalar": true
719                }"#
720                .to_string(),
721            );
722            assert!(errors.is_some());
723            errors.unwrap().remove(0)
724        };
725        assert_eq!(
726            error.message.as_str(),
727            "Invalid value true for type ViewPosition; did you mean one of \"bottom\", \"middle\" or \"top\"?"
728        );
729
730        // An invalid string value of enum type
731        let error = {
732            let errors = inspector.get_errors(
733                r#"{
734                    "scalar": "invalid-value"
735                }"#
736                .to_string(),
737            );
738            assert!(errors.is_some());
739            errors.unwrap().remove(0)
740        };
741        assert_eq!(
742            error.message.as_str(),
743            "Invalid value \"invalid-value\" for type ViewPosition; did you mean one of \"bottom\", \"middle\" or \"top\"?"
744        );
745
746        // An invalid key of enum type should not suggest all values for the string-alias, just the unused ones.
747        let error = {
748            let errors = inspector.get_errors(
749                r#"{
750                    "map": {
751                        "top": true,
752                        "invalid-key": true
753                    }
754                }"#
755                .to_string(),
756            );
757            assert!(errors.is_some());
758            errors.unwrap().remove(0)
759        };
760        assert_eq!(
761            error.message.as_str(),
762            "Invalid key \"invalid-key\" for type ViewPosition; did you mean \"bottom\" or \"middle\"?"
763        );
764
765        Ok(())
766    }
767}