Home

Awesome

rpc-router - json-rpc routing library

rpc-router is a JSON-RPC routing library in Rust for asynchronous dynamic dispatch with support for variadic arguments (up to 8 resources + 1 optional parameter). (code snippets below from: examples/c00-readme.rs)

The goal of this library is to enable application functions with different argument types and signatures as follows:

pub async fn create_task(mm: ModelManager, aim: AiManager, params: TaskForCreate) -> Result<i64, MyError> {
  // ...
}
pub async fn get_task(mm: ModelManager, params: ParamsIded) -> Result<Task, MyError> {
  // ...
}

To be callable from a JSON-RPC request as follows:

// json-rpc request comming from Axum route payload, Tauri command params, ...
let rpc_request = json!(
   { jsonrpc: "2.0", id: 1,          // required by json-rpc
     method:  "create_task",         // method name (matches function name)
     params:  {title: "First Task"}  // optional params (last function argument)
   }).try_into()?;

// Async Execute the RPC Request 
let call_response = rpc_router.call(rpc_request).await?;

For this, we just need to build the router, the resources, parse the json-rpc request, and execute the call from the router as follows:

// Build the Router with the handlers and common resources
let rpc_router = router_builder!(
    handlers: [get_task, create_task],         // will be turned into routes
    resources: [ModelManager {}, AiManager {}] // common resources for all calls
)
.build();
// Can do the same with `Router::builder().append(...)/append_resource(...)`

// Create and parse rpc request example.
let rpc_request: rpc_router::Request = json!({
    "jsonrpc": "2.0",
    "id": "some-client-req-id", // json-rpc request id. Can be null,num,string, but has to be present.
    "method": "create_task",
    "params": { "title": "First task" } // optional.
}).try_into()?;

// Async Execute the RPC Request.
let call_response = rpc_router.call(rpc_resources, rpc_request).await?;

// Or `call_with_resources` for  additional per-call Resources that override router common resources.
// e.g., rpc_router.call_with_resources(rpc_request, additional_resources)

// Display the response.
let CallResponse { id, method, value } = call_response;
println!(
	r#"RPC call response:
		
    id:  {id:?},
method:  {method},
 value:  {value:?},
"#
	);

See examples/c00-readme.rs for the complete functioning code.

For the above to work, here are the requirements for the various types:

// Make it a Resource with RpcResource derive macro
#[derive(Clone, RpcResource)]
pub struct ModelManager {}

// Make it a Resource by implementing FromResources
#[derive(Clone)]
pub struct AiManager {}
impl FromResources for AiManager {}
// Make it a Params with RpcParams derive macro
#[derive(Serialize, Deserialize, RpcParams)]
pub struct TaskForCreate {
	title: String,
	done: Option<bool>,
}

// Make it a Params by implementing IntoParams
#[derive(Deserialize)]
pub struct ParamsIded {
	pub id: i64,
}
impl IntoParams for ParamsIded {}
#[derive(Serialize)]
pub struct Task {
	id: i64,
	title: String,
	done: bool,
}
#[derive(Debug, thiserror::Error, RpcHandlerError)]
pub enum MyError {
	// TBC
	#[error("TitleCannotBeEmpty")]
	TitleCannotBeEmpty,
}

By the Rust type model, these application errors are set in the HandlerError and need to be retrieved by handler_error.get::<MyError>(). See examples/c05-error-handling.rs.

Full code examples/c00-readme.rs

IMPORTANT

For the 0.1.x releases, there may be some changes to types or API naming. Therefore, the version should be locked to the latest version used, for example, =0.1.0. I will try to keep changes to a minimum, if any, and document them in the future CHANGELOG.

Once 0.2.0 is released, I will adhere more strictly to the semantic versioning methodology.

Concepts

This library has the following main constructs:

  1. Router - Router is the construct that holds all of the Handler Functions, and can be invoked with router.call(resources, rpc_request). Here are the two main ways to build a Router object:

    • RouterBuilder - via RouterBuilder::default() or Router::build(), then call .append(name, function) or .append_dyn(name, function.into_dyn()) to avoid type monomorphization at the "append" stage.
    • router_builder! - via the macro router_builder!(function1, function2, ...). This will create, initialize, and return a RouterBuilder object.
    • In both cases, call .build() to construct the immutable, shareable (via inner Arc) Router object.
  2. Resources - Resources is the type map contstruct that hold the resources that a rpc handler function might request.

    • It's similar to Axum State/RequestExtractor, or Tauri State model. In the case of rpc-router there in one "domain space" for those states, that are called resources.
    • It's built via ResourcesBuilder::default().append(my_object)...build()
    • Or viat the macro resources_builder![my_object1, my_object2].build()
    • The Resources hold the "type map" in a Arc<> and is completely immutable and can be cloned effectively.
    • ResourcesBuilder is not wrapped in Arc<>, and cloning it will clone the full type map. This can be very useful for sharing a common base resources builder across various calls while allowing each call to add more per-request resources.
    • All the value/object inserted in the Resources must implement Clone + Send + Sync + 'static (in this context the 'static just means that the type cannot have any reference other than static one )
  3. Request - Is the object that have the json-rpc Request id, method, and params.

    • To make a struct a params it has to implement the rpc_router::IntoParams trait, which has the default implementation.
    • So, implement impl rpc_router::IntoParams for ... {} or #[derive(RpcParams)]
    • rpc_router::Request::from_value(serde_json::Value) -> Result<Request, RequestParsingError> will return and RequestParsingError if the Value does not have id: Value, method: String or if the Value does not contain "jsonrpc": "2.0" as per the json-rpc space.
    • let request: rpc_router::Request = value.try_into()? use the same from_value validation steps.
    • Doing serde_json::from_value::<rpc_router::Request>(value) will not chane the jsonrpc.
  4. Handler - RPC handler functions can be any async application function that takes up to 8 resource arguments, plus an optional Params argument.

    • For example, async fn create_task(_mm: ModelManager, aim: AiManager, params: TaskForCreate) -> MyResult<i64>
  5. HandlerError - RPC handler functions can return their own Result as long as the error type implements IntoHandlerError, which can be easily implemented as rpc_router::HandlerResult which includes an impl IntoHandlerError for MyError {}, or with the RpcHandlerError derive macro.

    • To allow handler functions to return their application error, HandlerError is essentially a type holder, which then allows the extraction of the application error with handler_error.get<MyError>().
    • This requires the application code to know which error type to extract but provides flexibility to return any Error type.
    • Typically, an application will have a few application error types for its handlers, so this ergonomic trade-off still has net positive value as it enables the use of application-specific error types.
  6. CallResult - router.call(...) will return a CallResult, which is a Result<CallResponse, CallError> where both will include the JSON-RPC id and method name context for future processing.

    • CallError contains .error: rpc_router::Error, which includes rpc_router::Error::Handler(HandlerError) in the event of a handler error.
    • CallResponse contains .value: serde_json::Value, which is the serialized value returned by a successful handler call.

Derive Macros

rpc-router has some convenient derive proc macros that generate the implementation of various traits.

This is just a stylistic convenience, as the traits themselves have default implementations and are typically one-liner implementations.

Note: Those derive proc macros are prefixed with Rpc as we often tend to just put the proc macro name in the derive, and therefore the prefix adds some clarity. Other rpc-router types, are without the prefix to follow Rust customs.

#[derive(rpc_router::RpcParams)]

Will implement rpc_router::IntoParams for the type.

Works on simple type.

#[derive(serde::Deserialize, rpc_router::RpcParams)]
pub strut ParamsIded {
    id: i64
}

// Will generate:
// impl rpc_router::IntoParams for ParamsIded {} 

Works with typed with generic (all will be bound to DeserializeOwned + Send)

#[derive(rpc_router::RpcParams)]
pub strut ParamsForUpdate<D> {
    id: i64
    D
}
// Will generate
// impl<D> IntoParams for ParamsForCreate<D> where D: DeserializeOwned + Send {}

#[derive(rpc_router::RpcResource)]

Will implement the rpc_router::FromResource trait.


#[derive(Clone, rpc_router::RpcResource)]
pub struct ModelManager;

// Will generate:
// impl FromResources for ModelManager {}

The FromResources trait has a default implementation to get the T type (here ModelManager) from the rpc_router::Resources type map.

#[derive(rpc_router::RpcHandlerError)]

Will implment the rpc_router::IntoHandlerError trait.

#[derive(Debug, Serialize, RpcHandlerError)]
pub enum MyError {
    InvalidName,
    // ...
}

// Will generate;
// impl IntoHandlerError for MyError {}
<br />

Related links