1mod 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 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 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 pub fn get_default_json(&self) -> Result<String> {
140 Ok(serde_json::to_string(&self.default_json)?)
141 }
142
143 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}