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}