Skip to main content

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}