Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

WASM plugin development

This guide explains how to create WASM plugins for Barbacane.

Overview

Barbacane plugins are WebAssembly (WASM) modules that extend gateway functionality. There are two types:

TypePurposeExports
MiddlewareProcess requests/responses in a chaininit, on_request, on_response
DispatcherHandle requests and generate responsesinit, dispatch

Prerequisites

  • Rust stable with wasm32-unknown-unknown target
  • barbacane-plugin-sdk crate
# Add the WASM target
rustup target add wasm32-unknown-unknown

Quick Start

1. Create a New Plugin

cargo new --lib my-plugin
cd my-plugin

2. Configure Cargo.toml

[package]
name = "my-plugin"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib"]

[dependencies]
barbacane-plugin-sdk = { path = "../path/to/barbacane/crates/barbacane-plugin-sdk" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"

3. Write the Plugin

Middleware example:

use barbacane_plugin_sdk::prelude::*;
use serde::Deserialize;

#[barbacane_middleware]
#[derive(Deserialize)]
pub struct MyMiddleware {
    // Configuration fields from the spec
    header_name: String,
    header_value: String,
}

impl MyMiddleware {
    pub fn on_request(&mut self, req: Request) -> Action {
        // Add a header to the request
        let mut req = req;
        req.headers.insert(
            self.header_name.clone(),
            self.header_value.clone(),
        );
        Action::Continue(req)
    }

    pub fn on_response(&mut self, resp: Response) -> Response {
        // Pass through unchanged
        resp
    }
}

Dispatcher example:

use barbacane_plugin_sdk::prelude::*;
use serde::Deserialize;

#[barbacane_dispatcher]
#[derive(Deserialize)]
pub struct MyDispatcher {
    status: u16,
    body: String,
}

impl MyDispatcher {
    pub fn dispatch(&mut self, _req: Request) -> Response {
        Response::text(self.status, Default::default(), &self.body)
    }
}

4. Create plugin.toml

[plugin]
name = "my-plugin"
version = "0.1.0"
type = "middleware"  # or "dispatcher"
category = "traffic-control"
description = "My custom plugin"
wasm = "my_plugin.wasm"

[capabilities]
host_functions = ["log"]

category is the plugin’s family. It groups the plugin in the middleware guide, and authentication additionally tells the compiler that the plugin verifies a client credential. Current values: authentication, authorization, caching, observability, traffic-control, transformation, ai-gateway for middleware; proxy, messaging, cloud, testing, ai-gateway for dispatchers.

5. Create config-schema.json

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "required": ["header_name", "header_value"],
  "properties": {
    "header_name": {
      "type": "string",
      "description": "Header name to add"
    },
    "header_value": {
      "type": "string",
      "description": "Header value to set"
    }
  }
}

Mark any field that carries a secret (API key, password, token, client secret) with "writeOnly": true — the standard JSON Schema keyword for sensitive values:

"api_key": {
  "type": "string",
  "writeOnly": true,
  "description": "Provider API key. Use a secret reference (env://VAR or file://...)."
}

barbacane compile then warns (E1070) — and the vacuum linter flags — when such a field is set to a plaintext literal instead of an env:// / file:// reference, so credentials are never baked into the compiled artifact. The detection is nested-aware (arrays and maps of objects). Do not mark non-secret fields (e.g. a message-key expression) writeOnly.

Declaring the request headers your plugin reads

A request carries only the headers its operation admits, and everything else is dropped before on_request runs. If your plugin reads a header and does not say so here, it will find nothing.

Mark the field whose value names a header with the format that matches how the value is written:

formatThe field holdsExample
header-namea header name, or a list of themapikey-auth.header_name, cache.vary
header-refa selector that may name a header among other thingsrate-limit.partition_key (header:x-tenant), kafka.key ($request.header.x-order-id)
header-name-mapan object whose keys are header namesrequest-transformer.headers.rename
"header_name": {
  "type": "string",
  "format": "header-name",
  "default": "X-API-Key",
  "description": "Header to read the key from"
}

A default is collected too, since it is the header the plugin reads when the configuration leaves the field out.

Names are matched case-insensitively. The x-auth-* namespace belongs to the auth plugins’ output, so naming one is a compile error (E1056).

A plugin that verifies a client credential reads its header from the spec instead. Set category = "authentication" in plugin.toml, and the compiler then requires every operation using the plugin to name the security scheme carrying the credential (E1057), which is what admits the header.

The SDK macros embed config-schema.json into the .wasm, as they already do plugin.toml, so these annotations reach the compiler wherever the binary travels and no sidecar file has to accompany it. Rebuild the plugin after editing either file, or the compiler reads the copy embedded by the previous build.

After changing config-schema.json, regenerate the vacuum ruleset validators (node docs/rulesets/generate.mjs) and run docs/rulesets/tests/run-tests.sh.

6. Build

cargo build --target wasm32-unknown-unknown --release
cp target/wasm32-unknown-unknown/release/my_plugin.wasm .

Plugin SDK Types

Request

pub struct Request {
    pub method: String,
    pub path: String,
    pub query: Option<String>,
    pub headers: BTreeMap<String, String>,
    pub body: Option<Vec<u8>>,      // binary-safe, travels via side-channel
    pub client_ip: String,
    pub path_params: BTreeMap<String, String>,
}

Helper methods: body_str() -> Option<&str>, body_string() -> Option<String>, set_body_text(&str).

Response

pub struct Response {
    pub status: u16,
    pub headers: BTreeMap<String, String>,
    pub body: Option<Vec<u8>>,      // binary-safe, travels via side-channel
}

Helper methods: body_str() -> Option<&str>, set_body_text(&str), Response::text(status, headers, &str).

Note: Bodies travel as raw bytes via side-channel host functions (host_body_read/host_body_set), not embedded in JSON. The proc macros handle this transparently — plugin authors just read and write request.body / response.body as Option<Vec<u8>>. This design (matching proxy-wasm and http-wasm) avoids the ~3.65× memory overhead of base64 encoding, allowing 10MB+ bodies within the default 16MB WASM memory limit.

Action (Middleware only)

pub enum Action {
    /// Continue to next middleware/dispatcher with (possibly modified) request
    Continue(Request),
    /// Short-circuit the chain and return this response immediately
    Respond(Response),
}

Host Functions

Plugins can call host functions to access gateway capabilities. Declare required capabilities in plugin.toml:

The SDK wraps the most common host functions so you don’t hand-roll the FFI: barbacane_plugin_sdk::log, ::http, ::context, ::ldap, ::errors::ProblemDetails, and ::jwt. Each has a native (non-wasm) stub so your plugin still compiles and unit-tests off-target (context keeps a thread-local map natively, so tests can set and read values). You still declare the underlying capability in plugin.toml.

Request context

[capabilities]
host_functions = ["context_get", "context_set"]
use barbacane_plugin_sdk::context;

// An auth plugin publishes the verified identity for the rest of the chain.
context::set(context::AUTH_SUB, "alice");
context::set(context::AUTH_GROUPS, "admin,editor");

// A later middleware reads it; unlike a header, a client cannot supply it.
let consumer = context::get(context::AUTH_SUB);

Logging

[capabilities]
host_functions = ["log"]
use barbacane_plugin_sdk::log;

log::info("Processing request");
log::warn("rate limit exceeded");
log::error("something went wrong");
// or an explicit level: log::log(log::LEVEL_DEBUG, "verbose detail");

HTTP Calls (Dispatcher only)

[capabilities]
host_functions = ["http_call"]
use barbacane_plugin_sdk::http;

let req = http::HttpRequest::new("GET", "https://api.example.com")
    .header("accept", "application/json")
    .timeout_ms(5000);

// The optional request body is passed separately (sent via the side-channel):
match http::call(&req, None) {
    Ok(resp) => {
        let _status = resp.status;
        let _body = resp.body_str(); // Option<&str>
    }
    Err(e) => { /* http::HttpError: Unreachable / Empty / ReadFailed / ... */ }
}

Error responses (RFC 9457 problem+json)

Build consistent application/problem+json error responses with the shared builder:

use barbacane_plugin_sdk::errors::ProblemDetails;

return Action::ShortCircuit(
    ProblemDetails::new(403, "urn:barbacane:error:forbidden", "Forbidden")
        .detail("Access denied")
        .with("consumer", "alice") // optional extension members
        .into_response(),
);

JWT parsing (auth plugins)

use barbacane_plugin_sdk::jwt;

if let Some(token) = jwt::bearer_token(auth_header) {
    // Decode claims for inspection — signature is verified separately
    // (host `verify_signature` capability), not by this helper.
    let claims: MyClaims = jwt::decode_claims_unverified(token)?;
    if claims.aud.contains("my-api") { /* jwt::Audience: string or array */ }
}

Clock, secrets (host imports)

These host functions do not have SDK wrappers — declare the capability and import the function directly. See any official plugin (e.g. oidc-auth for secrets, ldap-auth for the clock) for the exact extern "C" binding pattern.

[capabilities]
host_functions = ["clock_now", "get_secret"]

Secrets are resolved at gateway startup from env:// / file:// references, so get_secret returns the resolved value (never a plaintext literal baked into the artifact).

LDAP (host imports)

[capabilities]
host_functions = ["ldap"]

host_ldap_bind(req_ptr, req_len) -> i32 verifies a DN and password with a simple bind on a fresh connection; host_ldap_search(req_ptr, req_len) -> i32 runs a search on a pooled connection bound as the service account named in the request. Both take a JSON request carrying url, bind_dn, password, starttls, allow_plaintext and timeout_ms (search adds base_dn, scope, filter, attributes, size_limit). They return the result length, or -1 on an ABI error (bad pointer, unparseable request, no client); check for -1 before calling host_ldap_read_result(buf_ptr, buf_len) to read the JSON result. The result’s code field (invalid_credentials, connection_failed, timeout, plaintext_refused, …) tells a rejected credential apart from a directory failure. A password crosses a plaintext ldap:// connection only when allow_plaintext is set. Escape any user-supplied value per RFC 4515 before placing it in a filter. See ADR-0032 for the design.

Using Plugins in Specs

Declare in barbacane.yaml

Plugins can be sourced from a local path or a remote URL:

plugins:
  # Local path (development)
  my-plugin:
    path: ./plugins/my_plugin.wasm

  # Remote URL (production, CI/CD)
  jwt-auth:
    url: https://github.com/barbacane-dev/barbacane/releases/download/v0.5.2/jwt-auth.wasm
    sha256: abc123...  # optional integrity check

Remote plugins are downloaded at compile time and cached at ~/.barbacane/cache/plugins/. Use --no-cache to bypass the cache entirely (re-download without caching).

Use in OpenAPI spec

As middleware:

paths:
  /users:
    get:
      x-barbacane-middlewares:
        - name: my-plugin
          config:
            header_name: "X-Custom"
            header_value: "hello"

As dispatcher:

paths:
  /mock:
    get:
      x-barbacane-dispatch:
        name: my-plugin
        config:
          status: 200
          body: '{"message": "Hello"}'

Testing Plugins

Unit Testing

Test your plugin logic directly:

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

    #[test]
    fn test_adds_header() {
        let mut plugin = MyMiddleware {
            header_name: "X-Test".to_string(),
            header_value: "value".to_string(),
        };

        let req = Request {
            method: "GET".to_string(),
            path: "/test".to_string(),
            headers: Default::default(),
            ..Default::default()
        };

        let action = plugin.on_request(req);
        match action {
            Action::Continue(req) => {
                assert_eq!(req.headers.get("X-Test"), Some(&"value".to_string()));
            }
            _ => panic!("Expected Continue"),
        }
    }
}

Integration Testing

Use fixture specs with barbacane-test:

use barbacane_test::TestGateway;

#[tokio::test]
async fn test_my_plugin() {
    let gw = TestGateway::from_spec("tests/fixtures/my-plugin-test.yaml")
        .await
        .unwrap();

    let resp = gw.get("/test").await.unwrap();
    assert_eq!(resp.status(), 200);
    assert_eq!(resp.headers().get("X-Test"), Some("value"));
}

Official Plugins

Barbacane includes these official plugins in the plugins/ directory:

PluginTypeDescription
mockDispatcherReturn static responses
http-upstreamDispatcherReverse proxy to HTTP backends
lambdaDispatcherInvoke AWS Lambda functions
kafkaDispatcherPublish messages to Kafka
natsDispatcherPublish messages to NATS
s3DispatcherS3 / S3-compatible object storage proxy with SigV4 signing
jwt-authMiddlewareJWT token validation
apikey-authMiddlewareAPI key authentication
oauth2-authMiddlewareOAuth2 token introspection
ldap-authMiddlewareLDAP / Active Directory authentication (directory bind, group lookup)
rate-limitMiddlewareSliding window rate limiting
cacheMiddlewareResponse caching
corsMiddlewareCORS header management
correlation-idMiddlewareRequest correlation ID propagation
request-size-limitMiddlewareRequest body size limits
ip-restrictionMiddlewareIP allowlist/blocklist
bot-detectionMiddlewareBlock bots by User-Agent pattern
observabilityMiddlewareSLO monitoring and detailed logging

Use these as references for your own plugins.

Best Practices

  1. Keep plugins focused - One plugin, one responsibility
  2. Validate configuration - Use JSON Schema to catch config errors at compile time
  3. Handle errors gracefully - Return appropriate error responses, don’t panic
  4. Document capabilities - Only declare host functions you actually use
  5. Test thoroughly - Unit test logic, integration test with the gateway
  6. Use semantic versioning - Follow semver for plugin versions

Resource Limits

Plugins run in a sandboxed WASM environment with these limits:

ResourceLimit
Linear memorymax(16 MB, max_body_size + 4 MB)
Stack size1 MB
Execution time100 ms

WASM memory scales automatically based on the configured max_body_size. Exceeding these limits results in a trap (500 error for request phase, fault-tolerant for response phase).

Troubleshooting

Plugin not found

Ensure the plugin is declared in barbacane.yaml and the WASM file exists at the specified path.

Config validation failed

Check that your plugin’s configuration in the OpenAPI spec matches the JSON Schema in config-schema.json.

WASM trap

Your plugin exceeded resource limits or panicked. Check logs for details. Common causes:

  • Infinite loops
  • Large memory allocations
  • Unhandled errors causing panic

Unknown capability

You’re using a host function not declared in plugin.toml. Add it to capabilities.host_functions.

Distributing Plugins

GitHub Releases

The recommended way to distribute plugins is as GitHub release assets. Upload both the .wasm binary and plugin.toml alongside your release:

my-plugin.wasm
my-plugin.plugin.toml

Generate checksums for integrity verification:

sha256sum my-plugin.wasm > checksums.txt

Users reference your plugin by URL in their barbacane.yaml:

plugins:
  my-plugin:
    url: https://github.com/your-org/my-plugin/releases/download/v1.0.0/my-plugin.wasm
    sha256: <from checksums.txt>

Official Plugins

All official Barbacane plugins are published as release assets on every tagged release. Pre-built .wasm files and checksums (plugin-checksums.txt) are available at:

https://github.com/barbacane-dev/barbacane/releases/download/v<VERSION>/<plugin-name>.wasm

Plugin Metadata Discovery

When resolving a url: source, the compiler attempts to fetch plugin.toml from sibling URLs to extract version and type metadata:

  1. <name>.plugin.toml (same directory as the .wasm)
  2. plugin.toml (parent directory)

If neither is found, the plugin still works but without version/type metadata in the artifact manifest.