haproxy-spoa-hub-plugin-api 0.3.0

Plugin API for haproxy-spoa-hub — define SPOE agent plugins as shared libraries
Documentation
# crates/plugin-api

Published as `haproxy-spoa-hub-plugin-api` on crates.io. This is the only
crate plugin authors need to depend on.

## Public API

- **`SpoePlugin`** trait (`plugin_trait.rs`): `init`, `process`, `name`,
  `version`, `shutdown`, `config_schema`. Defined via `#[sabi_trait]` for
  FFI safety. Requires `Send + Sync + Debug`.
- **`define_plugin!`** macro (`lib.rs`): Generates `#[export_root_module]`
  boilerplate and wraps `process()` in `catch_unwind` for panic safety.
  Supports an optional `config_schema` block for JSON Schema validation.
- **Types** (`types.rs`): `ConfigValue`, `SpoeValue`, `SpoeMessage`,
  `ProcessingResult`, `TxnVariable`, `VarScope` — all `#[derive(StableAbi)]`.
- **`PluginMod` / `PluginMod_Ref`**: Root module with factory function.

## abi_stable Conventions

- `#[sabi(last_prefix_field)]` on `SpoePlugin::shutdown()` — methods added
  in future minor versions go after this marker with default implementations.
  `config_schema()` is the first such method (returns `RNone` by default).
- `RootModule::load_from_file()` for loading (not `load_from_directory()`
  which caches per `RootModule` type).
- FFI-safe types: `RString`, `RVec`, `RHashMap`, `RResult`, `RBoxError`,
  `ROption`.
- `RHashMap` iterates as `Tuple2<&K, &V>`, not `(&K, &V)` tuples. Use
  `pair.0` / `pair.1` instead of destructuring.

## Plugin Author Workflow

```rust
#![allow(non_camel_case_types, non_local_definitions)]
use haproxy_spoa_hub_plugin_api::*;

#[derive(Debug)]
struct MyPlugin;

define_plugin!(MyPlugin, {
    fn new() -> Self { MyPlugin }
    fn init(&mut self, _context: &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,
        message: &SpoeMessage,
    ) -> Result<ProcessingResult, Box<dyn std::error::Error + Send + Sync>> {
        let src = message.get_string("src").unwrap_or("unknown");
        Ok(ProcessingResult::single(
            TxnVariable::session("result", SpoeValue::String(src.into())),
        ))
    }
});
```

Plugin Cargo.toml must have `crate-type = ["cdylib"]` (add `"rlib"` too
for unit testing). The `abi_stable` crate is also required as a direct
dependency (the `#[export_root_module]` proc macro generates code that
references `::abi_stable` at the crate root).

## Convenience Helpers (v0.2.0)

- **`SpoeMessage`**: `get()`, `get_string()`, `get_int()`, `get_bool()`,
  `get_ipv4()`, `get_ipv6()`, `get_binary()` — O(1) argument lookup
  accepting `&str` directly (`RString` implements `Borrow<str>`).
- **`TxnVariable`**: `new()`, `transaction()`, `session()` — scope-specific
  constructors that accept `impl Into<RString>`.
- **`ProcessingResult`**: `empty()`, `single()`, `from_vars()` — result
  constructors replacing verbose struct literals.
- **`PluginContext`**: `get_config()` — config key lookup accepting `&str`.
- **Re-exports**: `RString`, `RVec`, `RHashMap`, `Tuple2` are re-exported
  from the plugin API crate, so plugins can import them without reaching
  into `abi_stable::std_types` directly.

## Panic Safety

The `define_plugin!` macro wraps `process()` in `std::panic::catch_unwind`.
A panicking plugin returns `RResult::RErr(PluginPanicError)` instead of
aborting the hub. This is critical because abi_stable's `#[sabi_trait]`
vtable uses `AbortBomb` semantics — without `catch_unwind`, a plugin panic
aborts the entire process.

## Versioning Rules

- Adding new named variants to enums **before** `__Other` = minor version
  (safe, `__Other` catch-all absorbs the layout change for old plugins)
- Adding new `SpoePlugin` methods after `last_prefix_field` = minor version
  (safe, old plugins use defaults)
- Adding new `SpoeMessage` fields = requires prefix types (major version)
- Changing existing variant/field types or reordering = major version bump
- Removing variants or fields = major version bump
- Moving `#[sabi(last_prefix_field)]` = major version bump

All public enums (`ConfigValue`, `SpoeValue`, `VarScope`) have a `__Other`
catch-all variant. Plugins MUST use `_ =>` wildcard arms in match
statements to handle future variants gracefully.