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)?;
125        let tenant_id = mapper
126            .resolve_optional_string(&dto.tenant_id)?
127            .unwrap_or_default();
128        let client_id = mapper
129            .resolve_optional_string(&dto.client_id)?
130            .unwrap_or_default();
131        let client_secret = mapper
132            .resolve_optional_string(&dto.client_secret)?
133            .unwrap_or_default();
134        let min_interval_ms = mapper.resolve_typed(&dto.min_interval_ms)?;
135        let max_interval_seconds = mapper.resolve_typed(&dto.max_interval_seconds)?;
136        let api_version = mapper.resolve_string(&dto.api_version)?;
137
138        let config = DataverseSourceConfig {
139            environment_url,
140            tenant_id,
141            client_id,
142            client_secret,
143            entities: dto.entities,
144            entity_set_overrides: dto.entity_set_overrides,
145            entity_columns: dto.entity_columns,
146            min_interval_ms,
147            max_interval_seconds,
148            api_version,
149        };
150
151        let mut source = crate::DataverseSourceBuilder::new(id)
152            .with_environment_url(config.environment_url.clone())
153            .with_tenant_id(config.tenant_id.clone())
154            .with_client_id(config.client_id.clone())
155            .with_client_secret(config.client_secret.clone())
156            .with_entities(config.entities.clone())
157            .with_min_interval_ms(config.min_interval_ms)
158            .with_max_interval_seconds(config.max_interval_seconds)
159            .with_api_version(config.api_version.clone())
160            .with_auto_start(auto_start);
161
162        // Apply entity set overrides
163        for (entity, set_name) in &config.entity_set_overrides {
164            source = source.with_entity_set_override(entity, set_name);
165        }
166
167        // Apply per-entity column selections
168        for (entity, columns) in &config.entity_columns {
169            source = source.with_entity_columns(entity, columns.clone());
170        }
171
172        let source = source.build()?;
173
174        Ok(Box::new(source))
175    }
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181
182    #[test]
183    fn test_dto_deserializes_from_camel_case_json() {
184        let json = serde_json::json!({
185            "environmentUrl": "https://myorg.crm.dynamics.com",
186            "tenantId": "tenant-1",
187            "clientId": "client-1",
188            "clientSecret": "secret-1",
189            "entities": ["account", "contact"]
190        });
191
192        let dto: DataverseSourceConfigDto =
193            serde_json::from_value(json).expect("should deserialize");
194
195        assert_eq!(
196            dto.environment_url,
197            ConfigValue::Static("https://myorg.crm.dynamics.com".to_string())
198        );
199        assert_eq!(
200            dto.tenant_id,
201            Some(ConfigValue::Static("tenant-1".to_string()))
202        );
203        assert_eq!(
204            dto.client_id,
205            Some(ConfigValue::Static("client-1".to_string()))
206        );
207        assert_eq!(
208            dto.client_secret,
209            Some(ConfigValue::Static("secret-1".to_string()))
210        );
211        assert_eq!(dto.entities, vec!["account", "contact"]);
212    }
213
214    #[test]
215    fn test_dto_applies_defaults() {
216        let json = serde_json::json!({
217            "environmentUrl": "https://myorg.crm.dynamics.com",
218            "entities": ["account"]
219        });
220
221        let dto: DataverseSourceConfigDto =
222            serde_json::from_value(json).expect("should deserialize");
223
224        assert_eq!(dto.min_interval_ms, ConfigValue::Static(500));
225        assert_eq!(dto.max_interval_seconds, ConfigValue::Static(30));
226        assert_eq!(dto.api_version, ConfigValue::Static("v9.2".to_string()));
227        assert!(dto.entity_set_overrides.is_empty());
228        assert!(dto.entity_columns.is_empty());
229        assert_eq!(dto.tenant_id, None);
230        assert_eq!(dto.client_id, None);
231        assert_eq!(dto.client_secret, None);
232    }
233
234    #[test]
235    fn test_descriptor_metadata() {
236        let desc = DataverseSourceDescriptor;
237        assert_eq!(desc.kind(), "dataverse");
238        assert_eq!(desc.config_version(), "1.0.0");
239        assert_eq!(
240            desc.config_schema_name(),
241            "source.dataverse.DataverseSourceConfig"
242        );
243        // Schema JSON should be valid JSON
244        let schema = desc.config_schema_json();
245        let _: serde_json::Value =
246            serde_json::from_str(&schema).expect("schema should be valid JSON");
247    }
248}