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}