Skip to main content

drasi_source_dataverse/
descriptor.rs

1// Copyright 2026 The Drasi Authors.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Plugin descriptor for the Dataverse source.
16
17use crate::DataverseSourceConfig;
18use drasi_plugin_sdk::prelude::*;
19use std::collections::HashMap;
20use utoipa::OpenApi;
21
22// ── DTO types ────────────────────────────────────────────────────────────────
23
24fn default_min_interval_ms() -> ConfigValue<u64> {
25    ConfigValue::Static(500)
26}
27
28fn default_max_interval_seconds() -> ConfigValue<u64> {
29    ConfigValue::Static(30)
30}
31
32fn default_api_version() -> ConfigValue<String> {
33    ConfigValue::Static("v9.2".to_string())
34}
35
36/// Configuration DTO for the Dataverse source plugin.
37#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, utoipa::ToSchema)]
38#[schema(as = source::dataverse::DataverseSourceConfig)]
39#[serde(rename_all = "camelCase", deny_unknown_fields)]
40pub struct DataverseSourceConfigDto {
41    /// Dataverse environment URL (e.g., `https://myorg.crm.dynamics.com`).
42    pub environment_url: ConfigValue<String>,
43
44    /// Azure AD tenant ID for OAuth2 authentication.
45    #[serde(default)]
46    pub tenant_id: Option<ConfigValue<String>>,
47
48    /// Azure AD application (client) ID.
49    #[serde(default)]
50    pub client_id: Option<ConfigValue<String>>,
51
52    /// Azure AD client secret for OAuth2 client credentials flow.
53    #[serde(default)]
54    pub client_secret: Option<ConfigValue<String>>,
55
56    /// Use Azure CLI for authentication instead of client credentials.
57    #[serde(default)]
58    pub use_azure_cli: bool,
59
60    /// Entity logical names to monitor (e.g., `["account", "contact"]`).
61    pub entities: Vec<String>,
62
63    /// Override entity set names for non-standard pluralization.
64    #[serde(default)]
65    pub entity_set_overrides: HashMap<String, String>,
66
67    /// Per-entity column selection.
68    #[serde(default)]
69    pub entity_columns: HashMap<String, Vec<String>>,
70
71    /// Minimum adaptive polling interval in milliseconds.
72    #[serde(default = "default_min_interval_ms")]
73    pub min_interval_ms: ConfigValue<u64>,
74
75    /// Maximum adaptive polling interval per entity in seconds.
76    /// Scaled by sqrt(entity_count) at startup.
77    #[serde(default = "default_max_interval_seconds")]
78    pub max_interval_seconds: ConfigValue<u64>,
79
80    /// Dataverse Web API version (default: `v9.2`).
81    #[serde(default = "default_api_version")]
82    pub api_version: ConfigValue<String>,
83}
84
85// ── Descriptor ───────────────────────────────────────────────────────────────
86
87#[derive(OpenApi)]
88#[openapi(components(schemas(DataverseSourceConfigDto)))]
89struct DataverseSourceSchemas;
90
91/// Plugin descriptor for the Dataverse source.
92pub struct DataverseSourceDescriptor;
93
94#[async_trait]
95impl SourcePluginDescriptor for DataverseSourceDescriptor {
96    fn kind(&self) -> &str {
97        "dataverse"
98    }
99
100    fn config_version(&self) -> &str {
101        "1.0.0"
102    }
103
104    fn config_schema_name(&self) -> &str {
105        "source.dataverse.DataverseSourceConfig"
106    }
107
108    fn config_schema_json(&self) -> String {
109        let api = DataverseSourceSchemas::openapi();
110        serde_json::to_string(
111            &api.components
112                .as_ref()
113                .expect("OpenAPI components missing")
114                .schemas,
115        )
116        .expect("Failed to serialize config schema")
117    }
118
119    async fn create_source(
120        &self,
121        id: &str,
122        config_json: &serde_json::Value,
123        auto_start: bool,
124    ) -> anyhow::Result<Box<dyn drasi_lib::sources::Source>> {
125        let dto: DataverseSourceConfigDto = serde_json::from_value(config_json.clone())?;
126        let mapper = DtoMapper::new();
127
128        let environment_url = mapper.resolve_string(&dto.environment_url)?;
129        let tenant_id = mapper
130            .resolve_optional_string(&dto.tenant_id)?
131            .unwrap_or_default();
132        let client_id = mapper
133            .resolve_optional_string(&dto.client_id)?
134            .unwrap_or_default();
135        let client_secret = mapper
136            .resolve_optional_string(&dto.client_secret)?
137            .unwrap_or_default();
138        let min_interval_ms = mapper.resolve_typed(&dto.min_interval_ms)?;
139        let max_interval_seconds = mapper.resolve_typed(&dto.max_interval_seconds)?;
140        let api_version = mapper.resolve_string(&dto.api_version)?;
141
142        let config = DataverseSourceConfig {
143            environment_url,
144            tenant_id,
145            client_id,
146            client_secret,
147            use_azure_cli: dto.use_azure_cli,
148            entities: dto.entities,
149            entity_set_overrides: dto.entity_set_overrides,
150            entity_columns: dto.entity_columns,
151            min_interval_ms,
152            max_interval_seconds,
153            api_version,
154        };
155
156        let mut source = crate::DataverseSourceBuilder::new(id)
157            .with_environment_url(config.environment_url.clone())
158            .with_tenant_id(config.tenant_id.clone())
159            .with_client_id(config.client_id.clone())
160            .with_client_secret(config.client_secret.clone())
161            .with_entities(config.entities.clone())
162            .with_min_interval_ms(config.min_interval_ms)
163            .with_max_interval_seconds(config.max_interval_seconds)
164            .with_api_version(config.api_version.clone())
165            .with_auto_start(auto_start);
166
167        if config.use_azure_cli {
168            source = source.with_azure_cli_auth();
169        }
170
171        // Apply entity set overrides
172        for (entity, set_name) in &config.entity_set_overrides {
173            source = source.with_entity_set_override(entity, set_name);
174        }
175
176        // Apply per-entity column selections
177        for (entity, columns) in &config.entity_columns {
178            source = source.with_entity_columns(entity, columns.clone());
179        }
180
181        let source = source.build()?;
182
183        Ok(Box::new(source))
184    }
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190
191    #[test]
192    fn test_dto_deserializes_from_camel_case_json() {
193        let json = serde_json::json!({
194            "environmentUrl": "https://myorg.crm.dynamics.com",
195            "tenantId": "tenant-1",
196            "clientId": "client-1",
197            "clientSecret": "secret-1",
198            "entities": ["account", "contact"]
199        });
200
201        let dto: DataverseSourceConfigDto =
202            serde_json::from_value(json).expect("should deserialize");
203
204        assert_eq!(
205            dto.environment_url,
206            ConfigValue::Static("https://myorg.crm.dynamics.com".to_string())
207        );
208        assert_eq!(
209            dto.tenant_id,
210            Some(ConfigValue::Static("tenant-1".to_string()))
211        );
212        assert_eq!(
213            dto.client_id,
214            Some(ConfigValue::Static("client-1".to_string()))
215        );
216        assert_eq!(
217            dto.client_secret,
218            Some(ConfigValue::Static("secret-1".to_string()))
219        );
220        assert_eq!(dto.entities, vec!["account", "contact"]);
221    }
222
223    #[test]
224    fn test_dto_applies_defaults() {
225        let json = serde_json::json!({
226            "environmentUrl": "https://myorg.crm.dynamics.com",
227            "entities": ["account"]
228        });
229
230        let dto: DataverseSourceConfigDto =
231            serde_json::from_value(json).expect("should deserialize");
232
233        assert_eq!(dto.min_interval_ms, ConfigValue::Static(500));
234        assert_eq!(dto.max_interval_seconds, ConfigValue::Static(30));
235        assert_eq!(dto.api_version, ConfigValue::Static("v9.2".to_string()));
236        assert!(!dto.use_azure_cli);
237        assert!(dto.entity_set_overrides.is_empty());
238        assert!(dto.entity_columns.is_empty());
239        assert_eq!(dto.tenant_id, None);
240        assert_eq!(dto.client_id, None);
241        assert_eq!(dto.client_secret, None);
242    }
243
244    #[test]
245    fn test_dto_azure_cli_mode() {
246        let json = serde_json::json!({
247            "environmentUrl": "https://myorg.crm.dynamics.com",
248            "useAzureCli": true,
249            "entities": ["account"]
250        });
251
252        let dto: DataverseSourceConfigDto =
253            serde_json::from_value(json).expect("should deserialize");
254
255        assert!(dto.use_azure_cli);
256        assert_eq!(dto.tenant_id, None);
257    }
258
259    #[test]
260    fn test_descriptor_metadata() {
261        let desc = DataverseSourceDescriptor;
262        assert_eq!(desc.kind(), "dataverse");
263        assert_eq!(desc.config_version(), "1.0.0");
264        assert_eq!(
265            desc.config_schema_name(),
266            "source.dataverse.DataverseSourceConfig"
267        );
268        // Schema JSON should be valid JSON
269        let schema = desc.config_schema_json();
270        let _: serde_json::Value =
271            serde_json::from_str(&schema).expect("schema should be valid JSON");
272    }
273}