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}