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