1use std::collections::BTreeMap;
2use std::path::Path;
3
4use serde::{Deserialize, Serialize};
5
6use crate::error::IacForgeError;
7
8pub trait ConfigLoader: Sized + serde::de::DeserializeOwned {
13 fn load(path: &Path) -> Result<Self, IacForgeError> {
19 let content = std::fs::read_to_string(path)?;
20 let spec: Self = toml::from_str(&content)?;
21 Ok(spec)
22 }
23
24 fn from_toml(content: &str) -> Result<Self, IacForgeError> {
30 let spec: Self = toml::from_str(content)?;
31 Ok(spec)
32 }
33}
34
35impl ConfigLoader for ResourceSpec {}
36impl ConfigLoader for DataSourceSpec {}
37impl ConfigLoader for ProviderSpec {}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct ResourceSpec {
54 pub resource: ResourceMeta,
56 #[serde(default)]
62 pub crud: CrudMapping,
63 #[serde(default)]
65 pub action: Option<ActionMapping>,
66 #[serde(default)]
71 pub identity: IdentityConfig,
72 #[serde(default)]
74 pub fields: BTreeMap<String, FieldOverride>,
75 #[serde(default)]
77 pub read_mapping: BTreeMap<String, String>,
78}
79
80impl ResourceSpec {
81 #[must_use]
86 pub fn is_action(&self) -> bool {
87 matches!(self.resource.kind.as_deref(), Some("action"))
88 }
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct ActionMapping {
98 pub endpoint: String,
100 pub schema: String,
102 #[serde(default)]
105 pub response_schema: Option<String>,
106 #[serde(default)]
109 pub mutating: Option<bool>,
110 #[serde(default)]
113 pub sensitive_response_fields: Vec<String>,
114 #[serde(default)]
121 pub sdk_method: Option<String>,
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize)]
126pub struct ResourceMeta {
127 pub name: String,
129 #[serde(default)]
131 pub description: String,
132 #[serde(default)]
134 pub category: String,
135 #[serde(default)]
141 pub kind: Option<String>,
142}
143
144#[derive(Debug, Clone, Default, Serialize, Deserialize)]
146pub struct CrudMapping {
147 pub create_endpoint: String,
149 pub create_schema: String,
151 #[serde(default)]
153 pub update_endpoint: Option<String>,
154 #[serde(default)]
156 pub update_schema: Option<String>,
157 pub read_endpoint: String,
159 pub read_schema: String,
161 #[serde(default)]
163 pub read_response_schema: Option<String>,
164 pub delete_endpoint: String,
166 pub delete_schema: String,
168}
169
170#[derive(Debug, Clone, Default, Serialize, Deserialize)]
172pub struct IdentityConfig {
173 pub id_field: String,
175 #[serde(default)]
177 pub import_field: Option<String>,
178 #[serde(default)]
180 pub force_new_fields: Vec<String>,
181}
182
183#[derive(Debug, Clone, Default, Serialize, Deserialize)]
185#[allow(clippy::struct_excessive_bools)]
186pub struct FieldOverride {
187 #[serde(default)]
189 pub computed: bool,
190 #[serde(default)]
192 pub sensitive: bool,
193 #[serde(default)]
195 pub skip: bool,
196 #[serde(default)]
198 pub type_override: Option<String>,
199 #[serde(default)]
201 pub description: Option<String>,
202 #[serde(default)]
204 pub force_new: bool,
205}
206
207#[derive(Debug, Clone, Serialize, Deserialize)]
209pub struct ProviderSpec {
210 pub provider: ProviderMeta,
212 #[serde(default)]
214 pub auth: AuthConfig,
215 #[serde(default)]
217 pub defaults: ProviderDefaults,
218 #[serde(default)]
220 pub platforms: BTreeMap<String, toml::Value>,
221}
222
223#[derive(Debug, Clone, Serialize, Deserialize)]
225pub struct ProviderMeta {
226 pub name: String,
228 #[serde(default)]
230 pub description: String,
231 #[serde(default)]
233 pub version: String,
234 #[serde(default)]
236 pub sdk_import: String,
237}
238
239#[derive(Debug, Clone, Default, Serialize, Deserialize)]
241pub struct AuthConfig {
242 #[serde(default)]
244 pub token_field: String,
245 #[serde(default)]
247 pub env_var: String,
248 #[serde(default)]
250 pub gateway_url_field: String,
251 #[serde(default)]
253 pub gateway_env_var: String,
254}
255
256#[derive(Debug, Clone, Default, Serialize, Deserialize)]
258pub struct ProviderDefaults {
259 #[serde(default)]
261 pub skip_fields: Vec<String>,
262}
263
264impl ResourceSpec {
265 pub fn load(path: &Path) -> Result<Self, IacForgeError> {
273 <Self as ConfigLoader>::load(path)
274 }
275
276 pub fn validate(&self, api: &openapi_forge::Spec) -> Result<(), IacForgeError> {
288 if self.is_action() {
289 return self.validate_action(api);
290 }
291
292 api.schema(&self.crud.create_schema)
293 .map_err(|_| IacForgeError::SchemaNotFound(self.crud.create_schema.clone()))?;
294
295 api.schema(&self.crud.read_schema)
296 .map_err(|_| IacForgeError::SchemaNotFound(self.crud.read_schema.clone()))?;
297
298 api.schema(&self.crud.delete_schema)
299 .map_err(|_| IacForgeError::SchemaNotFound(self.crud.delete_schema.clone()))?;
300
301 if let Some(ref update_schema) = self.crud.update_schema {
302 api.schema(update_schema)
303 .map_err(|_| IacForgeError::SchemaNotFound(update_schema.clone()))?;
304 }
305
306 if let Some(ref response_schema) = self.crud.read_response_schema {
307 api.schema(response_schema)
308 .map_err(|_| IacForgeError::SchemaNotFound(response_schema.clone()))?;
309 }
310
311 if api.endpoint_by_path(&self.crud.create_endpoint).is_none() {
312 return Err(IacForgeError::MissingEndpoint {
313 resource: self.resource.name.clone(),
314 endpoint: self.crud.create_endpoint.clone(),
315 });
316 }
317
318 Ok(())
319 }
320
321 fn validate_action(&self, api: &openapi_forge::Spec) -> Result<(), IacForgeError> {
322 let action = self.action.as_ref().ok_or_else(|| {
323 IacForgeError::ValidationError(format!(
324 "resource '{}' has kind = \"action\" but no [action] table",
325 self.resource.name
326 ))
327 })?;
328
329 api.schema(&action.schema)
330 .map_err(|_| IacForgeError::SchemaNotFound(action.schema.clone()))?;
331
332 if let Some(ref response_schema) = action.response_schema {
333 api.schema(response_schema)
334 .map_err(|_| IacForgeError::SchemaNotFound(response_schema.clone()))?;
335 }
336
337 if api.endpoint_by_path(&action.endpoint).is_none() {
338 return Err(IacForgeError::MissingEndpoint {
339 resource: self.resource.name.clone(),
340 endpoint: action.endpoint.clone(),
341 });
342 }
343
344 Ok(())
345 }
346}
347
348impl ProviderSpec {
349 pub fn load(path: &Path) -> Result<Self, IacForgeError> {
357 <Self as ConfigLoader>::load(path)
358 }
359}
360
361#[derive(Debug, Clone, Serialize, Deserialize)]
363pub struct DataSourceSpec {
364 pub data_source: DataSourceMeta,
366 pub read: ReadMapping,
368 #[serde(default)]
370 pub fields: BTreeMap<String, FieldOverride>,
371 #[serde(default)]
373 pub read_mapping: BTreeMap<String, String>,
374}
375
376#[derive(Debug, Clone, Serialize, Deserialize)]
378pub struct DataSourceMeta {
379 pub name: String,
381 #[serde(default)]
383 pub description: String,
384}
385
386#[derive(Debug, Clone, Serialize, Deserialize)]
388pub struct ReadMapping {
389 pub endpoint: String,
391 pub schema: String,
393 #[serde(default)]
395 pub response_schema: Option<String>,
396}
397
398impl DataSourceSpec {
399 pub fn load(path: &Path) -> Result<Self, IacForgeError> {
407 <Self as ConfigLoader>::load(path)
408 }
409
410 pub fn validate(&self, api: &openapi_forge::Spec) -> Result<(), IacForgeError> {
416 api.schema(&self.read.schema)
417 .map_err(|_| IacForgeError::SchemaNotFound(self.read.schema.clone()))?;
418
419 if let Some(ref response_schema) = self.read.response_schema {
420 api.schema(response_schema)
421 .map_err(|_| IacForgeError::SchemaNotFound(response_schema.clone()))?;
422 }
423
424 if api.endpoint_by_path(&self.read.endpoint).is_none() {
425 return Err(IacForgeError::MissingEndpoint {
426 resource: self.data_source.name.clone(),
427 endpoint: self.read.endpoint.clone(),
428 });
429 }
430
431 Ok(())
432 }
433}
434
435#[cfg(test)]
436mod tests {
437 use super::*;
438 use std::str::FromStr;
443
444 #[test]
445 fn config_loader_from_toml_resource() {
446 let toml_str = r#"
447[resource]
448name = "test_res"
449description = "Test"
450category = "test"
451
452[crud]
453create_endpoint = "/create"
454create_schema = "Create"
455read_endpoint = "/read"
456read_schema = "Read"
457delete_endpoint = "/delete"
458delete_schema = "Delete"
459
460[identity]
461id_field = "id"
462"#;
463 let spec = ResourceSpec::from_toml(toml_str).expect("from_toml");
464 assert_eq!(spec.resource.name, "test_res");
465 }
466
467 #[test]
468 fn config_loader_from_toml_data_source() {
469 let toml_str = r#"
470[data_source]
471name = "test_ds"
472description = "Test"
473
474[read]
475endpoint = "/read"
476schema = "Read"
477"#;
478 let spec = DataSourceSpec::from_toml(toml_str).expect("from_toml");
479 assert_eq!(spec.data_source.name, "test_ds");
480 }
481
482 #[test]
483 fn config_loader_from_toml_provider() {
484 let toml_str = r#"
485[provider]
486name = "test"
487description = "Test"
488version = "1.0.0"
489"#;
490 let spec = ProviderSpec::from_toml(toml_str).expect("from_toml");
491 assert_eq!(spec.provider.name, "test");
492 }
493
494 #[test]
495 fn config_loader_from_toml_error() {
496 let result = ResourceSpec::from_toml("not valid toml {{{");
497 assert!(result.is_err());
498 }
499
500 #[test]
501 fn parse_resource_spec() {
502 let toml_str = r#"
503[resource]
504name = "akeyless_static_secret"
505description = "Static secret"
506category = "secret"
507
508[crud]
509create_endpoint = "/create-secret"
510create_schema = "createSecret"
511update_endpoint = "/update-secret-val"
512update_schema = "updateSecretVal"
513read_endpoint = "/get-secret-value"
514read_schema = "getSecretValue"
515read_response_schema = "GetSecretValueOutput"
516delete_endpoint = "/delete-item"
517delete_schema = "deleteItem"
518
519[identity]
520id_field = "name"
521import_field = "name"
522force_new_fields = ["name"]
523
524[fields]
525token = { skip = true }
526uid_token = { skip = true }
527json = { skip = true }
528delete_protection = { type_override = "bool" }
529"#;
530 let spec: ResourceSpec = toml::from_str(toml_str).expect("parse");
531 assert_eq!(spec.resource.name, "akeyless_static_secret");
532 assert_eq!(spec.crud.create_endpoint, "/create-secret");
533 assert!(spec.fields.get("token").unwrap().skip);
534 assert_eq!(spec.identity.force_new_fields, vec!["name"]);
535 assert!(!spec.is_action());
536 assert!(spec.action.is_none());
537 }
538
539 #[test]
540 fn parse_action_spec() {
541 let toml_str = r#"
542[resource]
543name = "akeyless_uid_generate_token"
544description = "Generate a Universal Identity authentication token"
545category = "uid"
546kind = "action"
547
548[action]
549endpoint = "/uid-generate-token"
550schema = "uidGenerateToken"
551response_schema = "uidGenerateTokenOutput"
552mutating = true
553sensitive_response_fields = ["token"]
554
555[fields]
556"auth-method-name" = { required = true }
557"#;
558 let spec: ResourceSpec = toml::from_str(toml_str).expect("parse");
559 assert!(spec.is_action());
560 let action = spec.action.expect("action table");
561 assert_eq!(action.endpoint, "/uid-generate-token");
562 assert_eq!(action.schema, "uidGenerateToken");
563 assert_eq!(
564 action.response_schema.as_deref(),
565 Some("uidGenerateTokenOutput")
566 );
567 assert_eq!(action.mutating, Some(true));
568 assert_eq!(action.sensitive_response_fields, vec!["token".to_string()]);
569 }
570
571 #[test]
572 fn parse_action_spec_minimal() {
573 let toml_str = r#"
574[resource]
575name = "akeyless_uid_list_children"
576description = "List UID children"
577category = "uid"
578kind = "action"
579
580[action]
581endpoint = "/uid-list-children"
582schema = "uidListChildren"
583"#;
584 let spec: ResourceSpec = toml::from_str(toml_str).expect("parse");
585 assert!(spec.is_action());
586 let action = spec.action.expect("action table");
587 assert_eq!(action.endpoint, "/uid-list-children");
588 assert!(action.response_schema.is_none());
589 assert!(action.mutating.is_none());
590 assert!(action.sensitive_response_fields.is_empty());
591 }
592
593 #[test]
594 fn parse_data_source_spec() {
595 let toml_str = r#"
596[data_source]
597name = "akeyless_auth_method"
598description = "Read an auth method"
599
600[read]
601endpoint = "/get-auth-method"
602schema = "GetAuthMethod"
603response_schema = "AuthMethod"
604
605[fields]
606token = { skip = true }
607
608[read_mapping]
609"auth_method_access_id" = "access_id"
610"#;
611 let spec: DataSourceSpec = toml::from_str(toml_str).expect("parse");
612 assert_eq!(spec.data_source.name, "akeyless_auth_method");
613 assert_eq!(spec.read.endpoint, "/get-auth-method");
614 assert_eq!(spec.read.response_schema, Some("AuthMethod".to_string()));
615 }
616
617 #[test]
618 fn parse_provider_spec() {
619 let toml_str = r#"
620[provider]
621name = "akeyless"
622description = "Akeyless Vault Provider"
623version = "1.0.0"
624sdk_import = "github.com/akeylesslabs/akeyless-go/v5"
625
626[auth]
627token_field = "token"
628env_var = "AKEYLESS_ACCESS_TOKEN"
629gateway_url_field = "api_gateway_address"
630gateway_env_var = "AKEYLESS_GATEWAY"
631
632[defaults]
633skip_fields = ["token", "uid-token", "json"]
634"#;
635 let spec: ProviderSpec = toml::from_str(toml_str).expect("parse");
636 assert_eq!(spec.provider.name, "akeyless");
637 assert_eq!(spec.auth.token_field, "token");
638 assert_eq!(spec.defaults.skip_fields.len(), 3);
639 }
640
641 #[test]
642 fn parse_provider_with_platforms() {
643 let toml_str = r#"
644[provider]
645name = "akeyless"
646description = "Akeyless Vault Provider"
647version = "1.0.0"
648sdk_import = "github.com/akeylesslabs/akeyless-go/v5"
649
650[auth]
651token_field = "token"
652env_var = "AKEYLESS_ACCESS_TOKEN"
653
654[defaults]
655skip_fields = ["token"]
656
657[platforms.terraform]
658sdk_import = "github.com/akeylesslabs/akeyless-go/v5"
659
660[platforms.pulumi]
661module = "index"
662
663[platforms.crossplane]
664group = "akeyless.crossplane.io"
665api_version = "v1alpha1"
666"#;
667 let spec: ProviderSpec = toml::from_str(toml_str).expect("parse");
668 assert!(spec.platforms.contains_key("terraform"));
669 assert!(spec.platforms.contains_key("pulumi"));
670 assert!(spec.platforms.contains_key("crossplane"));
671 }
672
673 #[test]
674 fn config_loader_from_toml_invalid_resource() {
675 let result = ResourceSpec::from_toml("not valid toml {{{");
676 assert!(result.is_err());
677 }
678
679 #[test]
680 fn config_loader_from_toml_invalid_data_source() {
681 let result = DataSourceSpec::from_toml("this is not valid");
682 assert!(result.is_err());
683 }
684
685 #[test]
686 fn config_loader_from_toml_invalid_provider() {
687 let result = ProviderSpec::from_toml("[broken");
688 assert!(result.is_err());
689 }
690
691 #[test]
692 fn config_loader_from_toml_missing_resource_fails() {
693 let result = ResourceSpec::from_toml(
696 r#"
697[crud]
698create_endpoint = "/x"
699create_schema = "X"
700read_endpoint = "/x"
701read_schema = "X"
702delete_endpoint = "/x"
703delete_schema = "X"
704
705[identity]
706id_field = "id"
707"#,
708 );
709 assert!(result.is_err(), "missing [resource] section should fail");
710 }
711
712 #[test]
713 fn crud_spec_without_crud_table_parses_but_resolves_emptily() {
714 let spec = ResourceSpec::from_toml(
718 r#"
719[resource]
720name = "test"
721"#,
722 )
723 .expect("parse");
724 assert!(spec.crud.create_endpoint.is_empty());
725 assert!(spec.identity.id_field.is_empty());
726 assert!(!spec.is_action());
727 }
728
729 #[test]
730 fn config_loader_load_nonexistent_file() {
731 let result = ResourceSpec::load(std::path::Path::new("/nonexistent/path/resource.toml"));
732 assert!(result.is_err());
733 }
734
735 #[test]
736 fn config_loader_load_provider_nonexistent_file() {
737 let result = ProviderSpec::load(std::path::Path::new("/nonexistent/path/provider.toml"));
738 assert!(result.is_err());
739 }
740
741 #[test]
742 fn config_loader_load_data_source_nonexistent_file() {
743 let result =
744 DataSourceSpec::load(std::path::Path::new("/nonexistent/path/data_source.toml"));
745 assert!(result.is_err());
746 }
747
748 #[test]
749 fn resource_spec_validate_all_schemas_present() {
750 let toml_str = r#"
751[resource]
752name = "valid_res"
753description = "Valid resource"
754category = "test"
755
756[crud]
757create_endpoint = "/create"
758create_schema = "CreateSchema"
759read_endpoint = "/read"
760read_schema = "ReadSchema"
761delete_endpoint = "/delete"
762delete_schema = "DeleteSchema"
763
764[identity]
765id_field = "id"
766"#;
767 let spec = ResourceSpec::from_toml(toml_str).expect("parse");
768
769 let api_str = r#"
770openapi: "3.0.0"
771info: { title: Test, version: "1.0" }
772paths:
773 /create:
774 post:
775 operationId: create
776 responses:
777 "200": { description: ok }
778components:
779 schemas:
780 CreateSchema:
781 type: object
782 properties:
783 id: { type: string }
784 ReadSchema:
785 type: object
786 properties:
787 id: { type: string }
788 DeleteSchema:
789 type: object
790 properties:
791 id: { type: string }
792"#;
793 let api = openapi_forge::Spec::from_str(api_str).expect("parse");
794 assert!(spec.validate(&api).is_ok());
795 }
796
797 #[test]
798 fn resource_spec_validate_missing_create_schema() {
799 let toml_str = r#"
800[resource]
801name = "bad_res"
802description = "Bad resource"
803category = "test"
804
805[crud]
806create_endpoint = "/create"
807create_schema = "MissingCreate"
808read_endpoint = "/read"
809read_schema = "ReadSchema"
810delete_endpoint = "/delete"
811delete_schema = "DeleteSchema"
812
813[identity]
814id_field = "id"
815"#;
816 let spec = ResourceSpec::from_toml(toml_str).expect("parse");
817
818 let api_str = r#"
819openapi: "3.0.0"
820info: { title: Test, version: "1.0" }
821paths:
822 /create:
823 post:
824 operationId: create
825 responses:
826 "200": { description: ok }
827components:
828 schemas:
829 ReadSchema:
830 type: object
831 properties: {}
832 DeleteSchema:
833 type: object
834 properties: {}
835"#;
836 let api = openapi_forge::Spec::from_str(api_str).expect("parse");
837 let result = spec.validate(&api);
838 assert!(result.is_err());
839 let err = result.unwrap_err().to_string();
840 assert!(err.contains("MissingCreate"));
841 }
842
843 #[test]
844 fn resource_spec_validate_missing_update_schema() {
845 let toml_str = r#"
846[resource]
847name = "bad_update"
848description = "Missing update schema"
849category = "test"
850
851[crud]
852create_endpoint = "/create"
853create_schema = "CreateSchema"
854update_endpoint = "/update"
855update_schema = "MissingUpdate"
856read_endpoint = "/read"
857read_schema = "ReadSchema"
858delete_endpoint = "/delete"
859delete_schema = "DeleteSchema"
860
861[identity]
862id_field = "id"
863"#;
864 let spec = ResourceSpec::from_toml(toml_str).expect("parse");
865
866 let api_str = r#"
867openapi: "3.0.0"
868info: { title: Test, version: "1.0" }
869paths:
870 /create:
871 post:
872 operationId: create
873 responses:
874 "200": { description: ok }
875components:
876 schemas:
877 CreateSchema:
878 type: object
879 properties: {}
880 ReadSchema:
881 type: object
882 properties: {}
883 DeleteSchema:
884 type: object
885 properties: {}
886"#;
887 let api = openapi_forge::Spec::from_str(api_str).expect("parse");
888 let result = spec.validate(&api);
889 assert!(result.is_err());
890 let err = result.unwrap_err().to_string();
891 assert!(err.contains("MissingUpdate"));
892 }
893
894 #[test]
895 fn resource_spec_validate_missing_endpoint() {
896 let toml_str = r#"
897[resource]
898name = "no_endpoint"
899description = "Missing endpoint"
900category = "test"
901
902[crud]
903create_endpoint = "/nonexistent-create"
904create_schema = "CreateSchema"
905read_endpoint = "/read"
906read_schema = "ReadSchema"
907delete_endpoint = "/delete"
908delete_schema = "DeleteSchema"
909
910[identity]
911id_field = "id"
912"#;
913 let spec = ResourceSpec::from_toml(toml_str).expect("parse");
914
915 let api_str = r#"
916openapi: "3.0.0"
917info: { title: Test, version: "1.0" }
918paths:
919 /existing:
920 post:
921 operationId: existing
922 responses:
923 "200": { description: ok }
924components:
925 schemas:
926 CreateSchema:
927 type: object
928 properties: {}
929 ReadSchema:
930 type: object
931 properties: {}
932 DeleteSchema:
933 type: object
934 properties: {}
935"#;
936 let api = openapi_forge::Spec::from_str(api_str).expect("parse");
937 let result = spec.validate(&api);
938 assert!(result.is_err());
939 let err = result.unwrap_err().to_string();
940 assert!(err.contains("no_endpoint"));
941 assert!(err.contains("/nonexistent-create"));
942 }
943
944 #[test]
945 fn resource_spec_validate_with_no_update_schema() {
946 let toml_str = r#"
948[resource]
949name = "no_update"
950description = "No update schema"
951category = "test"
952
953[crud]
954create_endpoint = "/create"
955create_schema = "CreateSchema"
956read_endpoint = "/read"
957read_schema = "ReadSchema"
958delete_endpoint = "/delete"
959delete_schema = "DeleteSchema"
960
961[identity]
962id_field = "id"
963"#;
964 let spec = ResourceSpec::from_toml(toml_str).expect("parse");
965
966 let api_str = r#"
967openapi: "3.0.0"
968info: { title: Test, version: "1.0" }
969paths:
970 /create:
971 post:
972 operationId: create
973 responses:
974 "200": { description: ok }
975components:
976 schemas:
977 CreateSchema:
978 type: object
979 properties: {}
980 ReadSchema:
981 type: object
982 properties: {}
983 DeleteSchema:
984 type: object
985 properties: {}
986"#;
987 let api = openapi_forge::Spec::from_str(api_str).expect("parse");
988 assert!(spec.validate(&api).is_ok());
989 }
990
991 #[test]
992 fn resource_spec_validate_with_read_response_schema() {
993 let toml_str = r#"
994[resource]
995name = "with_resp"
996description = "With response schema"
997category = "test"
998
999[crud]
1000create_endpoint = "/create"
1001create_schema = "CreateSchema"
1002read_endpoint = "/read"
1003read_schema = "ReadSchema"
1004read_response_schema = "ReadResponse"
1005delete_endpoint = "/delete"
1006delete_schema = "DeleteSchema"
1007
1008[identity]
1009id_field = "id"
1010"#;
1011 let spec = ResourceSpec::from_toml(toml_str).expect("parse");
1012
1013 let api_str = r#"
1014openapi: "3.0.0"
1015info: { title: Test, version: "1.0" }
1016paths:
1017 /create:
1018 post:
1019 operationId: create
1020 responses:
1021 "200": { description: ok }
1022components:
1023 schemas:
1024 CreateSchema:
1025 type: object
1026 properties: {}
1027 ReadSchema:
1028 type: object
1029 properties: {}
1030 ReadResponse:
1031 type: object
1032 properties: {}
1033 DeleteSchema:
1034 type: object
1035 properties: {}
1036"#;
1037 let api = openapi_forge::Spec::from_str(api_str).expect("parse");
1038 assert!(spec.validate(&api).is_ok());
1039 }
1040
1041 #[test]
1042 fn resource_spec_validate_missing_read_response_schema() {
1043 let toml_str = r#"
1044[resource]
1045name = "bad_resp"
1046description = "Missing response schema"
1047category = "test"
1048
1049[crud]
1050create_endpoint = "/create"
1051create_schema = "CreateSchema"
1052read_endpoint = "/read"
1053read_schema = "ReadSchema"
1054read_response_schema = "MissingResponse"
1055delete_endpoint = "/delete"
1056delete_schema = "DeleteSchema"
1057
1058[identity]
1059id_field = "id"
1060"#;
1061 let spec = ResourceSpec::from_toml(toml_str).expect("parse");
1062
1063 let api_str = r#"
1064openapi: "3.0.0"
1065info: { title: Test, version: "1.0" }
1066paths:
1067 /create:
1068 post:
1069 operationId: create
1070 responses:
1071 "200": { description: ok }
1072components:
1073 schemas:
1074 CreateSchema:
1075 type: object
1076 properties: {}
1077 ReadSchema:
1078 type: object
1079 properties: {}
1080 DeleteSchema:
1081 type: object
1082 properties: {}
1083"#;
1084 let api = openapi_forge::Spec::from_str(api_str).expect("parse");
1085 let result = spec.validate(&api);
1086 assert!(result.is_err());
1087 let err = result.unwrap_err().to_string();
1088 assert!(err.contains("MissingResponse"));
1089 }
1090
1091 #[test]
1092 fn data_source_spec_validate_all_present() {
1093 let toml_str = r#"
1094[data_source]
1095name = "valid_ds"
1096description = "Valid data source"
1097
1098[read]
1099endpoint = "/read-ds"
1100schema = "ReadDsSchema"
1101"#;
1102 let spec = DataSourceSpec::from_toml(toml_str).expect("parse");
1103
1104 let api_str = r#"
1105openapi: "3.0.0"
1106info: { title: Test, version: "1.0" }
1107paths:
1108 /read-ds:
1109 post:
1110 operationId: readDs
1111 responses:
1112 "200": { description: ok }
1113components:
1114 schemas:
1115 ReadDsSchema:
1116 type: object
1117 properties:
1118 id: { type: string }
1119"#;
1120 let api = openapi_forge::Spec::from_str(api_str).expect("parse");
1121 assert!(spec.validate(&api).is_ok());
1122 }
1123
1124 #[test]
1125 fn data_source_spec_validate_missing_schema() {
1126 let toml_str = r#"
1127[data_source]
1128name = "bad_ds"
1129description = "Bad data source"
1130
1131[read]
1132endpoint = "/read-ds"
1133schema = "MissingSchema"
1134"#;
1135 let spec = DataSourceSpec::from_toml(toml_str).expect("parse");
1136
1137 let api_str = r#"
1138openapi: "3.0.0"
1139info: { title: Test, version: "1.0" }
1140paths:
1141 /read-ds:
1142 post:
1143 operationId: readDs
1144 responses:
1145 "200": { description: ok }
1146components:
1147 schemas: {}
1148"#;
1149 let api = openapi_forge::Spec::from_str(api_str).expect("parse");
1150 let result = spec.validate(&api);
1151 assert!(result.is_err());
1152 }
1153
1154 #[test]
1155 fn data_source_spec_validate_missing_endpoint() {
1156 let toml_str = r#"
1157[data_source]
1158name = "no_ep_ds"
1159description = "No endpoint"
1160
1161[read]
1162endpoint = "/nonexistent"
1163schema = "DsSchema"
1164"#;
1165 let spec = DataSourceSpec::from_toml(toml_str).expect("parse");
1166
1167 let api_str = r#"
1168openapi: "3.0.0"
1169info: { title: Test, version: "1.0" }
1170paths:
1171 /other:
1172 post:
1173 operationId: other
1174 responses:
1175 "200": { description: ok }
1176components:
1177 schemas:
1178 DsSchema:
1179 type: object
1180 properties: {}
1181"#;
1182 let api = openapi_forge::Spec::from_str(api_str).expect("parse");
1183 let result = spec.validate(&api);
1184 assert!(result.is_err());
1185 let err = result.unwrap_err().to_string();
1186 assert!(err.contains("no_ep_ds"));
1187 assert!(err.contains("/nonexistent"));
1188 }
1189
1190 #[test]
1191 fn data_source_spec_validate_missing_response_schema() {
1192 let toml_str = r#"
1193[data_source]
1194name = "resp_ds"
1195description = "Missing response schema"
1196
1197[read]
1198endpoint = "/read-ds"
1199schema = "DsSchema"
1200response_schema = "MissingResp"
1201"#;
1202 let spec = DataSourceSpec::from_toml(toml_str).expect("parse");
1203
1204 let api_str = r#"
1205openapi: "3.0.0"
1206info: { title: Test, version: "1.0" }
1207paths:
1208 /read-ds:
1209 post:
1210 operationId: readDs
1211 responses:
1212 "200": { description: ok }
1213components:
1214 schemas:
1215 DsSchema:
1216 type: object
1217 properties: {}
1218"#;
1219 let api = openapi_forge::Spec::from_str(api_str).expect("parse");
1220 let result = spec.validate(&api);
1221 assert!(result.is_err());
1222 }
1223
1224 #[test]
1225 fn resource_spec_with_all_field_overrides() {
1226 let toml_str = r#"
1227[resource]
1228name = "full_overrides"
1229description = "All field overrides"
1230category = "test"
1231
1232[crud]
1233create_endpoint = "/create"
1234create_schema = "Create"
1235read_endpoint = "/read"
1236read_schema = "Read"
1237delete_endpoint = "/delete"
1238delete_schema = "Delete"
1239
1240[identity]
1241id_field = "id"
1242
1243[fields]
1244skip_me = { skip = true }
1245computed_field = { computed = true }
1246sensitive_field = { sensitive = true }
1247typed_field = { type_override = "bool" }
1248described_field = { description = "Custom desc" }
1249force_new_field = { force_new = true }
1250"#;
1251 let spec = ResourceSpec::from_toml(toml_str).expect("parse");
1252 assert!(spec.fields.get("skip_me").unwrap().skip);
1253 assert!(spec.fields.get("computed_field").unwrap().computed);
1254 assert!(spec.fields.get("sensitive_field").unwrap().sensitive);
1255 assert_eq!(
1256 spec.fields.get("typed_field").unwrap().type_override,
1257 Some("bool".to_string())
1258 );
1259 assert_eq!(
1260 spec.fields.get("described_field").unwrap().description,
1261 Some("Custom desc".to_string())
1262 );
1263 assert!(spec.fields.get("force_new_field").unwrap().force_new);
1264 }
1265
1266 #[test]
1267 fn field_override_defaults() {
1268 let fo = FieldOverride::default();
1269 assert!(!fo.computed);
1270 assert!(!fo.sensitive);
1271 assert!(!fo.skip);
1272 assert!(fo.type_override.is_none());
1273 assert!(fo.description.is_none());
1274 assert!(!fo.force_new);
1275 }
1276
1277 #[test]
1278 fn provider_defaults_default() {
1279 let pd = ProviderDefaults::default();
1280 assert!(pd.skip_fields.is_empty());
1281 }
1282
1283 #[test]
1284 fn auth_config_default() {
1285 let ac = AuthConfig::default();
1286 assert!(ac.token_field.is_empty());
1287 assert!(ac.env_var.is_empty());
1288 assert!(ac.gateway_url_field.is_empty());
1289 assert!(ac.gateway_env_var.is_empty());
1290 }
1291
1292 #[test]
1293 fn resource_spec_empty_read_mapping() {
1294 let toml_str = r#"
1295[resource]
1296name = "no_mapping"
1297description = "No read mapping"
1298category = "test"
1299
1300[crud]
1301create_endpoint = "/create"
1302create_schema = "Create"
1303read_endpoint = "/read"
1304read_schema = "Read"
1305delete_endpoint = "/delete"
1306delete_schema = "Delete"
1307
1308[identity]
1309id_field = "id"
1310"#;
1311 let spec = ResourceSpec::from_toml(toml_str).expect("parse");
1312 assert!(spec.read_mapping.is_empty());
1313 }
1314
1315 #[test]
1316 fn data_source_spec_empty_fields_and_mapping() {
1317 let toml_str = r#"
1318[data_source]
1319name = "minimal"
1320description = "Minimal"
1321
1322[read]
1323endpoint = "/read"
1324schema = "Read"
1325"#;
1326 let spec = DataSourceSpec::from_toml(toml_str).expect("parse");
1327 assert!(spec.fields.is_empty());
1328 assert!(spec.read_mapping.is_empty());
1329 }
1330
1331 #[test]
1332 fn provider_spec_minimal() {
1333 let toml_str = r#"
1335[provider]
1336name = "minimal"
1337"#;
1338 let spec = ProviderSpec::from_toml(toml_str).expect("parse");
1339 assert_eq!(spec.provider.name, "minimal");
1340 assert!(spec.provider.description.is_empty());
1341 assert!(spec.provider.version.is_empty());
1342 assert!(spec.provider.sdk_import.is_empty());
1343 assert!(spec.auth.token_field.is_empty());
1344 assert!(spec.defaults.skip_fields.is_empty());
1345 assert!(spec.platforms.is_empty());
1346 }
1347
1348 #[test]
1349 fn resource_spec_validate_missing_read_schema() {
1350 let toml_str = r#"
1351[resource]
1352name = "bad_read"
1353description = "Missing read schema"
1354category = "test"
1355
1356[crud]
1357create_endpoint = "/create"
1358create_schema = "CreateSchema"
1359read_endpoint = "/read"
1360read_schema = "MissingReadSchema"
1361delete_endpoint = "/delete"
1362delete_schema = "DeleteSchema"
1363
1364[identity]
1365id_field = "id"
1366"#;
1367 let spec = ResourceSpec::from_toml(toml_str).expect("parse");
1368 let api_str = r#"
1369openapi: "3.0.0"
1370info: { title: Test, version: "1.0" }
1371paths:
1372 /create:
1373 post:
1374 operationId: create
1375 responses:
1376 "200": { description: ok }
1377components:
1378 schemas:
1379 CreateSchema:
1380 type: object
1381 properties: {}
1382 DeleteSchema:
1383 type: object
1384 properties: {}
1385"#;
1386 let api = openapi_forge::Spec::from_str(api_str).expect("parse");
1387 let result = spec.validate(&api);
1388 assert!(result.is_err());
1389 let err = result.unwrap_err().to_string();
1390 assert!(err.contains("MissingReadSchema"), "got: {err}");
1391 }
1392
1393 #[test]
1394 fn resource_spec_validate_missing_delete_schema() {
1395 let toml_str = r#"
1396[resource]
1397name = "bad_delete"
1398description = "Missing delete schema"
1399category = "test"
1400
1401[crud]
1402create_endpoint = "/create"
1403create_schema = "CreateSchema"
1404read_endpoint = "/read"
1405read_schema = "ReadSchema"
1406delete_endpoint = "/delete"
1407delete_schema = "MissingDeleteSchema"
1408
1409[identity]
1410id_field = "id"
1411"#;
1412 let spec = ResourceSpec::from_toml(toml_str).expect("parse");
1413 let api_str = r#"
1414openapi: "3.0.0"
1415info: { title: Test, version: "1.0" }
1416paths:
1417 /create:
1418 post:
1419 operationId: create
1420 responses:
1421 "200": { description: ok }
1422components:
1423 schemas:
1424 CreateSchema:
1425 type: object
1426 properties: {}
1427 ReadSchema:
1428 type: object
1429 properties: {}
1430"#;
1431 let api = openapi_forge::Spec::from_str(api_str).expect("parse");
1432 let result = spec.validate(&api);
1433 assert!(result.is_err());
1434 let err = result.unwrap_err().to_string();
1435 assert!(err.contains("MissingDeleteSchema"), "got: {err}");
1436 }
1437
1438 #[test]
1439 fn config_loader_load_valid_file() {
1440 let dir = tempfile::tempdir().expect("tmpdir");
1441 let path = dir.path().join("resource.toml");
1442 std::fs::write(
1443 &path,
1444 r#"
1445[resource]
1446name = "from_file"
1447description = "Loaded from file"
1448category = "test"
1449
1450[crud]
1451create_endpoint = "/create"
1452create_schema = "Create"
1453read_endpoint = "/read"
1454read_schema = "Read"
1455delete_endpoint = "/delete"
1456delete_schema = "Delete"
1457
1458[identity]
1459id_field = "id"
1460"#,
1461 )
1462 .expect("write");
1463 let spec = ResourceSpec::load(&path).expect("load");
1464 assert_eq!(spec.resource.name, "from_file");
1465 }
1466
1467 #[test]
1468 fn config_loader_load_valid_provider_file() {
1469 let dir = tempfile::tempdir().expect("tmpdir");
1470 let path = dir.path().join("provider.toml");
1471 std::fs::write(
1472 &path,
1473 r#"
1474[provider]
1475name = "file_provider"
1476description = "From file"
1477version = "2.0.0"
1478"#,
1479 )
1480 .expect("write");
1481 let spec = ProviderSpec::load(&path).expect("load");
1482 assert_eq!(spec.provider.name, "file_provider");
1483 assert_eq!(spec.provider.version, "2.0.0");
1484 }
1485
1486 #[test]
1487 fn config_loader_load_valid_data_source_file() {
1488 let dir = tempfile::tempdir().expect("tmpdir");
1489 let path = dir.path().join("ds.toml");
1490 std::fs::write(
1491 &path,
1492 r#"
1493[data_source]
1494name = "file_ds"
1495description = "From file"
1496
1497[read]
1498endpoint = "/read"
1499schema = "ReadDs"
1500"#,
1501 )
1502 .expect("write");
1503 let spec = DataSourceSpec::load(&path).expect("load");
1504 assert_eq!(spec.data_source.name, "file_ds");
1505 }
1506
1507 #[test]
1508 fn config_loader_load_invalid_toml_content() {
1509 let dir = tempfile::tempdir().expect("tmpdir");
1510 let path = dir.path().join("bad.toml");
1511 std::fs::write(&path, "this is not valid toml {{{").expect("write");
1512 let result = ResourceSpec::load(&path);
1513 assert!(result.is_err());
1514 }
1515
1516 #[test]
1517 fn resource_spec_serde_roundtrip() {
1518 let toml_str = r#"
1519[resource]
1520name = "roundtrip_res"
1521description = "Roundtrip test"
1522category = "test"
1523
1524[crud]
1525create_endpoint = "/create"
1526create_schema = "Create"
1527update_endpoint = "/update"
1528update_schema = "Update"
1529read_endpoint = "/read"
1530read_schema = "Read"
1531read_response_schema = "ReadResp"
1532delete_endpoint = "/delete"
1533delete_schema = "Delete"
1534
1535[identity]
1536id_field = "name"
1537import_field = "path"
1538force_new_fields = ["name"]
1539
1540[fields]
1541secret = { sensitive = true, computed = true }
1542
1543[read_mapping]
1544"resp_key" = "name"
1545"#;
1546 let spec = ResourceSpec::from_toml(toml_str).expect("parse");
1547 let json = serde_json::to_string(&spec).expect("serialize");
1548 let rt: ResourceSpec = serde_json::from_str(&json).expect("deserialize");
1549 assert_eq!(rt.resource.name, "roundtrip_res");
1550 assert_eq!(rt.crud.update_endpoint, Some("/update".to_string()));
1551 assert_eq!(rt.identity.import_field, Some("path".to_string()));
1552 assert!(rt.fields.get("secret").unwrap().sensitive);
1553 assert!(rt.fields.get("secret").unwrap().computed);
1554 assert_eq!(rt.read_mapping.get("resp_key"), Some(&"name".to_string()));
1555 }
1556
1557 #[test]
1558 fn data_source_spec_serde_roundtrip() {
1559 let toml_str = r#"
1560[data_source]
1561name = "roundtrip_ds"
1562description = "Roundtrip test"
1563
1564[read]
1565endpoint = "/read"
1566schema = "ReadSchema"
1567response_schema = "ReadResp"
1568
1569[fields]
1570token = { skip = true }
1571
1572[read_mapping]
1573"resp_val" = "value"
1574"#;
1575 let spec = DataSourceSpec::from_toml(toml_str).expect("parse");
1576 let json = serde_json::to_string(&spec).expect("serialize");
1577 let rt: DataSourceSpec = serde_json::from_str(&json).expect("deserialize");
1578 assert_eq!(rt.data_source.name, "roundtrip_ds");
1579 assert_eq!(rt.read.response_schema, Some("ReadResp".to_string()));
1580 assert!(rt.fields.get("token").unwrap().skip);
1581 }
1582
1583 #[test]
1584 fn data_source_spec_validate_with_response_schema_present() {
1585 let toml_str = r#"
1586[data_source]
1587name = "with_resp"
1588description = "Has response schema"
1589
1590[read]
1591endpoint = "/read-ds"
1592schema = "DsSchema"
1593response_schema = "DsResponse"
1594"#;
1595 let spec = DataSourceSpec::from_toml(toml_str).expect("parse");
1596 let api_str = r#"
1597openapi: "3.0.0"
1598info: { title: Test, version: "1.0" }
1599paths:
1600 /read-ds:
1601 post:
1602 operationId: readDs
1603 responses:
1604 "200": { description: ok }
1605components:
1606 schemas:
1607 DsSchema:
1608 type: object
1609 properties: {}
1610 DsResponse:
1611 type: object
1612 properties: {}
1613"#;
1614 let api = openapi_forge::Spec::from_str(api_str).expect("parse");
1615 assert!(spec.validate(&api).is_ok());
1616 }
1617
1618 #[test]
1619 fn resource_spec_with_multiple_read_mappings() {
1620 let toml_str = r#"
1621[resource]
1622name = "multi_mapping"
1623description = "Multiple mappings"
1624category = "test"
1625
1626[crud]
1627create_endpoint = "/create"
1628create_schema = "Create"
1629read_endpoint = "/read"
1630read_schema = "Read"
1631delete_endpoint = "/delete"
1632delete_schema = "Delete"
1633
1634[identity]
1635id_field = "id"
1636
1637[read_mapping]
1638"response_name" = "name"
1639"response_value" = "value"
1640"response_count" = "count"
1641"#;
1642 let spec = ResourceSpec::from_toml(toml_str).expect("parse");
1643 assert_eq!(spec.read_mapping.len(), 3);
1644 assert_eq!(
1645 spec.read_mapping.get("response_name"),
1646 Some(&"name".to_string())
1647 );
1648 assert_eq!(
1649 spec.read_mapping.get("response_value"),
1650 Some(&"value".to_string())
1651 );
1652 assert_eq!(
1653 spec.read_mapping.get("response_count"),
1654 Some(&"count".to_string())
1655 );
1656 }
1657
1658 #[test]
1659 fn provider_spec_serde_roundtrip() {
1660 let toml_str = r#"
1661[provider]
1662name = "roundtrip"
1663description = "Roundtrip test"
1664version = "2.0.0"
1665sdk_import = "github.com/example/sdk"
1666
1667[auth]
1668token_field = "token"
1669env_var = "TOKEN"
1670gateway_url_field = "gw"
1671gateway_env_var = "GW"
1672
1673[defaults]
1674skip_fields = ["token", "uid-token"]
1675
1676[platforms.terraform]
1677sdk_import = "github.com/example/tf-sdk"
1678"#;
1679 let spec = ProviderSpec::from_toml(toml_str).expect("parse");
1680 let json = serde_json::to_string(&spec).expect("serialize");
1681 let rt: ProviderSpec = serde_json::from_str(&json).expect("deserialize");
1682 assert_eq!(rt.provider.name, "roundtrip");
1683 assert_eq!(rt.provider.version, "2.0.0");
1684 assert_eq!(rt.auth.token_field, "token");
1685 assert_eq!(rt.auth.gateway_url_field, "gw");
1686 assert_eq!(rt.defaults.skip_fields.len(), 2);
1687 assert!(rt.platforms.contains_key("terraform"));
1688 }
1689
1690 #[test]
1691 fn field_override_serde_roundtrip() {
1692 let fo = FieldOverride {
1693 computed: true,
1694 sensitive: true,
1695 skip: false,
1696 type_override: Some("bool".to_string()),
1697 description: Some("custom".to_string()),
1698 force_new: true,
1699 };
1700 let json = serde_json::to_string(&fo).expect("serialize");
1701 let rt: FieldOverride = serde_json::from_str(&json).expect("deserialize");
1702 assert!(rt.computed);
1703 assert!(rt.sensitive);
1704 assert!(!rt.skip);
1705 assert_eq!(rt.type_override, Some("bool".to_string()));
1706 assert_eq!(rt.description, Some("custom".to_string()));
1707 assert!(rt.force_new);
1708 }
1709
1710 #[test]
1711 fn identity_config_serde_roundtrip() {
1712 let ic = IdentityConfig {
1713 id_field: "name".to_string(),
1714 import_field: Some("path".to_string()),
1715 force_new_fields: vec!["name".to_string()],
1716 };
1717 let json = serde_json::to_string(&ic).expect("serialize");
1718 let rt: IdentityConfig = serde_json::from_str(&json).expect("deserialize");
1719 assert_eq!(rt.id_field, "name");
1720 assert_eq!(rt.import_field, Some("path".to_string()));
1721 assert_eq!(rt.force_new_fields, vec!["name"]);
1722 }
1723
1724 #[test]
1725 fn crud_mapping_serde_roundtrip() {
1726 let cm = CrudMapping {
1727 create_endpoint: "/create".to_string(),
1728 create_schema: "Create".to_string(),
1729 update_endpoint: Some("/update".to_string()),
1730 update_schema: Some("Update".to_string()),
1731 read_endpoint: "/read".to_string(),
1732 read_schema: "Read".to_string(),
1733 read_response_schema: Some("ReadResp".to_string()),
1734 delete_endpoint: "/delete".to_string(),
1735 delete_schema: "Delete".to_string(),
1736 };
1737 let json = serde_json::to_string(&cm).expect("serialize");
1738 let rt: CrudMapping = serde_json::from_str(&json).expect("deserialize");
1739 assert_eq!(rt.create_endpoint, "/create");
1740 assert_eq!(rt.update_endpoint, Some("/update".to_string()));
1741 assert_eq!(rt.read_response_schema, Some("ReadResp".to_string()));
1742 }
1743
1744 #[test]
1745 fn read_mapping_serde_roundtrip() {
1746 let rm = ReadMapping {
1747 endpoint: "/read".to_string(),
1748 schema: "ReadSchema".to_string(),
1749 response_schema: Some("ReadResp".to_string()),
1750 };
1751 let json = serde_json::to_string(&rm).expect("serialize");
1752 let rt: ReadMapping = serde_json::from_str(&json).expect("deserialize");
1753 assert_eq!(rt.endpoint, "/read");
1754 assert_eq!(rt.schema, "ReadSchema");
1755 assert_eq!(rt.response_schema, Some("ReadResp".to_string()));
1756 }
1757}