alux-jsonrpc 0.2.0

Composable, interpreter-independent JSON-RPC programs for meaning-first Rust design
Documentation

alux-jsonrpc

alux-jsonrpc describes typed JSON-RPC method programs independently of an RPC framework.

The crate is a specification. It carries no interpreter and depends only on alux-ext: a JSON-RPC surface is described here as first-order syntax plus the capability algebras an interpreter must witness. Domain specifications provide their own capability traits and compose defunctionalized operations.

use alux_ext::{OperationAlg, ext};
use alux_jsonrpc::{JsonRpcApiAlg, JsonRpcProgramBuilder, jsonrpc};
use core::future::Future;

/// A downstream specification owns its primitive domain meaning.
trait StatusAlg {
    type Status;

    fn status(&self) -> impl Future<Output = Self::Status> + Send;
    fn status_set_temp(&self, temp: f32) -> impl Future<Output = Self::Status> + Send;
}

/// Derived operations become first-order values that preserve their argument names.
#[ext(name = StatusOperationExt, defunc)]
impl<This> This
where
    This: StatusAlg,
{
    async fn status_current(&self) -> This::Status {
        self.status().await
    }

    async fn status_adjusted(&self, temp: f32) -> This::Status {
        self.status_set_temp(temp).await
    }
}

/// The method program is declared before any framework is chosen.
#[ext(name = StatusRpcExt, defunc(via = jsonrpc))]
impl<This> This
where
    This: JsonRpcApiAlg,
{
    /// Declares the status surface: a reading and an adjustment.
    fn status_rpc<Alg>(&self)
    where
        Alg: StatusAlg,
    {
        self.methods()
            // The reading as it stands, taking no parameters.
            .method("status_current", self.op(Alg::status_current))
            // An adjustment, decoded from a JSON object using the authored argument names.
            .method("status_set_temp", self.op(Alg::status_adjusted).named())
    }
}

struct App;

impl StatusAlg for App {
    type Status = u32;

    async fn status(&self) -> u32 {
        1
    }

    async fn status_set_temp(&self, temp: f32) -> u32 {
        temp as u32
    }
}

// The same program is constructible directly, without the convenience macro and without an
// interpreter, because a method program is an ordinary value.
let builder = JsonRpcProgramBuilder;
let program = builder
    .methods()
    // The same method, declared without the convenience macro.
    .method("status_current", builder.op(StatusCurrentOperation::<App>::default()))
    .into_program();
let _merged = builder.methods().merge(builder.program(program)).into_program();

// Named decoding means these argument names, carried from the authored method.
assert_eq!(<StatusAdjustedOperation<App> as OperationAlg>::ARG_NAMES, ["temp"]);

Composing surfaces

The reason to keep a surface first-order is that programs compose before anything interprets them. Two crates that know nothing about each other can each declare part of a service, and a third can state the whole of it — no shared method table, no registry, no framework in the picture yet.

use alux_ext::ext;
use alux_jsonrpc::{JsonRpcApiAlg, RpcErrorAlg, jsonrpc};
use core::future::Future;

trait StatusAlg {
    type Status;

    fn status(&self) -> impl Future<Output = Self::Status> + Send;
    fn status_set_temp(&self, temp: f32) -> impl Future<Output = Self::Status> + Send;
}

trait ItemsAlg {
    type Items;

    fn items(&self) -> impl Future<Output = Self::Items> + Send;
}

/// The one reason this domain fails: it keeps no history.
struct NoHistory;

/// What that failure denotes on an RPC surface. The domain says it once, naming no interpreter.
impl RpcErrorAlg for NoHistory {
    fn rpc_code(&self) -> i32 {
        -32000
    }

    fn rpc_message(&self) -> String {
        "the domain keeps no history".to_owned()
    }
}

#[ext(name = StatusOperationExt, defunc)]
impl<This> This
where
    This: StatusAlg,
{
    /// Returns the status as it stands.
    async fn status_current(&self) -> This::Status {
        self.status().await
    }

    /// Returns the status after adjusting the temperature.
    async fn status_adjusted(&self, temp: f32) -> This::Status {
        self.status_set_temp(temp).await
    }

    /// Returns the reading this domain does not keep, stating its own failure.
    async fn status_history(&self) -> Result<This::Status, NoHistory> {
        Err(NoHistory)
    }

    /// Returns the reading from a moment the domain no longer holds.
    async fn status_at(&self, moment: u32) -> Result<This::Status, NoHistory> {
        let _ = moment;

        Err(NoHistory)
    }
}

#[ext(name = ItemsOperationExt, defunc)]
impl<This> This
where
    This: ItemsAlg,
{
    /// Returns every item the domain holds.
    async fn items_current(&self) -> This::Items {
        self.items().await
    }
}

/// One surface fragment, mixing methods that answer with a value and one that answers with a
/// protocol error.
#[ext(name = StatusRpcExt, defunc(via = jsonrpc))]
impl<This> This
where
    This: JsonRpcApiAlg,
{
    /// Declares the status methods.
    fn status_rpc<Alg>(&self)
    where
        Alg: StatusAlg,
    {
        self.methods()
            // The reading as it stands, taking no parameters.
            .method("status_current", self.op(Alg::status_current))
            // An adjustment, decoded from a JSON array.
            .method("status_set_temp", self.op(Alg::status_adjusted).positional())
            // The same operation, decoded from a JSON object using the authored argument names.
            .method("status_set_temp_named", self.op(Alg::status_adjusted).named())
            // `.fallible()` converts the operation's error into a JSON-RPC protocol error. This is
            // the method-level marker; `history_rpc` below states the same once on the ext.
            .method("status_at", self.op(Alg::status_at).fallible())
    }
}

/// Another fragment, declared independently — plausibly in another crate.
#[ext(name = ItemsRpcExt, defunc(via = jsonrpc))]
impl<This> This
where
    This: JsonRpcApiAlg,
{
    /// Declares the item method.
    fn items_rpc<Alg>(&self)
    where
        Alg: ItemsAlg,
    {
        // Every item the domain holds.
        self.methods().method("items_current", self.op(Alg::items_current))
    }
}

/// A fragment whose errors all answer as protocol errors, marked once on the ext instead of per
/// method.
#[ext(name = HistoryRpcExt, defunc(via = jsonrpc), fallible)]
impl<This> This
where
    This: JsonRpcApiAlg,
{
    /// Declares the history method, whose readings answer as JSON-RPC errors.
    fn history_rpc<Alg>(&self)
    where
        Alg: StatusAlg,
    {
        // The ext-level `fallible` marker states it for every method here, so this one stays silent.
        self.methods().method("status_history", self.op(Alg::status_history))
    }
}

/// The whole service, as the union of every fragment.
#[ext(name = ServiceRpcExt, defunc(via = jsonrpc))]
impl<This> This
where
    This: JsonRpcApiAlg,
{
    /// Declares every method the fragments contribute.
    fn service_rpc<Alg>(&self)
    where
        Alg: StatusAlg + ItemsAlg,
    {
        self.methods()
            .merge(self.status_rpc::<Alg>())
            .merge(self.history_rpc::<Alg>())
            .merge(self.items_rpc::<Alg>())
    }
}

Two things there are worth pausing on, because the authored text is not what runs:

  • The declarations look like they return nothing. They do return something. The macro replaces the written signature with -> ServiceRpcProgram<Alg> and the written body with ServiceRpcProgram::default(), so calling service_rpc hands back a zero-sized program value. Writing no return type is the convention: the type is generated, and naming it by hand would only repeat the macro.
  • merge is not receiving a method collection. The authored body is read as a description rather than executed, and a call to a sibling declaration is lifted into a nested program — the emitted body reads builder.merge(builder.program(builder.status_rpc::<Alg>())). That is what type-checks, and it is why one fragment composes with another without either knowing how the other was declared.

What that buys, and why it is not just tidiness:

  • Fragments state their own dependencies. Each names the capability it uses and nothing more, and service_rpc inherits exactly the union.
  • Merge is the whole composition. A JSON-RPC surface is a set of named methods, so it composes as a monoid — there is no prefix to nest under, because a method name has no parts. HTTP carries selectors and therefore nesting; the two program algebras differ because their surfaces genuinely differ.
  • One operation can appear more than once. status_adjusted is registered twice above, positionally and by name, which is a decision about the wire rather than about the domain.
  • Composition happens before interpretation. service_rpc is a value; the interpreter that registers it never chooses a method name.

Positional parameters are the default. .named() decodes a JSON object using the argument names retained by alux-ext. An argument the request leaves out reads as absent, which only an optional argument accepts, so a positional array may stop short of the product and a parameter object may omit a name.

fallible is the other distinction a method carries: it converts the operation's error into a JSON-RPC protocol error, so the failure answers in the response's error member instead of inside a successful result. It can be marked in either place. A program that mixes both kinds marks the declarations that convert, as status_rpc marks status_at above; a program whose errors all convert says so once as fallible on the ext, as history_rpc does, and each of its declarations stays silent. Marking it both ways means what marking it once means, and the two distinctions are independent — .named().fallible() states both, in either order.

What a failure denotes is RpcErrorAlg, implemented above for NoHistory: the code the JSON-RPC specification carries and the message the failure states. A domain says that once for its own error type, and nothing about it names an interpreter — which is what lets a specification state the meaning of its failures without depending on whichever library answers the call.

Marking it is also the migration path away from a failure that never reaches the protocol. An operation returning Result on the value path is serialized whole, so its error travels inside a successful answer — the shape JSON-RPC reserves a member for:

{"jsonrpc":"2.0","id":1,"result":{"Err":{"code":-32000,"message":"..."}}}   a failure as a success
{"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"..."}}           a failure as a failure

Moving from the first line to the second is two edits: mark fallible on the method or its ext, and implement RpcErrorAlg for the error type. Neither is guesswork — a fallible declaration does not compile until the error says what it denotes, and an error that is not serializable does not compile on the value path, which is what surfaces the mistake in the first place. An error type that happens to be serializable compiles either way, so those are the declarations worth reading twice.

alux-jsonrpc-jsonrpsee compiles the same program into jsonrpsee Methods.