plexus-macros 0.5.4

Procedural macros for Plexus RPC activations
Documentation

plexus-macros

Procedural macros for Plexus RPC activations. The macros are the source of truth for the client generation pipeline: your Rust function signature is the schema.


Overview: macros → schema → clients

#[plexus_macros::activation] Rust macros (this crate)
  ↓ schemars::schema_for!() + MethodRole / DeprecationInfo metadata
PluginSchema (serde JSON, pinned in plexus-core)
  ↓ Synapse (Haskell) fetches over Plexus RPC
Synapse IR (deduplicated, compiler-ready)
  ↓ plexus-codegen-{rust,typescript,python}
Client libraries (type-safe, documented, deprecation-aware)

Everything downstream — generated clients, OpenAPI docs, deprecation warnings in TypeScript IDEs — is derived from what #[plexus_macros::activation] emits into PluginSchema. Do not hand-write schemas; the macro owns them.


Comprehensive example

A single realistic activation that exercises every macro in the crate. Every attribute shown is currently supported on 0.5.x. Features that are less common are called out inline.

use async_stream::stream;
use futures::Stream;
use plexus_macros::{HandleEnum, PlexusRequest};
use plexus_core::plexus::bidirectional::StandardBidirChannel;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::sync::Arc;

// -------------------------------------------------------------------
// 1. Stream item type — a plain domain enum.
//    The caller-wraps streaming architecture means you do NOT need a
//    `StreamEvent` derive. Standard serde + JsonSchema is enough.
// -------------------------------------------------------------------
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum CounterEvent {
    /// Emitted on every tick.
    Tick { value: i64 },
    /// Emitted once when the counter is reset.
    Reset,
}

// -------------------------------------------------------------------
// 2. Typed handles — generated by `#[derive(HandleEnum)]`.
//    Handles are opaque strings on the wire; this derive makes them
//    typed on the Rust side. `plugin_id_type` pins the instantiation
//    when the owning activation is generic (IR-21).
// -------------------------------------------------------------------
#[derive(Debug, Clone, HandleEnum)]
#[handle(
    plugin_id = "Counter::PLUGIN_ID",
    // Optional — only needed if the activation is generic.
    // Pairs with `plugin_id` to emit `<Counter>::PLUGIN_ID`.
    plugin_id_type = "Counter",
    version = "1.0.0"
)]
pub enum CounterHandle {
    /// A single counter item.
    #[handle(method = "item", table = "items", key = "id")]
    Item {
        /// Stored item id.
        item_id: String,
    },
}

// -------------------------------------------------------------------
// 3. Typed request — generated by `#[derive(PlexusRequest)]`.
//    This struct describes the per-request HTTP context: cookies,
//    headers, query params, peer address, auth context. The
//    activation-level `request = CounterRequest` attr below wires it
//    into the dispatch path; fields become injectable via
//    `#[activation_param]` on method parameters.
// -------------------------------------------------------------------
#[derive(PlexusRequest)]
pub struct CounterRequest {
    /// Session cookie — required. Missing cookie → 401.
    #[from_cookie("access_token")]
    auth_token: String,

    /// Optional origin header. Absent → None.
    #[from_header("origin")]
    origin: Option<String>,

    /// Optional tenant query param: `?tenant=acme`.
    #[from_query("tenant")]
    tenant: Option<String>,

    /// Socket peer address, filled by the transport.
    #[from_peer]
    peer: Option<std::net::SocketAddr>,

    /// Full auth context (set by middleware).
    #[from_auth_context]
    auth: Option<plexus_core::plexus::AuthContext>,
}

// -------------------------------------------------------------------
// 4. The activation itself.
// -------------------------------------------------------------------
#[derive(Clone)]
pub struct Counter {
    // ... state, storage handles, etc.
}

impl Counter {
    pub fn new() -> Self { Self {} }
    fn lookup_item(&self, _id: &str) -> Option<Item> { None }
}

#[derive(Clone)]
pub struct Item; // stand-in for the child activation

#[plexus_macros::activation(
    namespace = "counter",
    version = "1.0.0",
    description = "Counter activation — demonstrates every plexus-macros feature",
    // Wire the typed request struct into dispatch. Every method gets
    // its fields available via `#[activation_param]` (see `whoami` below).
    request = CounterRequest,
)]
impl Counter {
    // -- A. Regular streaming method (the common case) --
    /// Tick the counter and stream `CounterEvent`s.
    #[plexus_macros::method(streaming)]
    async fn tick(&self, n: u32) -> impl Stream<Item = CounterEvent> + Send + 'static {
        stream! {
            for i in 0..n {
                yield CounterEvent::Tick { value: i as i64 };
            }
        }
    }

    // -- B. Parameter descriptions via `params(...)` --
    /// Add two numbers. `params(...)` documents each parameter.
    #[plexus_macros::method(
        params(
            a = "Left operand",
            b = "Right operand",
        )
    )]
    async fn add(&self, a: i64, b: i64) -> impl Stream<Item = i64> + Send + 'static {
        stream! { yield a + b; }
    }

    // -- C. Deprecation with precise `removed_in` hint --
    //
    // Rust's `#[deprecated]` only knows about `since` and `note`;
    // the companion `#[plexus_macros::removed_in]` attribute supplies
    // the removal-version field on the emitted `DeprecationInfo`.
    /// Legacy method — retained for backward compatibility.
    #[deprecated(since = "0.5.0", note = "use `tick` instead")]
    #[plexus_macros::removed_in("0.6")]
    #[plexus_macros::method]
    async fn legacy(&self) -> impl Stream<Item = i64> + Send + 'static {
        stream! { yield 0; }
    }

    // -- D. HTTP method hint for REST clients --
    /// Read-only, safe to cache — mark it GET for generated HTTP clients.
    #[plexus_macros::method(http_method = "GET")]
    async fn status(&self) -> impl Stream<Item = String> + Send + 'static {
        stream! { yield "ok".into(); }
    }

    // -- E. Bidirectional channel (streaming by convention) --
    //
    // The `ctx: &Arc<StandardBidirChannel>` argument is recognised by
    // the macro and not exposed as a param in the schema. For custom
    // request/response types, use
    // `bidirectional(request = "MyReq", response = "MyResp")`.
    /// Interactive prompt over a bidirectional channel.
    #[plexus_macros::method(bidirectional, streaming)]
    async fn prompt(&self, ctx: &Arc<StandardBidirChannel>)
        -> impl Stream<Item = String> + Send + 'static
    {
        let _ = ctx;
        stream! { yield "prompted".into(); }
    }

    // -- F. Request-struct field injection via `#[activation_param]` --
    //
    // The named parameter must match a field name on `CounterRequest`
    // by name AND type. The dispatch wrapper extracts the request
    // struct once per call and threads matching fields into the method.
    /// Reveal who the caller is (reads the `CounterRequest` fields).
    #[plexus_macros::method]
    async fn whoami(
        &self,
        #[activation_param] auth_token: String,
        #[activation_param] origin: Option<String>,
    ) -> impl Stream<Item = String> + Send + 'static {
        stream! {
            yield format!("token={} origin={:?}", auth_token, origin);
        }
    }

    // -- G. Per-method escape from activation-level request extraction --
    //
    // `request = ()` makes the dispatch skip `CounterRequest::extract()`
    // for this method. Use this for truly public endpoints like health
    // checks.
    /// Public health probe; no auth required.
    #[plexus_macros::method(request = ())]
    async fn health(&self) -> impl Stream<Item = String> + Send + 'static {
        stream! { yield "ok".into(); }
    }

    // -- H. Static child (zero-arg) --
    //
    // `#[child]` methods are NOT RPC methods; they contribute to the
    // generated `ChildRouter` implementation. A zero-arg method is a
    // named static child routed by the method identifier.
    /// Global stats — reachable as `counter.stats.<method>`.
    #[plexus_macros::child]
    fn stats(&self) -> Stats { Stats {} }

    // -- I. Dynamic child with list + search capabilities --
    //
    // `fn NAME(&self, name: &str) -> Option<Child>` is a dynamic
    // fallback dispatcher. `list = "..."` and `search = "..."` name
    // sibling methods that stream child names for completion and
    // search — the generated `ChildRouter` advertises the
    // corresponding `ChildCapabilities` bits.
    /// Look up an item child (`counter.item <id>.<method>`).
    #[plexus_macros::child(list = "item_ids", search = "find_item")]
    async fn item(&self, id: &str) -> Option<Item> {
        self.lookup_item(id)
    }

    /// Stream every addressable item id.
    async fn item_ids(&self) -> impl Stream<Item = String> + Send + '_ {
        futures::stream::iter(vec!["a".into(), "b".into()])
    }

    /// Stream item ids matching `query`.
    async fn find_item(&self, query: &str) -> impl Stream<Item = String> + Send + '_ {
        let q = query.to_string();
        futures::stream::iter(vec![q])
    }
}

#[derive(Clone)]
pub struct Stats;

The example above is the full feature surface of plexus-macros 0.5.x. Real activations typically use a subset — see plexus-substrate for concrete, minimally-documented usage (echo and bash are the smallest; cone, claudecode, and solar exercise children, handles, and generics).


Request forwarding: PlexusRequest + request = ...

A typed view of an inbound HTTP request. Without it, your method sees only the RPC arguments; with it, you get cookies / headers / query params / peer address / auth context as first-class Rust fields — extracted, validated, and type-checked at dispatch time.

What it is

plexus_core::request::PlexusRequest is a trait implemented by #[derive(PlexusRequest)]. It has two methods:

pub trait PlexusRequest: Sized {
    fn extract(ctx: &RawRequestContext) -> Result<Self, PlexusError>;
    fn request_schema() -> Option<serde_json::Value> { None }
}

RawRequestContext is the raw inputs — http::HeaderMap, Uri, optional AuthContext, optional SocketAddr. The derive generates both extract (run per-call by the dispatch code) and request_schema (included in the activation's PluginSchema with x-plexus-source hints).

Why it exists

Auth tokens live in cookies. CSRF checks need the Origin header. Tenancy lives in query strings. Per-peer rate limits need the socket address. Before PlexusRequest, every activation had to hand-parse this out of a RawRequestContext — boilerplate, inconsistent errors, no schema. With the derive:

  • Typed extraction: one #[from_cookie("...")] replaces several lines of header parsing.
  • Consistent errors: missing required fields return PlexusError::Unauthenticated.
  • Schema metadata: generated clients know which inputs come from cookies vs. body.
  • Composition: the same struct is reused across every method of the activation.

Field attributes

Attribute Source Required? Notes
#[from_cookie("name")] Named Cookie header value Non-Option → required (401 if missing) Multi-value cookie headers are parsed correctly.
#[from_header("name")] Named HTTP header Non-Option → required Case-insensitive per http crate.
#[from_query("name")] Named URI query parameter Non-Option → required Uses form_urlencoded::parse.
#[from_peer] ctx.peer (socket addr) Option<SocketAddr> recommended Transports without peer addr → None.
#[from_auth_context] ctx.auth (AuthContext) Option<AuthContext> recommended Populated by auth middleware.
(no attribute) PlexusRequestField::extract_from_raw Defined by the field type Use for newtypes like ValidOrigin.

Option<T> fields treat "absent" as None and never fail; non-Option fields return PlexusError::Unauthenticated on missing input. Parse failures (e.g. a cookie value that cannot parse into the declared type) also return Unauthenticated.

Wiring it into an activation

The activation attribute takes a request = <Type> argument:

#[plexus_macros::activation(
    namespace = "my_ns",
    version = "1.0.0",
    request = MyRequest,
)]
impl MyActivation { /* ... */ }

Two ways to consume the extracted fields in methods:

  1. #[activation_param] — the named parameter is pulled from the request struct by field name. The Rust type must match the declared field type (the macro enforces this):

    #[plexus_macros::method]
    async fn whoami(&self, #[activation_param] auth_token: String)
        -> impl Stream<Item = String> + Send + 'static { /* ... */ }
    
  2. #[from_auth(expr)] — runs an arbitrary resolver expression against the extracted auth context. Useful when you want a validated user type, not raw auth:

    #[plexus_macros::method]
    async fn admin(
        &self,
        #[from_auth(self.db.validate_admin)] admin: AdminUser,
    ) -> impl Stream<Item = String> + Send + 'static { /* ... */ }
    

To opt a method out of activation-level extraction (e.g. a public health probe), use request = ():

#[plexus_macros::method(request = ())]
async fn health(&self) -> impl Stream<Item = String> + Send + 'static { /* ... */ }

Wire-level: x-plexus-source

The derive augments the generated JSON Schema with a non-standard x-plexus-source key on each property, so generated clients know which input channel to use:

{
  "type": "object",
  "properties": {
    "auth_token": {
      "type": "string",
      "x-plexus-source": { "from": "cookie", "key": "access_token" }
    },
    "origin": {
      "anyOf": [{ "type": "string" }, { "type": "null" }],
      "x-plexus-source": { "from": "header", "key": "origin" }
    },
    "tenant": {
      "anyOf": [{ "type": "string" }, { "type": "null" }],
      "x-plexus-source": { "from": "query", "key": "tenant" }
    },
    "peer": {
      "x-plexus-source": { "from": "derived" }
    }
  },
  "required": ["auth_token"]
}

End-to-end example

Given this activation:

#[derive(PlexusRequest)]
struct AppRequest {
    #[from_cookie("access_token")] token: String,
    #[from_header("origin")]       origin: Option<String>,
}

#[plexus_macros::activation(namespace = "app", version = "1.0.0", request = AppRequest)]
impl App {
    #[plexus_macros::method]
    async fn me(&self, #[activation_param] token: String)
        -> impl Stream<Item = String> + Send + 'static
    {
        stream! { yield format!("token={}", token); }
    }
}

A request:

POST /rpc HTTP/1.1
Cookie: access_token=eyJhbGci...
Origin: https://app.example.com
Content-Type: application/json

{ "method": "app.me", "params": {} }

is dispatched as:

  1. Transport builds a RawRequestContext from headers + URI.
  2. Dispatch calls AppRequest::extract(&raw)AppRequest { token: "eyJhbGci…", origin: Some("https://app.example.com") }.
  3. The token field is threaded into me as its token parameter.
  4. me runs; streamed output is serialised back over the transport.

When to use PlexusRequest vs. method params

Rule of thumb:

  • Method params — per-call inputs that belong to the business logic: item_id, query, body.
  • PlexusRequest + #[activation_param] — per-request context that crosses every method: auth tokens, session cookies, tenancy, peer addr.

If a value would be the same for every method call in a request, it belongs in the request struct.


Per-macro reference

#[plexus_macros::activation(...)]

Attached to an impl block to generate the activation's Activation trait impl, method enum, RPC trait, and (when #[child] methods are present) ChildRouter impl.

Arg Type Default Notes
namespace = "..." string (required) Activation namespace. Appears as plugin_schema.namespace.
version = "..." string CARGO_PKG_VERSION Semver; used by handles and deprecation schemas.
description = "..." string (none) Max 15 words — macro errors on overflow. Use long_description for more.
long_description = "..." string (none) Unbounded documentation; projected into PluginSchema.
request = MyType type (none) Typed request struct; see the Request forwarding section.
resolve_handle flag (off) Generates Activation::resolve_handle that delegates to self.resolve_handle_impl(handle). You implement the impl.
crate_path = "..." string auto (via proc-macro-crate) Override the plexus-core import path. Rarely needed after CHILD-6.
plugin_id = "UUID" string deterministic UUIDv5 from namespace Explicit plugin UUID. Must parse as a UUID.
namespace_fn = "method" string (none) Advanced: generate namespace() as a method call instead of a constant.
hub flag (off, deprecated) Deprecated — hub mode is inferred from #[child] methods. Removed in 0.6.
children = [ident, ...] ident list (empty, deprecated) Deprecated — use #[child] methods. Removed in 0.6.

#[plexus_macros::method(...)]

Attached to methods inside an #[activation] impl. Marks the method as an RPC endpoint and collects schema metadata.

Arg Type Default Notes
description = "..." string first /// doc-comment block (CHILD-5) Explicit arg wins over doc comments.
name = "..." string method identifier Override the RPC name without renaming the Rust fn.
streaming flag (off) Advertises the method as AsyncGenerator-shaped in generated clients.
bidirectional flag (off) Method uses StandardBidirChannel. Use bidirectional(request = "...", response = "...") for custom types.
http_method = "..." string (none) One of GET|POST|PUT|DELETE|PATCH. Hint for REST generators.
params(name = "doc", ...) key/value list (empty) Per-parameter descriptions. Less common now that field-level /// extraction works; retained for cases where parameters have no natural Rust doc site.
returns(Variant, ...) ident list (all variants) Filter the return enum to named variants. Rare.
request = () tuple (inherits) Skip activation-level request extraction for this method (public endpoint escape hatch).
request = OtherType type (inherits) Reserved — per-method request type override is not yet wired end-to-end.

Method parameter attributes (applied inside the fn signature):

  • #[activation_param] — inject a field from the activation-level request struct; name + type must match.
  • #[from_auth(expr)] — call expr(&auth_context) and bind the result; useful for validated user types.

#[plexus_macros::child] / #[plexus_macros::child(list = "...", search = "...")]

Attached to methods inside an #[activation] impl to register them in the generated ChildRouter. Two accepted shapes:

Shape Role Notes
fn NAME(&self) -> Child Static child Routing name is the method identifier. Sync or async.
fn NAME(&self, name: &str) -> Option<Child> Dynamic fallback Called for any name not matched by a static child. Sync or async.

Optional arguments on dynamic #[child]:

Arg Type Advertises capability Sibling method shape
list = "method" ident ChildCapabilities::LIST (async) fn METHOD(&self) -> impl Stream<Item = String> (or BoxStream<'_, String>)
search = "method" ident ChildCapabilities::SEARCH (async) fn METHOD(&self, query: &str) -> impl Stream<Item = String>

Sibling methods are validated at macro-expansion time — typos in list = "..." or a wrong signature become compile errors pointing at the attribute.

#[child] methods do not appear as RPC endpoints in the schema; they contribute to routing only. The presence of any #[child] method flips the activation into hub mode (replacing the deprecated hub flag).

#[plexus_macros::removed_in("X")]

Companion to Rust's built-in #[deprecated]. Carries the removal-version field that rustc's #[deprecated] doesn't accept.

#[deprecated(since = "0.5", note = "use `tick` instead")]
#[plexus_macros::removed_in("0.6")]
#[plexus_macros::method]
async fn legacy(&self) -> impl Stream<Item = i64> + Send + 'static { /* ... */ }

The macro writes this into MethodSchema.deprecation as DeprecationInfo { since, removed_in, message }. Without the companion attribute, removed_in defaults to the string "unspecified".

Applying #[plexus_macros::removed_in] to a method without #[deprecated] is a compile error.

#[derive(PlexusRequest)]

See Request forwarding for the full treatment. Generates:

  • impl PlexusRequest for YourStruct (extraction + schema).
  • impl schemars::JsonSchema for YourStruct using plexus_core::__schemars (no direct schemars dependency required from the caller).

Supported field attributes:

#[from_cookie("name")]  #[from_header("name")]  #[from_query("name")]
#[from_peer]            #[from_auth_context]

Fields without any source attribute must implement PlexusRequestField — used for newtype wrappers that carry their own validation.

#[derive(HandleEnum)]

Generates to_handle(), impl TryFrom<&Handle>, and impl From<_> for Handle for an enum that represents the set of typed handles an activation issues.

Enum-level #[handle(...)] arguments:

Arg Required? Notes
plugin_id = "CONST_OR_PATH" yes Path to the activation's plugin UUID constant — e.g. "Counter::PLUGIN_ID" or "MY_PLUGIN_ID".
version = "X.Y.Z" yes Semver for handles emitted by this enum.
plugin_id_type = "Type" no Concrete instantiation to qualify plugin_id when the owning activation is generic (e.g. "Cone<NoParent>"). Fixes E0283 ambiguity.
crate_path = "..." no ("plexus_core") Override import path.

Variant-level #[handle(...)] arguments:

Arg Required? Notes
method = "..." yes The handle.method value on the wire.
table = "..." no SQLite table name (consumed by future handle-resolution tooling).
key = "..." no Primary key column.
key_field = "..." no Which variant field holds the key. Defaults to the first field.
strip_prefix = "..." no Strip this prefix before looking up by key.

#[derive(StreamEvent)]deprecated

Kept for backward compatibility. The caller-wraps streaming architecture means stream-item enums should just use Serialize + Deserialize + JsonSchema (standard derives). Do not reach for StreamEvent in new code.

#[derive(JsonSchema)] (compat shim)

Only available when plexus-macros is compiled with the schemars-compat feature and aliased as schemars in a crate's dev-dependencies. Produces a no-op impl JsonSchema, preventing a duplicate-impl conflict with the impl JsonSchema generated by #[derive(PlexusRequest)] when test code writes #[derive(PlexusRequest, schemars::JsonSchema)].

Library crates almost never need this directly; the companion crate plexus-schemars-compat is the public entry point.


Migration from deprecated surfaces

All deprecations in plexus-macros 0.5 continue to compile with warnings through 0.5.x and are removed in 0.6.

#[hub_methods]#[plexus_macros::activation]

Before:

#[hub_methods(namespace = "bash", version = "1.0.0")]
impl Bash { /* ... */ }

After:

#[plexus_macros::activation(namespace = "bash", version = "1.0.0")]
impl Bash { /* ... */ }

Identical semantics. #[hub_methods] emits a deprecation warning pointing at the replacement.

#[hub_method]#[plexus_macros::method]

Before:

#[hub_method]
async fn execute(&self, command: String) -> impl Stream<Item = BashEvent> { /* ... */ }

After:

#[plexus_macros::method]
async fn execute(&self, command: String) -> impl Stream<Item = BashEvent> { /* ... */ }

hub = true / children = [...]#[plexus_macros::child] methods

Before:

#[plexus_macros::activation(namespace = "solar", version = "1.0.0", hub, children = [sun, earth])]
impl Solar { /* ... */ }

After:

#[plexus_macros::activation(namespace = "solar", version = "1.0.0")]
impl Solar {
    #[plexus_macros::child]
    fn sun(&self) -> Sun { /* ... */ }

    #[plexus_macros::child]
    fn earth(&self) -> Earth { /* ... */ }
}

Hub mode is now inferred from the presence of #[child] methods (CHILD-8). The hub flag and children = [...] list emit deprecation warnings and are scheduled for removal in 0.6.


Output pipeline

The macro output feeds the same pipeline today as it did in 0.4, but with richer metadata:

#[plexus_macros::activation]  (this crate)
  ↓ emits
PluginSchema { methods: [MethodSchema { role, deprecation, params, ... }] }
  ↓ served over Plexus RPC (plexus-transport)
Synapse (Haskell) fetches, deduplicates, builds Synapse IR
  ↓ feeds
plexus-codegen-{rust,typescript,python}
  ↓ produces
Generated clients with typed methods, streaming iterators, and
deprecation warnings that mirror your `#[deprecated]` annotations

See the substrate reference server (plexus-substrate) for end-to-end examples of every feature in production use:

  • bash, echo — minimal single-method activations.
  • cone, claudecode — generic activations with HandleEnum, dynamic children, and resolve_handle.
  • solar — hub activation with dynamic child routing + list capability.
  • interactive — bidirectional channels.
  • changelog, mustache — multi-method flat activations.

License

MIT