drasi_plugin_sdk/descriptor.rs
1// Copyright 2025 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 traits that define how plugins advertise their capabilities.
16//!
17//! Each plugin type (source, reaction, bootstrapper) has a corresponding descriptor
18//! trait. A plugin crate implements one or more of these traits and returns instances
19//! via [`PluginRegistration`](crate::registration::PluginRegistration).
20//!
21//! # Descriptor Responsibilities
22//!
23//! Each descriptor provides:
24//!
25//! 1. **Kind** — A unique string identifier (e.g., `"postgres"`, `"http"`, `"log"`).
26//! 2. **Config version** — A semver string for the plugin's DTO version.
27//! 3. **Config schema** — A serialized [utoipa](https://docs.rs/utoipa) `Schema` object
28//! (as JSON) that describes the plugin's configuration DTO. This is used by the server
29//! to generate the OpenAPI specification.
30//! 4. **Factory method** — An async `create_*` method that takes raw JSON config and
31//! returns a configured plugin instance.
32//!
33//! # Schema Generation
34//!
35//! Plugins generate their schema by deriving [`utoipa::ToSchema`] on their DTO struct
36//! and serializing it to JSON:
37//!
38//! ```rust,ignore
39//! use utoipa::openapi::schema::Schema;
40//! use drasi_plugin_sdk::prelude::*;
41//!
42//! #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
43//! #[serde(rename_all = "camelCase")]
44//! pub struct MySourceConfigDto {
45//! #[schema(value_type = ConfigValueString)]
46//! pub host: ConfigValue<String>,
47//! #[schema(value_type = ConfigValueU16)]
48//! pub port: ConfigValue<u16>,
49//! }
50//!
51//! // In the descriptor implementation:
52//! fn config_schema_json(&self) -> String {
53//! let schema = <MySourceConfigDto as utoipa::ToSchema>::schema();
54//! serde_json::to_string(&schema).unwrap()
55//! }
56//! ```
57//!
58//! # DTO Versioning
59//!
60//! Each plugin versions its DTO independently using semver:
61//!
62//! - **Major** version bump → Breaking change (field removed, type changed, renamed).
63//! - **Minor** version bump → Additive change (new optional field added).
64//! - **Patch** version bump → Documentation or description change.
65//!
66//! The server compares the plugin's `config_version()` against known versions and
67//! can reject incompatible plugins at load time.
68//!
69//! # Dynamic Loading
70//!
71//! Descriptors are fully compatible with dynamic loading. When a plugin is compiled
72//! as a `cdylib` shared library, the descriptor trait objects are passed to the server
73//! through the [`PluginRegistration`](crate::registration::PluginRegistration) returned
74//! by the `drasi_plugin_init()` entry point. The server calls the descriptor methods
75//! (e.g., `kind()`, `config_schema_json()`, `create_source()`) across the shared library
76//! boundary. Both plugin and server **must** be compiled with the same Rust toolchain
77//! and the same `drasi-plugin-sdk` version for this to work correctly.
78//!
79//! # Schema Naming Convention
80//!
81//! Schema names follow the pattern `{component_type}.{kind}.{TypeName}` to avoid
82//! collisions when multiple plugins are loaded into the same OpenAPI spec.
83//!
84//! Examples:
85//! - `source.postgres.PostgresSourceConfig`
86//! - `reaction.http.HttpReactionConfig`
87//! - `bootstrap.mssql.MssqlBootstrapConfig`
88//!
89//! # Complete Example
90//!
91//! ```rust,ignore
92//! use drasi_plugin_sdk::prelude::*;
93//! use drasi_lib::sources::Source;
94//!
95//! /// Descriptor for the PostgreSQL source plugin.
96//! pub struct PostgresSourceDescriptor;
97//!
98//! #[async_trait]
99//! impl SourcePluginDescriptor for PostgresSourceDescriptor {
100//! fn kind(&self) -> &str {
101//! "postgres"
102//! }
103//!
104//! fn config_version(&self) -> &str {
105//! "1.0.0"
106//! }
107//!
108//! fn config_schema_json(&self) -> String {
109//! let schema = <PostgresSourceConfigDto as utoipa::ToSchema>::schema();
110//! serde_json::to_string(&schema).unwrap()
111//! }
112//!
113//! async fn create_source(
114//! &self,
115//! id: &str,
116//! config_json: &serde_json::Value,
117//! auto_start: bool,
118//! ) -> anyhow::Result<Box<dyn Source>> {
119//! let dto: PostgresSourceConfigDto = serde_json::from_value(config_json.clone())?;
120//! let mapper = DtoMapper::new();
121//! let host = mapper.resolve_string(&dto.host)?;
122//! let port = mapper.resolve_typed(&dto.port)?;
123//! // ... build and return the source
124//! todo!()
125//! }
126//! }
127//! ```
128
129use async_trait::async_trait;
130use drasi_lib::bootstrap::BootstrapProvider;
131use drasi_lib::identity::IdentityProvider;
132use drasi_lib::reactions::Reaction;
133use drasi_lib::sources::Source;
134
135/// Descriptor for a **source** plugin.
136///
137/// Source plugins ingest data from external systems (databases, APIs, message queues)
138/// and feed change events into the Drasi query engine.
139///
140/// # Implementors
141///
142/// Each source plugin crate (e.g., `drasi-source-postgres`) implements this trait
143/// on a zero-sized descriptor struct and returns it via [`PluginRegistration`].
144///
145/// See the [module docs](self) for a complete example.
146#[async_trait]
147pub trait SourcePluginDescriptor: Send + Sync {
148 /// The unique kind identifier for this source (e.g., `"postgres"`, `"http"`, `"mock"`).
149 ///
150 /// This value is used as the `kind` field in YAML configuration and API requests.
151 /// Must be lowercase, alphanumeric with hyphens (e.g., `"my-source"`).
152 fn kind(&self) -> &str;
153
154 /// The semver version of this plugin's configuration DTO.
155 ///
156 /// Bump major for breaking changes, minor for new optional fields, patch for docs.
157 fn config_version(&self) -> &str;
158
159 /// Returns all OpenAPI schemas for this plugin as a JSON-serialized map.
160 ///
161 /// The return value is a JSON object where keys are schema names and values
162 /// are utoipa `Schema` objects. This must include the top-level config DTO
163 /// (identified by [`config_schema_name()`](Self::config_schema_name)) as well
164 /// as any nested types it references.
165 ///
166 /// # Implementation
167 ///
168 /// Use `#[derive(OpenApi)]` listing only the top-level DTO to automatically
169 /// collect all transitive schema dependencies:
170 ///
171 /// ```rust,ignore
172 /// use utoipa::OpenApi;
173 ///
174 /// #[derive(OpenApi)]
175 /// #[openapi(schemas(MyConfigDto))]
176 /// struct MyPluginSchemas;
177 ///
178 /// fn config_schema_json(&self) -> String {
179 /// let api = MyPluginSchemas::openapi();
180 /// serde_json::to_string(&api.components.as_ref().unwrap().schemas).unwrap()
181 /// }
182 /// ```
183 fn config_schema_json(&self) -> String;
184
185 /// Returns the OpenAPI schema name for this plugin's configuration DTO.
186 ///
187 /// This name is used as the key in the OpenAPI `components/schemas` map.
188 /// It should match the `#[schema(as = ...)]` annotation on the DTO, or the
189 /// struct name if no alias is set. Use a `category.kind.TypeName` namespace
190 /// to avoid collisions (e.g., `"source.postgres.PostgresSourceConfig"`).
191 ///
192 /// # Example
193 ///
194 /// ```rust,ignore
195 /// fn config_schema_name(&self) -> &str {
196 /// "source.postgres.PostgresSourceConfig"
197 /// }
198 /// ```
199 fn config_schema_name(&self) -> &str;
200
201 /// Human-readable display name for this source kind (e.g., "PostgreSQL").
202 ///
203 /// Used by the UI and init wizard. Defaults to the `kind()` value.
204 fn display_name(&self) -> &str {
205 self.kind()
206 }
207
208 /// Human-readable description of this source plugin.
209 ///
210 /// Used by the UI and init wizard. Defaults to an empty string.
211 fn display_description(&self) -> &str {
212 ""
213 }
214
215 /// Icon identifier for this source kind (e.g., "postgres", "database").
216 ///
217 /// Used by the UI. Defaults to the `kind()` value.
218 fn display_icon(&self) -> &str {
219 self.kind()
220 }
221
222 /// Create a new source instance from the given configuration.
223 ///
224 /// # Arguments
225 ///
226 /// - `id` — The unique identifier for this source instance.
227 /// - `config_json` — The plugin-specific configuration as a JSON value.
228 /// This should be deserialized into the plugin's DTO type.
229 /// - `auto_start` — Whether the source should start automatically after creation.
230 ///
231 /// # Errors
232 ///
233 /// Returns an error if the configuration is invalid or the source cannot be created.
234 async fn create_source(
235 &self,
236 id: &str,
237 config_json: &serde_json::Value,
238 auto_start: bool,
239 ) -> anyhow::Result<Box<dyn Source>>;
240}
241
242/// Descriptor for a **reaction** plugin.
243///
244/// Reaction plugins consume query results and perform side effects (webhooks,
245/// logging, stored procedures, SSE streams, etc.).
246///
247/// # Implementors
248///
249/// Each reaction plugin crate (e.g., `drasi-reaction-http`) implements this trait
250/// on a zero-sized descriptor struct and returns it via [`PluginRegistration`].
251#[async_trait]
252pub trait ReactionPluginDescriptor: Send + Sync {
253 /// The unique kind identifier for this reaction (e.g., `"http"`, `"log"`, `"sse"`).
254 fn kind(&self) -> &str;
255
256 /// The semver version of this plugin's configuration DTO.
257 fn config_version(&self) -> &str;
258
259 /// Returns all OpenAPI schemas as a JSON-serialized map (see [`SourcePluginDescriptor::config_schema_json`]).
260 fn config_schema_json(&self) -> String;
261
262 /// Returns the OpenAPI schema name for this plugin's configuration DTO.
263 fn config_schema_name(&self) -> &str;
264
265 /// Human-readable display name for this reaction kind.
266 fn display_name(&self) -> &str {
267 self.kind()
268 }
269
270 /// Human-readable description of this reaction plugin.
271 fn display_description(&self) -> &str {
272 ""
273 }
274
275 /// Icon identifier for this reaction kind.
276 fn display_icon(&self) -> &str {
277 self.kind()
278 }
279
280 /// Create a new reaction instance from the given configuration.
281 ///
282 /// # Arguments
283 ///
284 /// - `id` — The unique identifier for this reaction instance.
285 /// - `query_ids` — The IDs of queries this reaction subscribes to.
286 /// - `config_json` — The plugin-specific configuration as a JSON value.
287 /// - `auto_start` — Whether the reaction should start automatically after creation.
288 async fn create_reaction(
289 &self,
290 id: &str,
291 query_ids: Vec<String>,
292 config_json: &serde_json::Value,
293 auto_start: bool,
294 ) -> anyhow::Result<Box<dyn Reaction>>;
295}
296
297/// Descriptor for a **bootstrap** plugin.
298///
299/// Bootstrap plugins provide initial data snapshots for sources when queries
300/// first subscribe. They deliver historical/current state so queries start with
301/// a complete view of the data.
302///
303/// # Implementors
304///
305/// Each bootstrap plugin crate (e.g., `drasi-bootstrap-postgres`) implements this trait
306/// on a zero-sized descriptor struct and returns it via [`PluginRegistration`].
307#[async_trait]
308pub trait BootstrapPluginDescriptor: Send + Sync {
309 /// The unique kind identifier for this bootstrapper (e.g., `"postgres"`, `"scriptfile"`).
310 fn kind(&self) -> &str;
311
312 /// The semver version of this plugin's configuration DTO.
313 fn config_version(&self) -> &str;
314
315 /// Returns all OpenAPI schemas as a JSON-serialized map (see [`SourcePluginDescriptor::config_schema_json`]).
316 fn config_schema_json(&self) -> String;
317
318 /// Returns the OpenAPI schema name for this plugin's configuration DTO.
319 fn config_schema_name(&self) -> &str;
320
321 /// Human-readable display name for this bootstrap kind.
322 fn display_name(&self) -> &str {
323 self.kind()
324 }
325
326 /// Human-readable description of this bootstrap plugin.
327 fn display_description(&self) -> &str {
328 ""
329 }
330
331 /// Icon identifier for this bootstrap kind.
332 fn display_icon(&self) -> &str {
333 self.kind()
334 }
335
336 /// Create a new bootstrap provider from the given configuration.
337 ///
338 /// # Arguments
339 ///
340 /// - `config_json` — The bootstrap-specific configuration as a JSON value.
341 /// - `source_config_json` — The parent source's configuration, which the
342 /// bootstrapper may need to connect to the same data system.
343 async fn create_bootstrap_provider(
344 &self,
345 config_json: &serde_json::Value,
346 source_config_json: &serde_json::Value,
347 ) -> anyhow::Result<Box<dyn BootstrapProvider>>;
348}
349
350/// Descriptor for an **identity provider** plugin.
351///
352/// Identity provider plugins supply authentication credentials (passwords, tokens,
353/// certificates) to sources and reactions that need them for connecting to external
354/// systems. Examples include Azure AD managed-identity providers and AWS IAM
355/// authentication providers.
356///
357/// # Implementors
358///
359/// Each identity provider plugin crate (e.g., `drasi-identity-azure`) implements
360/// this trait on a zero-sized descriptor struct and returns it via
361/// [`PluginRegistration`].
362#[async_trait]
363pub trait IdentityProviderPluginDescriptor: Send + Sync {
364 /// The unique kind identifier for this identity provider (e.g., `"azure"`, `"aws"`).
365 fn kind(&self) -> &str;
366
367 /// The semver version of this plugin's configuration DTO.
368 fn config_version(&self) -> &str;
369
370 /// Returns all OpenAPI schemas as a JSON-serialized map (see [`SourcePluginDescriptor::config_schema_json`]).
371 fn config_schema_json(&self) -> String;
372
373 /// Returns the OpenAPI schema name for this plugin's configuration DTO.
374 fn config_schema_name(&self) -> &str;
375
376 /// Create a new identity provider instance from the given configuration.
377 ///
378 /// # Arguments
379 ///
380 /// - `config_json` — The plugin-specific configuration as a JSON value.
381 /// This should be deserialized into the plugin's DTO type.
382 async fn create_identity_provider(
383 &self,
384 config_json: &serde_json::Value,
385 ) -> anyhow::Result<Box<dyn IdentityProvider>>;
386}
387
388#[cfg(test)]
389mod tests {
390 use super::*;
391 use drasi_lib::SubscriptionResponse;
392
393 // A minimal mock source for testing the descriptor trait
394 struct MockTestSource {
395 id: String,
396 }
397
398 #[async_trait]
399 impl Source for MockTestSource {
400 fn id(&self) -> &str {
401 &self.id
402 }
403 fn type_name(&self) -> &str {
404 "test"
405 }
406 fn properties(&self) -> std::collections::HashMap<String, serde_json::Value> {
407 std::collections::HashMap::new()
408 }
409 async fn start(&self) -> anyhow::Result<()> {
410 Ok(())
411 }
412 async fn stop(&self) -> anyhow::Result<()> {
413 Ok(())
414 }
415 async fn status(&self) -> drasi_lib::ComponentStatus {
416 drasi_lib::ComponentStatus::Stopped
417 }
418 async fn subscribe(
419 &self,
420 _settings: drasi_lib::config::SourceSubscriptionSettings,
421 ) -> anyhow::Result<SubscriptionResponse> {
422 unimplemented!()
423 }
424 fn as_any(&self) -> &dyn std::any::Any {
425 self
426 }
427 async fn initialize(&self, _context: drasi_lib::SourceRuntimeContext) {}
428 }
429
430 struct TestSourceDescriptor;
431
432 #[async_trait]
433 impl SourcePluginDescriptor for TestSourceDescriptor {
434 fn kind(&self) -> &str {
435 "test"
436 }
437 fn config_version(&self) -> &str {
438 "1.0.0"
439 }
440 fn config_schema_json(&self) -> String {
441 r#"{"TestSourceConfig":{"type":"object"}}"#.to_string()
442 }
443 fn config_schema_name(&self) -> &str {
444 "TestSourceConfig"
445 }
446 async fn create_source(
447 &self,
448 id: &str,
449 _config_json: &serde_json::Value,
450 _auto_start: bool,
451 ) -> anyhow::Result<Box<dyn Source>> {
452 Ok(Box::new(MockTestSource { id: id.to_string() }))
453 }
454 }
455
456 #[tokio::test]
457 async fn test_source_descriptor_kind() {
458 let desc = TestSourceDescriptor;
459 assert_eq!(desc.kind(), "test");
460 }
461
462 #[tokio::test]
463 async fn test_source_descriptor_version() {
464 let desc = TestSourceDescriptor;
465 assert_eq!(desc.config_version(), "1.0.0");
466 }
467
468 #[tokio::test]
469 async fn test_source_descriptor_schema() {
470 let desc = TestSourceDescriptor;
471 let schema = desc.config_schema_json();
472 let parsed: serde_json::Value = serde_json::from_str(&schema).expect("valid JSON");
473 assert_eq!(parsed["TestSourceConfig"]["type"], "object");
474 }
475
476 #[tokio::test]
477 async fn test_source_descriptor_create() {
478 let desc = TestSourceDescriptor;
479 let config = serde_json::json!({});
480 let source = desc
481 .create_source("my-source", &config, true)
482 .await
483 .expect("create source");
484 assert_eq!(source.id(), "my-source");
485 }
486}