macro_rules! define_plugin {
(
$plugin_ty:ty, {
fn new() -> Self $new_body:block
fn init(&mut $init_self:ident, $ctx_param:ident : &PluginContext $(,)?)
-> Result<(), Box<dyn std::error::Error + Send + Sync>> $init_body:block
fn name(&$name_self:ident) -> &str $name_body:block
fn version(&$version_self:ident) -> &str $version_body:block
fn process(
&$process_self:ident,
$msg_param:ident : &SpoeMessage $(,)?
) -> Result<ProcessingResult, Box<dyn std::error::Error + Send + Sync>> $process_body:block
$(fn config_schema(&$schema_self:ident) -> Option<&str> $schema_body:block)?
$(fn validate(&$validate_self:ident, $vctx_param:ident : &PluginContext $(,)?) -> Vec<Diagnostic> $validate_body:block)?
$(fn drain(&$drain_self:ident, $drain_timeout_param:ident : u64 $(,)?) -> bool $drain_body:block)?
$(metrics_static = $metrics_static:path;)?
}
) => { ... };
}Expand description
Define a plugin and emit the FFI surface (vtable + entry symbol).
Plugin authors write a regular impl-style block; the macro emits
extern "C" thunks that bridge into it, the static PluginVTable,
and the get_plugin_vtable symbol the hub looks up after dlopen.
process is automatically wrapped in std::panic::catch_unwind so
a panic during request handling does not abort the hub process.
§Usage
use haproxy_spoa_hub_plugin_api::{ProcessingResult, SpoeMessage, SpoeValue, TxnVariable, define_plugin};
#[derive(Debug, Default)]
struct MyPlugin;
define_plugin!(MyPlugin, {
fn new() -> Self { MyPlugin }
fn init(&mut self, _: &PluginContext) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
Ok(())
}
fn name(&self) -> &str { "my-plugin" }
fn version(&self) -> &str { env!("CARGO_PKG_VERSION") }
fn process(&self, _: &SpoeMessage)
-> Result<ProcessingResult, Box<dyn std::error::Error + Send + Sync>>
{
Ok(ProcessingResult::single(TxnVariable::session(
"result", SpoeValue::String("ok".into()),
)))
}
});config_schema and validate are optional. Add them at the end of
the block in that order if you need them. Plugins that omit them
get the default no-op behavior (no schema, empty diagnostics).
§validate() contract: do semantic checks only
The hub guarantees that by the time your validate() body runs,
the PluginContext passed in has already been validated against
your config_schema() (in BOTH the load path and the
--validate-socket path; see crates/hub/src/plugin_loader.rs’s
collect_schema_errors and how it’s called from
crates/hub/src/validate/orchestrator.rs). Schema errors short-
circuit before validate() is called and surface to the operator
as the same diagnostic shape your override would emit.
You should therefore never reimplement structural validation
inside validate(). No re-checking that a field is a string,
no walking arrays-vs-tables defensively, no type-shape
assumptions. Use whatever typed accessor your plugin already
builds for init() (typically from_context(ctx) populating a
PluginConfig struct) — the structure is guaranteed valid.
validate() is for semantic checks the JSON Schema can’t
express. Examples:
- Compiling
SecLangdirectives via Coraza so a typo’dSecBogusDirectivebecomes a line-numbered diagnostic (haproxy-spoa-hub-plugin-coraza). - Resolving a remote auth-server URL to verify it’s reachable (a hypothetical external-auth deep check).
- Anything else that requires runtime context the schema can’t capture.
Historical context: pre-hub-v0.5.2, the --validate-socket path
did NOT schema-check before calling validate(), so plugins that
added their own structural walking could (and did) drift away
from config_schema() and the runtime parser. The coraza plugin’s
applications-as-array vs applications-as-table mismatch in
v0.4.0–v0.4.1 was that bug. Centralising the check in the hub
makes it impossible for a plugin author to accidentally repeat.
§Lifetime constraints
name and version MUST return &'static str. The macro emits an
FFI thunk whose return type is RStr<'static>, so a &str
borrowed from &self will not compile. In practice this means
returning either a string literal or env!("CARGO_PKG_VERSION").
If you need to compute the name from instance fields, store it in
a &'static str (e.g. via Box::leak(...) at construction time)
or pre-register the strings as consts.
§Panic safety
Every author-supplied body is wrapped in std::panic::catch_unwind
inside its FFI thunk. A panic surfaces as:
process/init/create:RResult::RErr(PluginPanicError).validate: a singleDiagnostic::errorentry returned to the host (not an abort — required for--validate-socketmode).config_schema: treated asRNone.name/version: returned as the literal"<plugin-panic>".shutdown/destroy: absorbed; memory may leak but the hub stays up.