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    /// Entity logical names to monitor (e.g., `["account", "contact"]`).
57    pub entities: Vec<String>,
58
59    /// Override entity set names for non-standard pluralization.
60    #[serde(default)]
61    pub entity_set_overrides: HashMap<String, String>,
62
63    /// Per-entity column selection.
64    #[serde(default)]
65    pub entity_columns: HashMap<String, Vec<String>>,
66
67    /// Minimum adaptive polling interval in milliseconds.
68    #[serde(default = "default_min_interval_ms")]
69    pub min_interval_ms: ConfigValue<u64>,
70
71    /// Maximum adaptive polling interval per entity in seconds.
72    /// Scaled by sqrt(entity_count) at startup.
73    #[serde(default = "default_max_interval_seconds")]
74    pub max_interval_seconds: ConfigValue<u64>,
75
76    /// Dataverse Web API version (default: `v9.2`).
77    #[serde(default = "default_api_version")]
78    pub api_version: ConfigValue<String>,
79}
80
81// ── Descriptor ───────────────────────────────────────────────────────────────
82
83#[derive(OpenApi)]
84#[openapi(components(schemas(DataverseSourceConfigDto)))]
85struct DataverseSourceSchemas;
86
87/// Plugin descriptor for the Dataverse source.
88pub struct DataverseSourceDescriptor;
89
90#[async_trait]
91impl SourcePluginDescriptor for DataverseSourceDescriptor {
92    fn kind(&self) -> &str {
93        "dataverse"
94    }
95
96    fn config_version(&self) -> &str {
97        "1.0.0"
98    }
99
100    fn config_schema_name(&self) -> &str {
101        "source.dataverse.DataverseSourceConfig"
102    }
103
104    fn config_schema_json(&self) -> String {
105        let api = DataverseSourceSchemas::openapi();
106        serde_json::to_string(
107            &api.components
108                .as_ref()
109                .expect("OpenAPI components missing")
110                .schemas,
111        )
112        .expect("Failed to serialize config schema")
113    }
114
115    async fn create_source(
116        &self,
117        id: &str,
118        config_json: &serde_json::Value,
119        auto_start: bool,
120    ) -> anyhow::Result<Box<dyn drasi_lib::sources::Source>> {
121        let dto: DataverseSourceConfigDto = serde_json::from_value(config_json.clone())?;
122        let mapper = DtoMapper::new();
123
124        let environment_url = mapper.resolve_string(&dto.environment_url).await?;
125        let tenant_id = mapper
126            .resolve_optional_string(&dto.tenant_id)
127            .await?
128            .unwrap_or_default();
129        let client_id = mapper
130            .resolve_optional_string(&dto.client_id)
131            .await?
132            .unwrap_or_default();
133        let client_secret = mapper
134            .resolve_optional_string(&dto.client_secret)
135            .await?
136            .unwrap_or_default();
137        let min_interval_ms = mapper.resolve_typed(&dto.min_interval_ms).await?;
138        let max_interval_seconds = mapper.resolve_typed(&dto.max_interval_seconds).await?;
139        let api_version = mapper.resolve_string(&dto.api_version).await?;
140
141        let config = DataverseSourceConfig {
142            environment_url,
143            tenant_id,
144            client_id,
145            client_secret,
146            entities: dto.entities,
147            entity_set_overrides: dto.entity_set_overrides,
148            entity_columns: dto.entity_columns,
149            min_interval_ms,
150            max_interval_seconds,
151            api_version,
152        };
153
154        let mut source = crate::DataverseSourceBuilder::new(id)
155            .with_environment_url(config.environment_url.clone())
156            .with_tenant_id(config.tenant_id.clone())
157            .with_client_id(config.client_id.clone())
158            .with_client_secret(config.client_secret.clone())
159            .with_entities(config.entities.clone())
160            .with_min_interval_ms(config.min_interval_ms)
161            .with_max_interval_seconds(config.max_interval_seconds)
162            .with_api_version(config.api_version.clone())
163            .with_auto_start(auto_start);
164
165        // Apply entity set overrides
166        for (entity, set_name) in &config.entity_set_overrides {
167            source = source.with_entity_set_override(entity, set_name);
168        }
169
170        // Apply per-entity column selections
171        for (entity, columns) in &config.entity_columns {
172            source = source.with_entity_columns(entity, columns.clone());
173        }
174
175        let source = source.build()?;
176
177        Ok(Box::new(source))
178    }
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184
185    #[test]
186    fn test_dto_deserializes_from_camel_case_json() {
187        let json = serde_json::json!({
188            "environmentUrl": "https://myorg.crm.dynamics.com",
189            "tenantId": "tenant-1",
190            "clientId": "client-1",
191            "clientSecret": "secret-1",
192            "entities": ["account", "contact"]
193        });
194
195        let dto: DataverseSourceConfigDto =
196            serde_json::from_value(json).expect("should deserialize");
197
198        assert_eq!(
199            dto.environment_url,
200            ConfigValue::Static("https://myorg.crm.dynamics.com".to_string())
201        );
202        assert_eq!(
203            dto.tenant_id,
204            Some(ConfigValue::Static("tenant-1".to_string()))
205        );
206        assert_eq!(
207            dto.client_id,
208            Some(ConfigValue::Static("client-1".to_string()))
209        );
210        assert_eq!(
211            dto.client_secret,
212            Some(ConfigValue::Static("secret-1".to_string()))
213        );
214        assert_eq!(dto.entities, vec!["account", "contact"]);
215    }
216
217    #[test]
218    fn test_dto_applies_defaults() {
219        let json = serde_json::json!({
220            "environmentUrl": "https://myorg.crm.dynamics.com",
221            "entities": ["account"]
222        });
223
224        let dto: DataverseSourceConfigDto =
225            serde_json::from_value(json).expect("should deserialize");
226
227        assert_eq!(dto.min_interval_ms, ConfigValue::Static(500));
228        assert_eq!(dto.max_interval_seconds, ConfigValue::Static(30));
229        assert_eq!(dto.api_version, ConfigValue::Static("v9.2".to_string()));
230        assert!(dto.entity_set_overrides.is_empty());
231        assert!(dto.entity_columns.is_empty());
232        assert_eq!(dto.tenant_id, None);
233        assert_eq!(dto.client_id, None);
234        assert_eq!(dto.client_secret, None);
235    }
236
237    #[test]
238    fn test_descriptor_metadata() {
239        let desc = DataverseSourceDescriptor;
240        assert_eq!(desc.kind(), "dataverse");
241        assert_eq!(desc.config_version(), "1.0.0");
242        assert_eq!(
243            desc.config_schema_name(),
244            "source.dataverse.DataverseSourceConfig"
245        );
246        // Schema JSON should be valid JSON
247        let schema = desc.config_schema_json();
248        let _: serde_json::Value =
249            serde_json::from_str(&schema).expect("schema should be valid JSON");
250    }
251}