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::reactions::Reaction;
132use drasi_lib::sources::Source;
133
134/// Descriptor for a **source** plugin.
135///
136/// Source plugins ingest data from external systems (databases, APIs, message queues)
137/// and feed change events into the Drasi query engine.
138///
139/// # Implementors
140///
141/// Each source plugin crate (e.g., `drasi-source-postgres`) implements this trait
142/// on a zero-sized descriptor struct and returns it via [`PluginRegistration`].
143///
144/// See the [module docs](self) for a complete example.
145#[async_trait]
146pub trait SourcePluginDescriptor: Send + Sync {
147    /// The unique kind identifier for this source (e.g., `"postgres"`, `"http"`, `"mock"`).
148    ///
149    /// This value is used as the `kind` field in YAML configuration and API requests.
150    /// Must be lowercase, alphanumeric with hyphens (e.g., `"my-source"`).
151    fn kind(&self) -> &str;
152
153    /// The semver version of this plugin's configuration DTO.
154    ///
155    /// Bump major for breaking changes, minor for new optional fields, patch for docs.
156    fn config_version(&self) -> &str;
157
158    /// Returns all OpenAPI schemas for this plugin as a JSON-serialized map.
159    ///
160    /// The return value is a JSON object where keys are schema names and values
161    /// are utoipa `Schema` objects. This must include the top-level config DTO
162    /// (identified by [`config_schema_name()`](Self::config_schema_name)) as well
163    /// as any nested types it references.
164    ///
165    /// # Implementation
166    ///
167    /// Use `#[derive(OpenApi)]` listing only the top-level DTO to automatically
168    /// collect all transitive schema dependencies:
169    ///
170    /// ```rust,ignore
171    /// use utoipa::OpenApi;
172    ///
173    /// #[derive(OpenApi)]
174    /// #[openapi(schemas(MyConfigDto))]
175    /// struct MyPluginSchemas;
176    ///
177    /// fn config_schema_json(&self) -> String {
178    ///     let api = MyPluginSchemas::openapi();
179    ///     serde_json::to_string(&api.components.as_ref().unwrap().schemas).unwrap()
180    /// }
181    /// ```
182    fn config_schema_json(&self) -> String;
183
184    /// Returns the OpenAPI schema name for this plugin's configuration DTO.
185    ///
186    /// This name is used as the key in the OpenAPI `components/schemas` map.
187    /// It should match the `#[schema(as = ...)]` annotation on the DTO, or the
188    /// struct name if no alias is set. Use a `category.kind.TypeName` namespace
189    /// to avoid collisions (e.g., `"source.postgres.PostgresSourceConfig"`).
190    ///
191    /// # Example
192    ///
193    /// ```rust,ignore
194    /// fn config_schema_name(&self) -> &str {
195    ///     "source.postgres.PostgresSourceConfig"
196    /// }
197    /// ```
198    fn config_schema_name(&self) -> &str;
199
200    /// Human-readable display name for this source kind (e.g., "PostgreSQL").
201    ///
202    /// Used by the UI and init wizard. Defaults to the `kind()` value.
203    fn display_name(&self) -> &str {
204        self.kind()
205    }
206
207    /// Human-readable description of this source plugin.
208    ///
209    /// Used by the UI and init wizard. Defaults to an empty string.
210    fn display_description(&self) -> &str {
211        ""
212    }
213
214    /// Icon identifier for this source kind (e.g., "postgres", "database").
215    ///
216    /// Used by the UI. Defaults to the `kind()` value.
217    fn display_icon(&self) -> &str {
218        self.kind()
219    }
220
221    /// Create a new source instance from the given configuration.
222    ///
223    /// # Arguments
224    ///
225    /// - `id` — The unique identifier for this source instance.
226    /// - `config_json` — The plugin-specific configuration as a JSON value.
227    ///   This should be deserialized into the plugin's DTO type.
228    /// - `auto_start` — Whether the source should start automatically after creation.
229    ///
230    /// # Errors
231    ///
232    /// Returns an error if the configuration is invalid or the source cannot be created.
233    async fn create_source(
234        &self,
235        id: &str,
236        config_json: &serde_json::Value,
237        auto_start: bool,
238    ) -> anyhow::Result<Box<dyn Source>>;
239}
240
241/// Descriptor for a **reaction** plugin.
242///
243/// Reaction plugins consume query results and perform side effects (webhooks,
244/// logging, stored procedures, SSE streams, etc.).
245///
246/// # Implementors
247///
248/// Each reaction plugin crate (e.g., `drasi-reaction-http`) implements this trait
249/// on a zero-sized descriptor struct and returns it via [`PluginRegistration`].
250#[async_trait]
251pub trait ReactionPluginDescriptor: Send + Sync {
252    /// The unique kind identifier for this reaction (e.g., `"http"`, `"log"`, `"sse"`).
253    fn kind(&self) -> &str;
254
255    /// The semver version of this plugin's configuration DTO.
256    fn config_version(&self) -> &str;
257
258    /// Returns all OpenAPI schemas as a JSON-serialized map (see [`SourcePluginDescriptor::config_schema_json`]).
259    fn config_schema_json(&self) -> String;
260
261    /// Returns the OpenAPI schema name for this plugin's configuration DTO.
262    fn config_schema_name(&self) -> &str;
263
264    /// Human-readable display name for this reaction kind.
265    fn display_name(&self) -> &str {
266        self.kind()
267    }
268
269    /// Human-readable description of this reaction plugin.
270    fn display_description(&self) -> &str {
271        ""
272    }
273
274    /// Icon identifier for this reaction kind.
275    fn display_icon(&self) -> &str {
276        self.kind()
277    }
278
279    /// Create a new reaction instance from the given configuration.
280    ///
281    /// # Arguments
282    ///
283    /// - `id` — The unique identifier for this reaction instance.
284    /// - `query_ids` — The IDs of queries this reaction subscribes to.
285    /// - `config_json` — The plugin-specific configuration as a JSON value.
286    /// - `auto_start` — Whether the reaction should start automatically after creation.
287    async fn create_reaction(
288        &self,
289        id: &str,
290        query_ids: Vec<String>,
291        config_json: &serde_json::Value,
292        auto_start: bool,
293    ) -> anyhow::Result<Box<dyn Reaction>>;
294}
295
296/// Descriptor for a **bootstrap** plugin.
297///
298/// Bootstrap plugins provide initial data snapshots for sources when queries
299/// first subscribe. They deliver historical/current state so queries start with
300/// a complete view of the data.
301///
302/// # Implementors
303///
304/// Each bootstrap plugin crate (e.g., `drasi-bootstrap-postgres`) implements this trait
305/// on a zero-sized descriptor struct and returns it via [`PluginRegistration`].
306#[async_trait]
307pub trait BootstrapPluginDescriptor: Send + Sync {
308    /// The unique kind identifier for this bootstrapper (e.g., `"postgres"`, `"scriptfile"`).
309    fn kind(&self) -> &str;
310
311    /// The semver version of this plugin's configuration DTO.
312    fn config_version(&self) -> &str;
313
314    /// Returns all OpenAPI schemas as a JSON-serialized map (see [`SourcePluginDescriptor::config_schema_json`]).
315    fn config_schema_json(&self) -> String;
316
317    /// Returns the OpenAPI schema name for this plugin's configuration DTO.
318    fn config_schema_name(&self) -> &str;
319
320    /// Human-readable display name for this bootstrap kind.
321    fn display_name(&self) -> &str {
322        self.kind()
323    }
324
325    /// Human-readable description of this bootstrap plugin.
326    fn display_description(&self) -> &str {
327        ""
328    }
329
330    /// Icon identifier for this bootstrap kind.
331    fn display_icon(&self) -> &str {
332        self.kind()
333    }
334
335    /// Create a new bootstrap provider from the given configuration.
336    ///
337    /// # Arguments
338    ///
339    /// - `config_json` — The bootstrap-specific configuration as a JSON value.
340    /// - `source_config_json` — The parent source's configuration, which the
341    ///   bootstrapper may need to connect to the same data system.
342    async fn create_bootstrap_provider(
343        &self,
344        config_json: &serde_json::Value,
345        source_config_json: &serde_json::Value,
346    ) -> anyhow::Result<Box<dyn BootstrapProvider>>;
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352    use drasi_lib::SubscriptionResponse;
353
354    // A minimal mock source for testing the descriptor trait
355    struct MockTestSource {
356        id: String,
357    }
358
359    #[async_trait]
360    impl Source for MockTestSource {
361        fn id(&self) -> &str {
362            &self.id
363        }
364        fn type_name(&self) -> &str {
365            "test"
366        }
367        fn properties(&self) -> std::collections::HashMap<String, serde_json::Value> {
368            std::collections::HashMap::new()
369        }
370        async fn start(&self) -> anyhow::Result<()> {
371            Ok(())
372        }
373        async fn stop(&self) -> anyhow::Result<()> {
374            Ok(())
375        }
376        async fn status(&self) -> drasi_lib::ComponentStatus {
377            drasi_lib::ComponentStatus::Stopped
378        }
379        async fn subscribe(
380            &self,
381            _settings: drasi_lib::config::SourceSubscriptionSettings,
382        ) -> anyhow::Result<SubscriptionResponse> {
383            unimplemented!()
384        }
385        fn as_any(&self) -> &dyn std::any::Any {
386            self
387        }
388        async fn initialize(&self, _context: drasi_lib::SourceRuntimeContext) {}
389    }
390
391    struct TestSourceDescriptor;
392
393    #[async_trait]
394    impl SourcePluginDescriptor for TestSourceDescriptor {
395        fn kind(&self) -> &str {
396            "test"
397        }
398        fn config_version(&self) -> &str {
399            "1.0.0"
400        }
401        fn config_schema_json(&self) -> String {
402            r#"{"TestSourceConfig":{"type":"object"}}"#.to_string()
403        }
404        fn config_schema_name(&self) -> &str {
405            "TestSourceConfig"
406        }
407        async fn create_source(
408            &self,
409            id: &str,
410            _config_json: &serde_json::Value,
411            _auto_start: bool,
412        ) -> anyhow::Result<Box<dyn Source>> {
413            Ok(Box::new(MockTestSource { id: id.to_string() }))
414        }
415    }
416
417    #[tokio::test]
418    async fn test_source_descriptor_kind() {
419        let desc = TestSourceDescriptor;
420        assert_eq!(desc.kind(), "test");
421    }
422
423    #[tokio::test]
424    async fn test_source_descriptor_version() {
425        let desc = TestSourceDescriptor;
426        assert_eq!(desc.config_version(), "1.0.0");
427    }
428
429    #[tokio::test]
430    async fn test_source_descriptor_schema() {
431        let desc = TestSourceDescriptor;
432        let schema = desc.config_schema_json();
433        let parsed: serde_json::Value = serde_json::from_str(&schema).expect("valid JSON");
434        assert_eq!(parsed["TestSourceConfig"]["type"], "object");
435    }
436
437    #[tokio::test]
438    async fn test_source_descriptor_create() {
439        let desc = TestSourceDescriptor;
440        let config = serde_json::json!({});
441        let source = desc
442            .create_source("my-source", &config, true)
443            .await
444            .expect("create source");
445        assert_eq!(source.id(), "my-source");
446    }
447}