Octopus
1.x
Docs/Octopus/Writing a Plugin
Open

Reading7 min
Updated31 Jul 2026
Sourcev1/plugins/writing-plugins.mdx

Writing a Plugin

Plugins are authored against the octopus-plugin-api crate. A plugin is a Rust type that implements the core Plugin trait (identity + lifecycle) and, optionally, one or more capability traits that give it behaviour at a specific point in request handling.

Warning

The SDK and the lifecycle runtime are implemented and tested, but the gateway does not yet invoke plugin capability hooks during request handling (see the overview). This page documents the real, compilable API surface so your plugin is ready when execution wiring lands. For logic you can run on live traffic today, use middleware or scripting.

The core trait01

Start by implementing Plugin. Only name, version, init, start, and stop are required; the rest have defaults.

use octopus_plugin_api::prelude::*;

#[derive(Debug, Default)]
struct HeaderStamp {
    header_value: String,
}

#[async_trait]
impl Plugin for HeaderStamp {
    fn name(&self) -> &str { "header-stamp" }
    fn version(&self) -> &str { "0.1.0" }
    fn description(&self) -> &str { "Stamps an X-Gateway header onto every request" }

    async fn init(&mut self, config: serde_json::Value) -> Result<(), PluginError> {
        // `config` is the JSON map from the plugin's `config:` block.
        self.header_value = config
            .get("value")
            .and_then(|v| v.as_str())
            .unwrap_or("octopus")
            .to_string();
        Ok(())
    }

    async fn start(&mut self) -> Result<(), PluginError> { Ok(()) }
    async fn stop(&mut self) -> Result<(), PluginError> { Ok(()) }
}

Errors

Return PluginError from any hook. Constructors map to typed variants:

ConstructorVariantUse for
PluginError::init(msg)InitErrorFailures during init.
PluginError::config(msg)ConfigErrorInvalid/missing configuration.
PluginError::runtime(msg)RuntimeErrorGeneral runtime failures.
PluginError::dependency(name)DependencyMissingA required dependency is absent.
PluginError::invalid_state(msg)InvalidStateOperation called in the wrong lifecycle state.
PluginError::auth(msg)AuthErrorAuthentication failures.
PluginError::authz(msg)AuthzErrorAuthorization failures.
PluginError::transform(msg)TransformErrorTransformation failures.
PluginError::protocol(msg)ProtocolErrorProtocol-handler failures.

std::io::Error and serde_json::Error convert into PluginError automatically (via #[from]), so ? works on them.

Adding a capability02

The core trait gives you a managed lifecycle but no behaviour. To do something, implement a capability trait. Each one extends Plugin, so your type implements both.

Capability traitHook(s)What it does
RequestInterceptorintercept_requestInspect/modify the request before routing; may short-circuit.
ResponseInterceptorintercept_responseInspect/modify the response before it returns.
AuthProviderauthenticate, validate, extract_credentialsCustom authentication schemes.
TransformPlugintransform_request, transform_responseConfig-driven header/path/body transforms.
ProtocolHandlerprotocol, supports, handle, upgradeHandle non-HTTP protocols (gRPC, GraphQL, WebSocket).
ScriptInterceptorPluginexecute_on_request, execute_on_responseWrap a scripting engine as a plugin capability.

Request interceptor example

RequestInterceptor receives a mutable http::Request<Body> (where Body = Full<Bytes>) and a shared RequestContext. It returns an InterceptorAction.

use octopus_plugin_api::prelude::*;
use octopus_plugin_api::interceptor::Body;
use http::{Request, Response, StatusCode};

#[async_trait]
impl RequestInterceptor for HeaderStamp {
    async fn intercept_request(
        &self,
        req: &mut Request<Body>,
        ctx: &RequestContext,
    ) -> Result<InterceptorAction, PluginError> {
        // Read context: request_id, remote_addr, route, upstream, metadata, elapsed().
        let _ = ctx.request_id.as_str();

        // Modify the request in place.
        req.headers_mut().insert(
            "x-gateway",
            self.header_value.parse().map_err(PluginError::runtime)?,
        );

        // Short-circuit: block requests missing an API key.
        if !req.headers().contains_key("x-api-key") {
            let res = Response::builder()
                .status(StatusCode::UNAUTHORIZED)
                .body(Body::default())
                .map_err(PluginError::runtime)?;
            return Ok(InterceptorAction::Return(res));
        }

        // Otherwise hand off to the next interceptor / the router.
        Ok(InterceptorAction::Continue)
    }
}

Continue, short-circuit, or abort

intercept_request / intercept_response decide control flow by returning one of:

  • InterceptorAction::Continue — proceed to the next interceptor or the router/handler.

  • InterceptorAction::Return(Response<Body>) — stop here and return this response immediately (short-circuit). Useful for auth rejections, cached responses, redirects.

  • InterceptorAction::Abort(PluginError) — abort the request with an error.

InterceptorAction provides is_continue(), is_return(), is_abort(), and into_result() -> Result<Option<Response<Body>>> (where Abort becomes the Err).

Note

ResponseInterceptor::intercept_response uses the same InterceptorAction type, but Return on the response side is for replacing the response, not short-circuiting routing (the upstream has already run).

The request and response context03

Interceptors receive read-mostly context structs (the request/response bodies are mutated through the http::Request/http::Response, not the context):

  • RequestContextrequest_id: String, remote_addr: SocketAddr, start_time: Instant, route: Option<String>, upstream: Option<String>, metadata: HashMap<String, Value>. Helpers: elapsed(), set_metadata / get_metadata.

  • ResponseContextrequest_id, duration: Duration, status_code: u16, upstream: Option<String>, response_size: Option<usize>, metadata. Helpers: is_success(), is_client_error(), is_server_error().

Declaring dependencies04

If your plugin needs another plugin to be present, declare it. The registry validates required dependencies at registration time, detects cycles, and starts dependencies before your plugin.

fn dependencies(&self) -> Vec<PluginDependency> {
    vec![
        PluginDependency::required("auth", ">=1.0.0"),
        PluginDependency::optional("metrics", "*"),
    ]
}

Version requirements are semver (semver::VersionReq); a missing required dependency fails registration with DependencyMissing.

Packaging: static vs dynamic05

rust
use octopus_plugin_runtime::PluginManager;

let manager = PluginManager::new();
manager
    .register_and_init(
        "header-stamp",
        Box::new(HeaderStamp::default()),
        serde_json::json!({ "value": "octopus" }),
    )
    .await?;
manager.start("header-stamp").await?;
1

Add the dependency. Depend on octopus-plugin-api and use the prelude (it re-exports the core trait, capability traits, context, error, and async_trait).

Implement Plugin. Provide name/version and the three lifecycle hooks. Parse your config: serde_json::Value in init.

Add a capability trait (e.g. RequestInterceptor) and return Continue / Return / Abort from the hook.

Declare dependencies if needed, then register and start the plugin through PluginManager (static) or package it as a shared library with octopus_declare_plugin! (dynamic).