jsonrpc_bidirectional_service!
Use jsonrpc_bidirectional_service! for typed JSON-RPC traffic over
WebSockets. It generates server-side dispatch for client calls, client-side
method helpers, typed notification handling, and optional server-to-client
request support.
Dependencies And Features
[dependencies]
async-trait = "0.1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
ras-auth-core = "0.2.0"
ras-jsonrpc-types = "0.2.0"
ras-jsonrpc-bidirectional-types = "0.2.0"
ras-jsonrpc-bidirectional-macro = { version = "0.2.0", default-features = false }
ras-jsonrpc-bidirectional-server = { version = "0.2.0", optional = true }
ras-jsonrpc-bidirectional-client = { version = "0.2.0", optional = true }
[features]
default = []
server = [
"ras-jsonrpc-bidirectional-macro/server",
"dep:ras-jsonrpc-bidirectional-server",
]
client = [
"ras-jsonrpc-bidirectional-macro/client",
"dep:ras-jsonrpc-bidirectional-client",
]
These API-crate features forward to the macro crate and enable the runtime
dependencies used by the generated surface. The WebSocket server depends on the
API crate with features = ["server"]; TUI, native, or browser clients depend
on it with features = ["client"].
If server_to_client_calls is used, the server feature also needs optional
tokio and uuid dependencies because generated server-side client handles
track pending responses and timeouts.
Define The Service
use ras_jsonrpc_bidirectional_macro::jsonrpc_bidirectional_service;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SendMessageRequest {
pub channel: String,
pub body: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SendMessageResponse {
pub message_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MessageReceived {
pub channel: String,
pub body: String,
}
jsonrpc_bidirectional_service!({
service_name: ChatService,
client_to_server: [
WITH_PERMISSIONS(["user"]) send_message(SendMessageRequest) -> SendMessageResponse,
],
server_to_client: [
message_received(MessageReceived),
],
server_to_client_calls: [
]
});
client_to_server methods support the same UNAUTHORIZED, OPTIONAL_AUTH, and
WITH_PERMISSIONS(["a"] | ["b", "c"]) style as the HTTP JSON-RPC macro (see
Auth In The API Contract). An OPTIONAL_AUTH
method is built from the connection’s optional user, so the server must allow
anonymous connections (require_auth(false)) for anonymous callers to reach it.
OPTIONAL_AUTH is not allowed on server_to_client calls — those are
outbound, so there is no inbound caller to identify.
Implement And Mount The Server
Server handlers receive the connection id and connection manager. WITH_PERMISSIONS
methods also receive &AuthenticatedUser; OPTIONAL_AUTH methods receive a
ras_auth_core::Caller (in the same position) instead.
#[async_trait::async_trait]
impl ChatServiceService for ChatServiceImpl {
async fn send_message(
&self,
client_id: ras_jsonrpc_bidirectional_types::ConnectionId,
connection_manager: &dyn ras_jsonrpc_bidirectional_types::ConnectionManager,
user: &ras_auth_core::AuthenticatedUser,
request: SendMessageRequest,
) -> Result<SendMessageResponse, Box<dyn std::error::Error + Send + Sync>> {
todo!("persist and broadcast the message")
}
async fn notify_message_received(
&self,
connection_id: ras_jsonrpc_bidirectional_types::ConnectionId,
params: MessageReceived,
) -> ras_jsonrpc_bidirectional_types::Result<()> {
Ok(())
}
}
let websocket_service = ChatServiceBuilder::new(ChatServiceImpl, my_auth_provider)
.require_auth(false)
.build();
let app = axum::Router::new()
.route("/ws", axum::routing::get(ras_jsonrpc_bidirectional_server::websocket_handler::<_>))
.with_state(websocket_service);
require_auth(true) requires credentials for the connection as a whole.
Method-level permissions are still enforced for protected calls.
Connection Security Defaults
The generated builder produces a WebSocketService. Its defaults are tuned for
production, and every one of them is overridable on
ras_jsonrpc_bidirectional_server::WebSocketServiceBuilder or by implementing
the corresponding WebSocketService method:
| Setting | Default | What it does |
|---|---|---|
max_message_size | 1 MiB | Enforced at the transport, so an oversized frame is rejected before it is buffered. |
subscription_limits | 64 topics per message, 256 per connection, 256-byte names, 100 000 globally | An over-limit Subscribe gets an invalid-params error and the connection stays open. |
keepalive | ping every 30 s, close after 90 s idle | Reclaims half-open sockets. Browsers and tungstenite answer pings automatically. |
auth_revalidation_interval | 30 s | Re-runs the auth provider on the connection’s token. Failure closes the socket. |
on_permission_change | DropSubscriptions | On successful re-validation every held subscription is re-run through authorize_subscribe; topics no longer authorized are dropped, and any topic message already queued for the socket is discarded at send time. Close closes the socket instead so the client must reconnect. |
max_connections | 10 000 | Enforced atomically with a semaphore permit held for the connection’s lifetime. None lifts the cap. |
All of these are also settable on the generated <Service>Builder.
Topic subscriptions are default-deny: override authorize_subscribe on the
handler to allow the topics a connection is entitled to. The limits above are
enforced inside ConnectionContext::subscribe itself, the only way to add a
subscription, so they hold from any handler callback and with any
ConnectionManager. It returns an error on refusal; the default
handle_subscribe logs and continues. WITH_PERMISSIONS
checks go through your provider’s check_permissions, so wildcard or
hierarchical permission schemes behave the same over WebSocket as over REST.
Authenticating From A Browser
A browser WebSocket cannot set headers. The WASM client therefore offers two
subprotocols on upgrade, ras-jsonrpc and token.<jwt>; the server reads the
token from the second and selects the first, so the token is never echoed in
the response. Native clients send Authorization: Bearer <token>. Tokens are
never placed in the URL query string, and the server does not read them from
there.
Client Usage
The client feature generates a typed client builder, method calls, connection helpers, and notification registration:
let mut client = ChatServiceClientBuilder::new("ws://localhost:3000/ws")
.with_jwt_token(token)
.build()
.await?;
client.on_message_received(|message| {
println!("{}: {}", message.channel, message.body);
});
client.connect().await?;
let sent = client.send_message(SendMessageRequest {
channel: "general".to_string(),
body: "hello".to_string(),
}).await?;
In application code, it is usually useful to register all notification handlers
before connect, then wrap common calls behind a small app-level client:
client.on_message_received(|message| {
println!("{}: {}", message.channel, message.body);
});
client.on_user_joined(|event| {
println!("{} joined", event.username);
});
client.connect().await?;
let rooms = client.list_rooms(ListRoomsRequest {}).await?;
client
.send_message(SendMessageRequest {
channel: rooms.default_channel,
body: "hello".to_string(),
})
.await?;
This macro does not currently generate OpenRPC. Use HTTP
jsonrpc_service! when an OpenRPC document is required.