Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ members = [
"crates/cpex-core",
"crates/cpex-sdk",
"crates/cpex-ffi",
"crates/cpex-dynamic-plugin",
"crates/cpex-dynamic-plugin/examples/single-plugin",
"crates/cpex-dynamic-plugin/examples/multi-handler",
"crates/cpex-dynamic-plugin/examples/multi-plugin",
"examples/go-demo/ffi",
]

Expand Down
191 changes: 183 additions & 8 deletions crates/cpex-core/src/factory.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,50 +88,225 @@ pub struct PluginInstance {
/// The host populates this before calling `PluginManager::from_config()`.
/// Each factory knows how to create plugins of a specific kind.
///
/// # Two dispatch modes
///
/// Factories register under one of two patterns:
///
/// * **Exact-match `kind`** — `register("rate_limiter", factory)`.
/// Matches plugins whose `kind:` is exactly `"rate_limiter"`. This
/// is the standard pattern for in-tree factories.
/// * **Scheme prefix** — `register_scheme("lib", factory)`. Matches
/// plugins whose `kind:` starts with `"lib:"` (e.g.,
/// `kind: "lib:/opt/plugins/foo.so#bar"`). The factory's
/// `create()` receives the full kind string and parses the
/// scheme-specific format itself. Used by dynamic loaders
/// (cdylib, WASM, gRPC) where the kind needs to carry a
/// resource locator alongside the plugin name.
///
/// Exact matches win over scheme matches when both are registered.
///
/// # Examples
///
/// ```rust,ignore
/// let mut factories = PluginFactoryRegistry::new();
/// factories.register("builtin/rate_limit", Box::new(RateLimiterFactory));
/// factories.register("builtin/identity", Box::new(IdentityFactory));
/// factories.register("rate_limiter", Box::new(RateLimiterFactory));
/// factories.register_scheme("lib", Box::new(DynamicPluginFactory::new()));
///
/// let manager = PluginManager::from_config(path, &factories)?;
/// ```
pub struct PluginFactoryRegistry {
/// Factories registered for exact `kind` matches.
factories: HashMap<String, Box<dyn PluginFactory>>,
/// Factories registered for `<scheme>:...` style kinds. The
/// key is the scheme alone (e.g., `"lib"`).
scheme_factories: HashMap<String, Box<dyn PluginFactory>>,
}

impl PluginFactoryRegistry {
/// Create an empty factory registry.
pub fn new() -> Self {
Self {
factories: HashMap::new(),
scheme_factories: HashMap::new(),
}
}

/// Register a factory for a given `kind` name.
/// Register a factory for a given `kind` name (exact match).
pub fn register(&mut self, kind: impl Into<String>, factory: Box<dyn PluginFactory>) {
self.factories.insert(kind.into(), factory);
}

/// Look up a factory by `kind` name.
/// Register a factory that handles all kinds starting with
/// `<scheme>:`. The factory's `create()` receives the full
/// kind string (including the scheme prefix) and is
/// responsible for parsing the scheme-specific format.
///
/// Example: `register_scheme("lib", ...)` matches plugins with
/// `kind: "lib:/path/to/foo.so"`, `kind: "lib:/other.so#handler"`,
/// etc.
pub fn register_scheme(
&mut self,
scheme: impl Into<String>,
factory: Box<dyn PluginFactory>,
) {
self.scheme_factories.insert(scheme.into(), factory);
}

/// Look up a factory by `kind` name. Tries exact match first;
/// falls back to scheme-prefix match if the kind contains a
/// `:` separator.
pub fn get(&self, kind: &str) -> Option<&dyn PluginFactory> {
self.factories.get(kind).map(|f| f.as_ref())
if let Some(f) = self.factories.get(kind) {
return Some(f.as_ref());
}
if let Some((scheme, _rest)) = kind.split_once(':') {
if !scheme.is_empty() {
return self.scheme_factories.get(scheme).map(|f| f.as_ref());
}
}
None
}

/// Whether a factory exists for the given `kind`.
/// Whether a factory exists for the given `kind` (exact or
/// scheme-prefix match).
pub fn has(&self, kind: &str) -> bool {
self.factories.contains_key(kind)
self.get(kind).is_some()
}

/// All registered kind names.
/// All registered exact-match kind names.
pub fn kinds(&self) -> Vec<&str> {
self.factories.keys().map(|s| s.as_str()).collect()
}

/// All registered scheme names (without the trailing `:`).
pub fn schemes(&self) -> Vec<&str> {
self.scheme_factories.keys().map(|s| s.as_str()).collect()
}
}

impl Default for PluginFactoryRegistry {
fn default() -> Self {
Self::new()
}
}

#[cfg(test)]
mod tests {
use super::*;
use crate::plugin::PluginConfig;

/// Fake factory that records a tag so tests can verify which
/// factory was dispatched to. `create()` always errors with the
/// tag embedded — tests look at the error message instead of
/// constructing real PluginInstances.
struct TagFactory(&'static str);
impl PluginFactory for TagFactory {
fn create(
&self,
_config: &PluginConfig,
) -> Result<PluginInstance, Box<PluginError>> {
Err(Box::new(PluginError::Config {
message: format!("dispatched-to:{}", self.0),
}))
}
}

fn make_cfg(kind: &str) -> PluginConfig {
PluginConfig {
name: "test".into(),
kind: kind.into(),
..Default::default()
}
}

/// Pull the dispatch tag out of a TagFactory error. Uses match
/// instead of `unwrap_err()` because `PluginInstance` (the Ok
/// variant) holds `Arc<dyn Plugin>` and doesn't impl Debug.
fn dispatch_tag(result: Result<PluginInstance, Box<PluginError>>) -> String {
match result {
Err(boxed) => match *boxed {
PluginError::Config { message } => message
.strip_prefix("dispatched-to:")
.map(String::from)
.unwrap_or(message),
_ => panic!("unexpected error variant"),
},
Ok(_) => panic!("TagFactory should always Err"),
}
}

#[test]
fn exact_match_dispatches_to_registered_factory() {
let mut reg = PluginFactoryRegistry::new();
reg.register("rate_limit", Box::new(TagFactory("rate_limit")));
let factory = reg.get("rate_limit").expect("factory found");
assert_eq!(dispatch_tag(factory.create(&make_cfg("rate_limit"))), "rate_limit");
}

#[test]
fn unknown_kind_returns_none() {
let reg = PluginFactoryRegistry::new();
assert!(reg.get("nonexistent").is_none());
assert!(!reg.has("nonexistent"));
}

#[test]
fn scheme_match_dispatches_when_no_exact_match() {
let mut reg = PluginFactoryRegistry::new();
reg.register_scheme("lib", Box::new(TagFactory("lib-loader")));
// kind starts with `lib:` → dispatch to scheme factory.
let factory = reg.get("lib:/opt/plugins/foo.so#bar").expect("factory found");
assert_eq!(
dispatch_tag(factory.create(&make_cfg("lib:/opt/plugins/foo.so#bar"))),
"lib-loader",
);
}

#[test]
fn exact_match_wins_over_scheme_match() {
let mut reg = PluginFactoryRegistry::new();
reg.register("lib", Box::new(TagFactory("exact-lib")));
reg.register_scheme("lib", Box::new(TagFactory("scheme-lib")));
let exact = reg.get("lib").unwrap();
assert_eq!(dispatch_tag(exact.create(&make_cfg("lib"))), "exact-lib");
let prefixed = reg.get("lib:/path/to.so").unwrap();
assert_eq!(
dispatch_tag(prefixed.create(&make_cfg("lib:/path/to.so"))),
"scheme-lib",
);
}

#[test]
fn empty_scheme_does_not_match() {
let mut reg = PluginFactoryRegistry::new();
reg.register_scheme("", Box::new(TagFactory("would-be-empty")));
assert!(
reg.get(":foo").is_none(),
"leading-colon kind must not dispatch even when empty scheme is registered",
);
}

#[test]
fn kind_with_colons_in_path_dispatches_correctly() {
// Windows path with drive-letter colon: `lib:/C:/plugins/foo.dll`.
// `split_once(':')` splits on the FIRST colon only — scheme is
// `"lib"`, rest with embedded colons passes through unchanged.
let mut reg = PluginFactoryRegistry::new();
reg.register_scheme("lib", Box::new(TagFactory("lib-loader")));
let factory = reg.get("lib:/C:/plugins/foo.dll").unwrap();
assert_eq!(
dispatch_tag(factory.create(&make_cfg("lib:/C:/plugins/foo.dll"))),
"lib-loader",
);
}

#[test]
fn schemes_lists_registered_schemes() {
let mut reg = PluginFactoryRegistry::new();
reg.register_scheme("lib", Box::new(TagFactory("a")));
reg.register_scheme("wasm", Box::new(TagFactory("b")));
let mut names: Vec<&str> = reg.schemes();
names.sort();
assert_eq!(names, vec!["lib", "wasm"]);
}
}
38 changes: 38 additions & 0 deletions crates/cpex-core/src/manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,44 @@ impl PluginManager {
.register(kind, factory);
}

/// Register a factory that handles all plugin `kind`s starting
/// with `<scheme>:`. Used by dynamic loaders (cdylib, WASM,
/// gRPC) where the kind string carries a resource locator
/// alongside the plugin name — e.g.
/// `kind: "lib:/opt/plugins/foo.so#bar"`.
///
/// The factory's `create()` receives the full kind string
/// (including the scheme prefix) and is responsible for
/// parsing the scheme-specific format.
///
/// Exact-match `register_factory` registrations win over
/// scheme matches when both could apply.
///
/// # Examples
///
/// ```rust,ignore
/// // Once at host startup:
/// manager.register_factory_scheme(
/// "lib",
/// Box::new(DynamicPluginFactory::new()),
/// );
///
/// // Operators then write in unified-config YAML:
/// // plugins:
/// // - name: rate-limit
/// // kind: "lib:/opt/plugins/rate_limit.so#default"
/// ```
pub fn register_factory_scheme(
&self,
scheme: impl Into<String>,
factory: Box<dyn crate::factory::PluginFactory>,
) {
self.factories
.write()
.unwrap_or_else(|p| p.into_inner())
.register_scheme(scheme, factory);
}

// -----------------------------------------------------------------------
// Config Loading
// -----------------------------------------------------------------------
Expand Down
Loading