Drasi Plugin SDK
The Drasi Plugin SDK provides traits, types, and utilities for building plugins for the any application that implements the Host SDK. Plugins extend the server with new data sources, reactions, and bootstrap providers.
Plugins can be compiled directly into the server binary (static linking) or built as shared libraries for dynamic loading at runtime.
Quick Start
Add the SDK to your plugin crate:
[]
= { = true }
= { = true }
Import the prelude:
use *;
Plugin Types
The SDK defines three plugin categories, each with a corresponding descriptor trait:
| Plugin Type | Trait | Purpose |
|---|---|---|
| Source | SourcePluginDescriptor |
Ingests data from external systems (databases, APIs, queues) |
| Reaction | ReactionPluginDescriptor |
Consumes query results and performs side effects (webhooks, logging, stored procedures) |
| Bootstrap | BootstrapPluginDescriptor |
Provides initial data snapshots so queries start with a complete view |
Writing a Plugin
1. Define your configuration DTO
Use ConfigValue<T> for fields that may be provided as static values, environment variable references, or secret references. Derive utoipa::ToSchema for OpenAPI schema generation.
use *;
2. Implement a descriptor trait
Each descriptor provides:
kind()— A unique string identifier (e.g.,"postgres","http")config_version()— A semver version for the configuration DTOconfig_schema_json()— A JSON-serialized OpenAPI schema mapconfig_schema_name()— The schema name used as the key in the OpenAPI speccreate_*()— A factory method that builds a plugin instance from raw JSON config
use *;
use OpenApi;
;
// Collect all transitive schemas via utoipa's OpenApi derive
;
3. Register the plugin
For static plugins
Bundle your descriptors into a PluginRegistration and pass them to the server at startup:
use *;
For dynamic plugins
Use the export_plugin! macro, which generates the FFI entry points, a dedicated tokio runtime, and log/lifecycle callback wiring:
export_plugin!;
Static vs. Dynamic Plugins
Static Linking
Bundle your descriptors directly into a Rust binary using PluginRegistration. This is only applicable when using drasi-lib programmatically (embedded/library use) — no shared library boundary, no ABI concerns. The standalone drasi-server always uses dynamic loading.
Dynamic Loading
Build the plugin as a cdylib shared library that the server discovers and loads at runtime. This is the standard deployment model for the standalone server — dynamic loading is always enabled with no feature flags required on the server side.
Step 1: Configure crate type
[]
= ["lib", "cdylib"]
[]
= { = true }
= { = true }
The dual ["lib", "cdylib"] crate type allows the crate to be used both as a normal Rust dependency (for static builds) and as a shared library (for dynamic loading).
Step 2: Use the export_plugin! macro
The export_plugin! macro generates everything needed for dynamic loading:
drasi_plugin_metadata()— Returns version info for validation before initializationdrasi_plugin_init()— Returns anFfiPluginRegistrationwith FFI vtable factories- A dedicated tokio runtime for the plugin
- Log and lifecycle callback bridges (tracing integration via
FfiTracingLayer)
export_plugin!;
Each descriptor is wrapped in an FFI vtable (SourcePluginVtable, ReactionPluginVtable, BootstrapPluginVtable) that provides #[repr(C)] function pointers. When the host calls a factory method (e.g., create_source_fn), the plugin constructs a trait object and wraps it in a component vtable (SourceVtable, ReactionVtable, BootstrapProviderVtable) with its own FFI-safe function pointers.
Step 3: Build and deploy
# Build the plugin as a cdylib shared library
# Copy the shared library to the server's plugin directory
Note: Individual plugin crates may define a
dynamic-pluginfeature to gateexport_plugin!macro invocation — check the plugin'sCargo.toml.
Plugin metadata provenance
PluginMetadata includes git_commit (from GIT_COMMIT env or git rev-parse HEAD) and build_timestamp (from BUILD_TIMESTAMP env or compile time) for build provenance tracking.
Plugin signing
When publishing plugins with cargo xtask publish-plugins --sign, each plugin is signed using cosign keyless signing (Sigstore). Third-party plugin authors can sign their plugins using cosign sign --yes <oci-reference> after publishing.
Compatibility requirements
Both the plugin and the server must be compiled with:
- The same Rust toolchain version — the Rust ABI is not stable across compiler versions.
- The same
drasi-plugin-sdkversion — the server comparesSDK_VERSIONat load time and rejects mismatches. - The same target triple — validated via
PluginMetadata::target_triple.
The server performs a two-phase load:
- Metadata validation — calls
drasi_plugin_metadata()to check SDK version, build hash, and target triple. - Initialization — calls
drasi_plugin_init()only if metadata validation passes.
FFI Architecture
Dynamic plugins communicate with the host through #[repr(C)] vtable structs (stable C ABI). The FFI layer is organized into several modules:
| Module | Description |
|---|---|
ffi::types |
Core FFI-safe primitives (FfiStr, FfiResult, FfiOwnedStr, etc.). Re-exported from the drasi-ffi-primitives crate. |
ffi::vtables |
#[repr(C)] vtable structs for all component types, generated with the ffi_vtable! macro |
ffi::vtable_gen |
Functions that wrap trait objects into vtables (e.g., build_source_vtable) |
ffi::callbacks |
Log and lifecycle callback types (LogCallbackFn, LifecycleCallbackFn) |
ffi::metadata |
PluginMetadata struct and version constants for load-time validation |
ffi::tracing_bridge |
Bridges tracing/log events from the plugin to the host via FFI callbacks |
ffi::identity |
FFI types for IdentityProvider injection into plugins |
ffi::identity_proxy |
Plugin-side proxy that implements IdentityProvider over an FFI vtable |
ffi::state_store_proxy |
Plugin-side proxy that implements StateStoreProvider over an FFI vtable |
ffi::bootstrap_proxy |
Plugin-side proxy that implements BootstrapProvider over an FFI vtable |
Each plugin gets its own tokio runtime (created by the export_plugin! macro). Synchronous vtable calls (e.g., start, stop) use dispatch_to_runtime with a std::sync::mpsc::sync_channel(0) rendezvous to bridge into the plugin's async runtime. However, the primary data paths use a push-based model that avoids per-event dispatch_to_runtime overhead.
Push-Based Data Delivery
All high-throughput data paths use a push model where a forwarder task is spawned on one side and events are pushed via a callback into a channel on the other side:
- Source change events: The host calls
start_push_fnwith a callback. The plugin spawns a forwarder task on its runtime that reads from the underlying change channel and invokes the callback for each event. This avoids per-eventdispatch_to_runtimeoverhead (~0.3µs vs ~5-20µs for rendezvous dispatch). - Bootstrap events: Same push pattern — the plugin spawns a forwarder that pushes bootstrap events (a finite stream) into a host-side channel via a callback.
- Reaction query results: Reversed direction —
start_result_push_fnlets the host push query results into a channel. The plugin's forwarder drains them via a blocking callback (spawn_blocking+std::sync::mpsc::Receiver::recv).
Typical FFI overhead through the full push pipeline is ~1-2µs per change event.
Cross-cdylib Channel Safety
All channels that cross the cdylib boundary use std::sync::mpsc (not tokio::sync::mpsc). Each cdylib has its own statically-linked copy of tokio with incompatible internal state, so tokio::sync channels cannot be shared across the boundary. Host-side proxies bridge from std::sync::mpsc into tokio channels using forwarding tasks or tokio::sync::Notify for async wakeup.
Fire-and-Forget Host Callbacks
Host callbacks for logging and lifecycle events use fire-and-forget tokio::spawn on the host runtime (via run_on_host_runtime) rather than blocking rendezvous. This avoids deadlocks when the host runtime is single-threaded (e.g., in test environments). The plugin invokes the extern "C" callback synchronously, but the host side immediately spawns an async task and returns.
Configuration Values
ConfigValue<T> is the building block for plugin configuration fields. It supports three input formats:
Static value
host: "localhost"
port: 5432
POSIX environment variable reference
host: "${DB_HOST}"
port: "${DB_PORT:-5432}"
Structured reference
password:
kind: Secret
name: db-password
port:
kind: EnvironmentVariable
name: DB_PORT
default: "5432"
Type aliases
| Alias | Type |
|---|---|
ConfigValueString |
ConfigValue<String> |
ConfigValueU16 |
ConfigValue<u16> |
ConfigValueU32 |
ConfigValue<u32> |
ConfigValueU64 |
ConfigValue<u64> |
ConfigValueUsize |
ConfigValue<usize> |
ConfigValueBool |
ConfigValue<bool> |
Each alias has a corresponding *Schema wrapper type for use with #[schema(value_type = ...)] annotations in utoipa.
Resolving Configuration Values
Use DtoMapper to resolve ConfigValue references to their actual values:
let mapper = new;
// Resolve to string
let host: String = mapper.resolve_string?;
// Resolve to typed value (parses string → T via FromStr)
let port: u16 = mapper.resolve_typed?;
// Resolve optional fields
let timeout: = mapper.resolve_optional?;
// Resolve a vec of string values
let tags: = mapper.resolve_string_vec?;
Built-in resolvers
| Resolver | Handles | Behavior |
|---|---|---|
EnvironmentVariableResolver |
ConfigValue::EnvironmentVariable |
Reads std::env::var(), falls back to default |
SecretResolver |
ConfigValue::Secret |
Delegates to a pluggable SecretResolver registered via register_secret_resolver() |
Custom resolvers
Implement ValueResolver to add custom resolution logic (e.g., HashiCorp Vault, AWS SSM):
use *;
let mapper = new
.with_resolver;
DTO Versioning
Each plugin independently versions its configuration DTO via the config_version() method using semver:
| Change | Version Bump | Example |
|---|---|---|
| Field removed or renamed | Major | 1.0.0 → 2.0.0 |
| Field type changed | Major | 1.0.0 → 2.0.0 |
| New optional field added | Minor | 1.0.0 → 1.1.0 |
| Documentation or description change | Patch | 1.0.0 → 1.0.1 |
OpenAPI Schema Generation
Plugins provide their configuration schemas as JSON-serialized utoipa schema maps. The server collects these from all registered plugins and assembles them into the OpenAPI specification. This keeps schema ownership with the plugins while allowing the server to build a unified API spec.
The recommended pattern is to use #[derive(OpenApi)] to automatically collect all transitive schema dependencies:
use OpenApi;
;
Modules
| Module | Description |
|---|---|
config_value |
ConfigValue<T> enum, type aliases, and OpenAPI schema wrappers |
descriptor |
Plugin descriptor traits (SourcePluginDescriptor, ReactionPluginDescriptor, BootstrapPluginDescriptor) |
ffi |
FFI layer for dynamic plugin loading — vtables, callbacks, proxies, tracing bridge |
mapper |
DtoMapper service and ConfigMapper trait for DTO-to-domain conversions |
registration |
PluginRegistration struct, SDK_VERSION, BUILD_HASH, and TOKIO_VERSION constants |
resolver |
ValueResolver trait and built-in implementations |
prelude |
Convenience re-exports for plugin authors |
License
Licensed under the Apache License, Version 2.0.