Skip to main content

iac_forge/
spec.rs

1use std::collections::BTreeMap;
2use std::path::Path;
3
4use serde::{Deserialize, Serialize};
5
6use crate::error::IacForgeError;
7
8/// Trait for loading specs from TOML.
9///
10/// Eliminates identical `load()` methods across spec types. Provides both
11/// file-based loading and string-based parsing (useful for tests).
12pub trait ConfigLoader: Sized + serde::de::DeserializeOwned {
13    /// Load from a TOML file.
14    ///
15    /// # Errors
16    ///
17    /// Returns an error if the file can't be read or parsed.
18    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    /// Parse from a TOML string (useful for tests).
25    ///
26    /// # Errors
27    ///
28    /// Returns an error if the string can't be parsed.
29    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/// Top-level resource specification loaded from TOML.
40///
41/// Two flavours are supported:
42/// - **CRUD** (default): `kind = "crud"` or absent — requires `[crud]` +
43///   `[identity]` tables. Resolved via [`crate::resolve_resource`].
44/// - **Action** (RPC-style): `kind = "action"` — requires `[action]`
45///   table; MUST NOT have `[crud]`/`[identity]`. Resolved via
46///   [`crate::resolve_action`].
47///
48/// Both `crud` and `identity` default to empty placeholders so an action
49/// TOML can omit them entirely. Use [`ResourceSpec::is_action`] before
50/// dispatching to the CRUD resolver — calling `resolve_resource` on an
51/// action spec will fail loudly.
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct ResourceSpec {
54    /// Resource metadata (name, description, category, kind).
55    pub resource: ResourceMeta,
56    /// CRUD operation mappings.
57    ///
58    /// Defaults to an empty placeholder so `kind = "action"` specs (which
59    /// have no `[crud]` table) parse cleanly. CRUD callers must verify
60    /// via [`ResourceSpec::is_action`] before reading the empty struct.
61    #[serde(default)]
62    pub crud: CrudMapping,
63    /// Action mapping for RPC-style endpoints. Required when `kind = "action"`.
64    #[serde(default)]
65    pub action: Option<ActionMapping>,
66    /// Identity and import configuration.
67    ///
68    /// Defaults to an empty placeholder so action specs parse without an
69    /// `[identity]` table.
70    #[serde(default)]
71    pub identity: IdentityConfig,
72    /// Per-field overrides (skip, computed, sensitive, etc.).
73    #[serde(default)]
74    pub fields: BTreeMap<String, FieldOverride>,
75    /// Mapping from API response JSON paths to field names.
76    #[serde(default)]
77    pub read_mapping: BTreeMap<String, String>,
78}
79
80impl ResourceSpec {
81    /// Whether this spec describes an action (RPC-style) resource.
82    ///
83    /// Action specs MUST have `kind = "action"` AND a populated `[action]`
84    /// table. CRUD specs (kind absent or `"crud"`) return `false`.
85    #[must_use]
86    pub fn is_action(&self) -> bool {
87        matches!(self.resource.kind.as_deref(), Some("action"))
88    }
89}
90
91/// Action (RPC-style) endpoint mapping.
92///
93/// Used for endpoints like `/uid-generate-token`, `/encrypt`, and similar
94/// one-shot calls that don't fit `present/absent` semantics. Encoded under
95/// the `[action]` table in an action TOML.
96#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct ActionMapping {
98    /// API path (e.g. `/uid-generate-token`).
99    pub endpoint: String,
100    /// Request schema (camelCase, OpenAPI form — e.g. `uidGenerateToken`).
101    pub schema: String,
102    /// Optional response schema name. When `None`, generated modules echo
103    /// the raw response dict back to the caller.
104    #[serde(default)]
105    pub response_schema: Option<String>,
106    /// Whether the action mutates server state. Defaults to `true` when
107    /// not specified; list/get-style ops should set `false`.
108    #[serde(default)]
109    pub mutating: Option<bool>,
110    /// Response fields to mask (`***`) before echoing back to Ansible.
111    /// Useful for `token`, `ciphertext`, `private_key`, etc.
112    #[serde(default)]
113    pub sensitive_response_fields: Vec<String>,
114    /// Optional SDK method-name override.
115    ///
116    /// Some endpoints (like `/encrypt-batch`) reuse a request body schema
117    /// (`BatchEncryptionRequestLine`) that doesn't match the SDK method
118    /// derived from the schema name. Set this to the actual SDK method
119    /// (e.g. `"encrypt_batch"`) to bypass the schema-based derivation.
120    #[serde(default)]
121    pub sdk_method: Option<String>,
122}
123
124/// Resource metadata.
125#[derive(Debug, Clone, Serialize, Deserialize)]
126pub struct ResourceMeta {
127    /// Resource identifier (e.g., `akeyless_static_secret`).
128    pub name: String,
129    /// Human-readable description.
130    #[serde(default)]
131    pub description: String,
132    /// Category grouping (e.g., "secret", "auth").
133    #[serde(default)]
134    pub category: String,
135    /// Resource flavour: `"crud"` (default) or `"action"`.
136    ///
137    /// `None` is equivalent to `"crud"` for backward compatibility with
138    /// the 157 pre-existing CRUD TOMLs. Lives on `ResourceMeta` because
139    /// it sits under the `[resource]` TOML table alongside name/category.
140    #[serde(default)]
141    pub kind: Option<String>,
142}
143
144/// Maps CRUD operations to API endpoints and schemas.
145#[derive(Debug, Clone, Default, Serialize, Deserialize)]
146pub struct CrudMapping {
147    /// API path for the create operation.
148    pub create_endpoint: String,
149    /// `OpenAPI` schema name for the create request body.
150    pub create_schema: String,
151    /// API path for the update operation, if separate.
152    #[serde(default)]
153    pub update_endpoint: Option<String>,
154    /// `OpenAPI` schema name for the update request body.
155    #[serde(default)]
156    pub update_schema: Option<String>,
157    /// API path for the read operation.
158    pub read_endpoint: String,
159    /// `OpenAPI` schema name for the read request body.
160    pub read_schema: String,
161    /// `OpenAPI` schema name for the read response, if different.
162    #[serde(default)]
163    pub read_response_schema: Option<String>,
164    /// API path for the delete operation.
165    pub delete_endpoint: String,
166    /// `OpenAPI` schema name for the delete request body.
167    pub delete_schema: String,
168}
169
170/// Identity and import configuration.
171#[derive(Debug, Clone, Default, Serialize, Deserialize)]
172pub struct IdentityConfig {
173    /// Primary identifier field name.
174    pub id_field: String,
175    /// Field used for Terraform import (defaults to `id_field`).
176    #[serde(default)]
177    pub import_field: Option<String>,
178    /// Fields whose changes force resource replacement.
179    #[serde(default)]
180    pub force_new_fields: Vec<String>,
181}
182
183/// Per-field overrides in the resource spec.
184#[derive(Debug, Clone, Default, Serialize, Deserialize)]
185#[allow(clippy::struct_excessive_bools)]
186pub struct FieldOverride {
187    /// Whether this field is server-computed.
188    #[serde(default)]
189    pub computed: bool,
190    /// Whether this field contains sensitive data.
191    #[serde(default)]
192    pub sensitive: bool,
193    /// Whether to exclude this field from the generated IR.
194    #[serde(default)]
195    pub skip: bool,
196    /// Override the inferred type (e.g., `"bool"`, `"int64"`).
197    #[serde(default)]
198    pub type_override: Option<String>,
199    /// Override the field description.
200    #[serde(default)]
201    pub description: Option<String>,
202    /// Whether changing this field forces resource replacement.
203    #[serde(default)]
204    pub force_new: bool,
205}
206
207/// Provider-level configuration.
208#[derive(Debug, Clone, Serialize, Deserialize)]
209pub struct ProviderSpec {
210    /// Provider metadata (name, version, SDK import).
211    pub provider: ProviderMeta,
212    /// Authentication configuration.
213    #[serde(default)]
214    pub auth: AuthConfig,
215    /// Default settings (e.g., fields to skip).
216    #[serde(default)]
217    pub defaults: ProviderDefaults,
218    /// Per-platform configuration blobs (e.g., terraform, pulumi).
219    #[serde(default)]
220    pub platforms: BTreeMap<String, toml::Value>,
221}
222
223/// Provider metadata (name, version, SDK import path).
224#[derive(Debug, Clone, Serialize, Deserialize)]
225pub struct ProviderMeta {
226    /// Provider identifier (e.g., "akeyless").
227    pub name: String,
228    /// Human-readable description.
229    #[serde(default)]
230    pub description: String,
231    /// Semantic version of the provider.
232    #[serde(default)]
233    pub version: String,
234    /// Language-specific SDK import path.
235    #[serde(default)]
236    pub sdk_import: String,
237}
238
239/// Authentication configuration for a provider spec.
240#[derive(Debug, Clone, Default, Serialize, Deserialize)]
241pub struct AuthConfig {
242    /// API field name for the authentication token.
243    #[serde(default)]
244    pub token_field: String,
245    /// Environment variable supplying the token.
246    #[serde(default)]
247    pub env_var: String,
248    /// API field name for the gateway URL.
249    #[serde(default)]
250    pub gateway_url_field: String,
251    /// Environment variable supplying the gateway URL.
252    #[serde(default)]
253    pub gateway_env_var: String,
254}
255
256/// Provider-level default settings.
257#[derive(Debug, Clone, Default, Serialize, Deserialize)]
258pub struct ProviderDefaults {
259    /// Fields to skip across all resources for this provider.
260    #[serde(default)]
261    pub skip_fields: Vec<String>,
262}
263
264impl ResourceSpec {
265    /// Load a resource spec from a TOML file.
266    ///
267    /// Delegates to [`ConfigLoader::load`].
268    ///
269    /// # Errors
270    ///
271    /// Returns an error if the file can't be read or parsed.
272    pub fn load(path: &Path) -> Result<Self, IacForgeError> {
273        <Self as ConfigLoader>::load(path)
274    }
275
276    /// Validate the resource spec against an `OpenAPI` spec.
277    ///
278    /// Dispatches on [`ResourceSpec::is_action`]:
279    /// - CRUD specs validate create/read/delete (+ optional update / read
280    ///   response) schemas and the create endpoint path.
281    /// - Action specs validate the action endpoint + request schema (and
282    ///   the optional response schema).
283    ///
284    /// # Errors
285    ///
286    /// Returns validation errors if schemas are missing or endpoints don't exist.
287    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    /// Load a provider spec from a TOML file.
350    ///
351    /// Delegates to [`ConfigLoader::load`].
352    ///
353    /// # Errors
354    ///
355    /// Returns an error if the file can't be read or parsed.
356    pub fn load(path: &Path) -> Result<Self, IacForgeError> {
357        <Self as ConfigLoader>::load(path)
358    }
359}
360
361/// Top-level data source specification loaded from TOML.
362#[derive(Debug, Clone, Serialize, Deserialize)]
363pub struct DataSourceSpec {
364    /// Data source metadata (name, description).
365    pub data_source: DataSourceMeta,
366    /// Read operation mapping.
367    pub read: ReadMapping,
368    /// Per-field overrides (skip, computed, sensitive, etc.).
369    #[serde(default)]
370    pub fields: BTreeMap<String, FieldOverride>,
371    /// Mapping from API response JSON paths to field names.
372    #[serde(default)]
373    pub read_mapping: BTreeMap<String, String>,
374}
375
376/// Data source metadata.
377#[derive(Debug, Clone, Serialize, Deserialize)]
378pub struct DataSourceMeta {
379    /// Data source identifier.
380    pub name: String,
381    /// Human-readable description.
382    #[serde(default)]
383    pub description: String,
384}
385
386/// Maps a read operation to an API endpoint and schema.
387#[derive(Debug, Clone, Serialize, Deserialize)]
388pub struct ReadMapping {
389    /// API path for the read operation.
390    pub endpoint: String,
391    /// `OpenAPI` schema name for the read request body.
392    pub schema: String,
393    /// `OpenAPI` schema name for the read response, if different.
394    #[serde(default)]
395    pub response_schema: Option<String>,
396}
397
398impl DataSourceSpec {
399    /// Load a data source spec from a TOML file.
400    ///
401    /// Delegates to [`ConfigLoader::load`].
402    ///
403    /// # Errors
404    ///
405    /// Returns an error if the file can't be read or parsed.
406    pub fn load(path: &Path) -> Result<Self, IacForgeError> {
407        <Self as ConfigLoader>::load(path)
408    }
409
410    /// Validate the data source spec against an `OpenAPI` spec.
411    ///
412    /// # Errors
413    ///
414    /// Returns validation errors if schemas are missing or endpoints don't exist.
415    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    // `Spec::from_str` is `impl FromStr for Spec`, not an inherent method, so
439    // the trait must be in scope. This compiled while openapi-forge came from
440    // git and broke when it came from the registry — not an API change, just a
441    // different prelude reach at the call site.
442    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        // ResourceSpec still requires [resource]. [crud] and [identity] now
694        // default to empty placeholders so action specs (no [crud]) parse.
695        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        // After action-style introduction, missing [crud]/[identity] no longer
715        // errors at parse time — the validator + resolver enforce semantic
716        // constraints. This documents the deserialization behaviour.
717        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        // When update_schema is None, validation should pass without checking it
947        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        // Provider with only name, everything else defaults
1334        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}