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