feat(daemon): wire protocol with a type-safe method vocabulary and a generated OpenRPC contract

Methods and events are enums built by one macro, so every consumer matches
exhaustively: adding a member is a compile error until the dispatcher, the
published schema, and the remote client all handle it. docs/openrpc.json is
derived from the DTOs rather than hand-written, and a test fails the build
when the checked-in copy drifts.
This commit is contained in:
Michel de Graaf
2026-07-27 09:56:48 +02:00
parent e2813392ea
commit df089fbab3
8 changed files with 3481 additions and 0 deletions
Generated
+63
View File
@@ -847,6 +847,12 @@ version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f"
[[package]]
name = "dyn-clone"
version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]]
name = "either"
version = "1.16.0"
@@ -2196,6 +2202,26 @@ dependencies = [
"thiserror 2.0.18",
]
[[package]]
name = "ref-cast"
version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d"
dependencies = [
"ref-cast-impl",
]
[[package]]
name = "ref-cast-impl"
version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.2",
]
[[package]]
name = "regex"
version = "1.13.1"
@@ -2265,6 +2291,31 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "schemars"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc"
dependencies = [
"dyn-clone",
"ref-cast",
"schemars_derive",
"serde",
"serde_json",
]
[[package]]
name = "schemars_derive"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f"
dependencies = [
"proc-macro2",
"quote",
"serde_derive_internals",
"syn 2.0.119",
]
[[package]]
name = "scopeguard"
version = "1.2.0"
@@ -2362,6 +2413,17 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "serde_derive_internals"
version = "0.29.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "serde_json"
version = "1.0.151"
@@ -2494,6 +2556,7 @@ dependencies = [
"igd-next",
"keyring",
"ratatui",
"schemars",
"serde",
"serde_json",
"soulseek-rs-lib",
+1809
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -44,4 +44,5 @@ keyring = { version = "3", features = ["apple-native", "windows-native", "async-
chrono = { version = "0.4.45", default-features = false, features = ["clock", "serde"] }
[dev-dependencies]
schemars = "1.2.1"
tempfile = "3"
+7
View File
@@ -0,0 +1,7 @@
//! The resident service: one logged-in session, controlled over a socket.
/// Generates `docs/openrpc.json` from the wire types and checks it is current.
/// Test-only: the published document is served at runtime by `include_str!`.
#[cfg(test)]
mod openrpc;
pub mod proto;
+318
View File
@@ -0,0 +1,318 @@
//! Generates the published OpenRPC contract from the wire types themselves.
//!
//! `docs/openrpc.json` is what third parties generate clients from, and
//! `rpc.discover` serves it straight out of the binary. Hand-maintaining it
//! would drift the moment a DTO gained a field, so it is derived here from the
//! same structs the daemon actually serializes, and [`document_is_current`]
//! fails the build when the checked-in copy no longer matches.
//!
//! Regenerate with `UPDATE_OPENRPC=1 cargo test -p soulseek-rs openrpc`.
use super::proto::{
Ack, AuthParams, AuthResult, BrowseEvent, DaemonStatus, DownloadStartParams,
DownloadStatusEvent, Downloads, DownloadStarted, Event, IntervalSeconds,
MessageParams, Members, Messages, Method, QueryParams, RoomEventDto,
RoomRef, SayParams, SearchResults, Seconds, SessionLossEvent, SharesStatus,
SlotsParams, TransferRef, UploadInfoDto, Uploads, UserMessageDto, UserRef,
UserResult, DirectoriesParams,
};
use schemars::{JsonSchema, SchemaGenerator, generate::SchemaSettings};
use serde_json::{Value, json};
/// Where the generated `$ref`s point. OpenRPC keeps shared schemas under
/// `components/schemas`, the same place OpenAPI does.
const DEFINITIONS_PATH: &str = "#/components/schemas/";
/// A method that takes no parameters.
fn nothing() -> Value {
json!({ "type": "null" })
}
fn schema_of<T: JsonSchema>(generator: &mut SchemaGenerator) -> Value {
generator.subschema_for::<T>().to_value()
}
/// The parameter schema for one method.
///
/// Exhaustive on purpose: a new [`Method`] variant does not compile until it is
/// described here, so the published contract cannot fall behind the daemon.
fn params_schema(method: Method, generator: &mut SchemaGenerator) -> Value {
match method {
Method::Auth => schema_of::<AuthParams>(generator),
Method::SearchStart
| Method::SearchResults
| Method::SearchWishlist => schema_of::<QueryParams>(generator),
Method::DownloadStart => schema_of::<DownloadStartParams>(generator),
Method::DownloadPause
| Method::DownloadResume
| Method::DownloadRemove
| Method::DownloadRemoveQueued
| Method::UploadCancel => schema_of::<TransferRef>(generator),
Method::UploadSlots => schema_of::<SlotsParams>(generator),
Method::RoomJoin | Method::RoomLeave | Method::RoomMembers => {
schema_of::<RoomRef>(generator)
}
Method::RoomSay => schema_of::<SayParams>(generator),
Method::MessageSend => schema_of::<MessageParams>(generator),
Method::BrowseUser | Method::UserRequest | Method::UserInfoOf => {
schema_of::<UserRef>(generator)
}
Method::SharesSet => schema_of::<DirectoriesParams>(generator),
Method::DaemonStatus
| Method::DaemonStop
| Method::RpcDiscover
| Method::SearchWishlistInterval
| Method::DownloadList
| Method::UploadList
| Method::PrivilegesCheck
| Method::PrivilegesOwn
| Method::RoomListRequest
| Method::MessageHistory
| Method::SharesStatusOf
| Method::SharesReindex => nothing(),
}
}
/// The result schema for one method. Exhaustive for the same reason.
fn result_schema(method: Method, generator: &mut SchemaGenerator) -> Value {
match method {
Method::Auth => schema_of::<AuthResult>(generator),
Method::DaemonStatus => schema_of::<DaemonStatus>(generator),
Method::RpcDiscover => json!({ "type": "object" }),
Method::SearchResults => schema_of::<SearchResults>(generator),
Method::SearchWishlistInterval => {
schema_of::<IntervalSeconds>(generator)
}
Method::DownloadStart => schema_of::<DownloadStarted>(generator),
Method::DownloadList => schema_of::<Downloads>(generator),
Method::UploadList => schema_of::<Uploads>(generator),
Method::PrivilegesOwn => schema_of::<Seconds>(generator),
Method::RoomMembers => schema_of::<Members>(generator),
Method::MessageHistory => schema_of::<Messages>(generator),
Method::UserInfoOf => schema_of::<UserResult>(generator),
Method::SharesStatusOf | Method::SharesSet | Method::SharesReindex => {
schema_of::<SharesStatus>(generator)
}
// Everything else acknowledges and says nothing more.
Method::DaemonStop
| Method::SearchStart
| Method::SearchWishlist
| Method::DownloadPause
| Method::DownloadResume
| Method::DownloadRemove
| Method::DownloadRemoveQueued
| Method::UploadCancel
| Method::UploadSlots
| Method::PrivilegesCheck
| Method::RoomListRequest
| Method::RoomJoin
| Method::RoomLeave
| Method::RoomSay
| Method::MessageSend
| Method::BrowseUser
| Method::UserRequest => schema_of::<Ack>(generator),
}
}
/// What a method does, for the generated documentation.
fn summary(method: Method) -> &'static str {
match method {
Method::Auth => "Authenticate. Must be the first request on a connection.",
Method::DaemonStatus => "Who the daemon is logged in as and what its session is doing.",
Method::DaemonStop => "Ask the daemon to shut down.",
Method::RpcDiscover => "This document.",
Method::SearchStart => "Put a search on the wire and return at once; results accumulate.",
Method::SearchResults => "Everything that has answered a search so far.",
Method::SearchWishlist => "Send a query as a wishlist search (server code 103).",
Method::SearchWishlistInterval => "How long the server wants between wishlist searches.",
Method::DownloadStart => "Queue a transfer. Progress arrives as event.download_status.",
Method::DownloadList => "Every transfer the daemon knows about.",
Method::DownloadPause => "Pause a transfer in progress.",
Method::DownloadResume => "Resume a paused transfer.",
Method::DownloadRemove => "Forget a transfer entirely.",
Method::DownloadRemoveQueued => "Drop a transfer that has not started.",
Method::UploadList => "Uploads being served, active first.",
Method::UploadCancel => "Stop serving an upload in progress.",
Method::UploadSlots => "How many uploads may run at once.",
Method::PrivilegesCheck => "Ask the server for this account's privilege time.",
Method::PrivilegesOwn => "Privilege seconds left, or null if the server has not answered.",
Method::RoomListRequest => "Ask the server for the public room list; it arrives as event.room.",
Method::RoomJoin => "Join a chat room.",
Method::RoomLeave => "Leave a chat room.",
Method::RoomSay => "Post a message to a room.",
Method::RoomMembers => "Who is currently in a room.",
Method::MessageSend => "Send a private message.",
Method::MessageHistory => "Private messages the daemon collected, including while nobody was attached.",
Method::BrowseUser => "Ask a peer for its share listing; it arrives as event.browse.",
Method::UserRequest => "Ask the server about a user; poll user.info for the answer.",
Method::UserInfoOf => "What the server has said about a user so far.",
Method::SharesStatusOf => "What the share index currently holds.",
Method::SharesSet => "Replace the shared folders and re-index.",
Method::SharesReindex => "Re-scan the configured folders.",
}
}
/// The payload schema for one pushed event. Exhaustive, like the methods.
fn event_schema(event: Event, generator: &mut SchemaGenerator) -> Value {
match event {
Event::Room => schema_of::<RoomEventDto>(generator),
Event::Message => schema_of::<UserMessageDto>(generator),
Event::Upload => schema_of::<UploadInfoDto>(generator),
Event::DownloadStatus => schema_of::<DownloadStatusEvent>(generator),
Event::Browse => schema_of::<BrowseEvent>(generator),
Event::SessionLoss => schema_of::<SessionLossEvent>(generator),
}
}
fn event_summary(event: Event) -> &'static str {
match event {
Event::Room => "Something happened in a room this session has joined.",
Event::Message => "A private message arrived.",
Event::Upload => "An upload changed state.",
Event::DownloadStatus => "A transfer moved.",
Event::Browse => "A peer answered a browse request.",
Event::SessionLoss => "The server session ended; nothing seen after this means anything.",
}
}
/// Build the whole document.
fn document() -> Value {
let settings = SchemaSettings::draft2020_12().with(|settings| {
settings.definitions_path = DEFINITIONS_PATH.into();
});
let mut generator = SchemaGenerator::new(settings);
let methods: Vec<Value> = Method::ALL
.iter()
.map(|&method| {
let params = params_schema(method, &mut generator);
let result = result_schema(method, &mut generator);
json!({
"name": method.wire_name(),
"summary": summary(method),
"paramStructure": "by-name",
"params": if params == json!({ "type": "null" }) {
json!([])
} else {
json!([{
"name": "params",
"required": true,
"schema": params,
}])
},
"result": {
"name": "result",
"schema": result,
},
})
})
.collect();
// OpenRPC has no first-class notion of a server-pushed notification, so
// the events are published as methods flagged with an extension field.
// A generator still emits their payload types; the delivery semantics are
// prose in docs/daemon-protocol.md.
let events: Vec<Value> = Event::ALL
.iter()
.map(|&event| {
json!({
"name": event.wire_name(),
"summary": event_summary(event),
"x-notification": true,
"paramStructure": "by-name",
"params": [{
"name": "params",
"required": true,
"schema": event_schema(event, &mut generator),
}],
"result": { "name": "result", "schema": { "type": "null" } },
})
})
.collect();
let definitions: serde_json::Map<String, Value> =
generator.take_definitions(true).into_iter().collect();
json!({
"openrpc": "1.3.2",
"info": {
"title": "soulseek-rs daemon",
"version": env!("CARGO_PKG_VERSION"),
"description": "Control a running soulseek-rs daemon. Newline-delimited \
JSON-RPC 2.0 over a Unix socket (local, authenticated by socket permissions) or \
TCP (remote, authenticated by the token in <state-dir>/daemon.token). `auth` must \
be the first request on a connection. Errors carry the CLI exit status in \
`error.data.exit`. Entries marked `x-notification` are pushed by the daemon and \
are never requested.",
"license": { "name": "MIT" },
},
"methods": methods.into_iter().chain(events).collect::<Vec<_>>(),
"components": { "schemas": definitions },
})
}
/// Where the published document lives, relative to this source file.
fn document_path() -> std::path::PathBuf {
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("docs")
.join("openrpc.json")
}
#[test]
fn document_is_current() {
let generated = format!(
"{}\n",
serde_json::to_string_pretty(&document())
.expect("the document is plain data")
);
let path = document_path();
if std::env::var_os("UPDATE_OPENRPC").is_some() {
std::fs::write(&path, &generated).expect("docs/ is writable");
return;
}
let published = std::fs::read_to_string(&path).unwrap_or_default();
assert_eq!(
published, generated,
"docs/openrpc.json is out of date with the wire types — regenerate it \
with `UPDATE_OPENRPC=1 cargo test -p soulseek-rs openrpc`"
);
}
#[test]
fn every_method_and_event_is_published() {
let document = document();
let published: Vec<&str> = document["methods"]
.as_array()
.expect("methods is an array")
.iter()
.map(|method| method["name"].as_str().expect("a name is a string"))
.collect();
for method in Method::ALL {
assert!(
published.contains(&method.wire_name()),
"{method} is missing from the published contract"
);
}
for event in Event::ALL {
assert!(
published.contains(&event.wire_name()),
"{event} is missing from the published contract"
);
}
assert_eq!(published.len(), Method::ALL.len() + Event::ALL.len());
}
#[test]
fn a_wire_name_round_trips_through_its_enum() {
for method in Method::ALL {
assert_eq!(Method::from_wire(method.wire_name()), Some(*method));
}
for event in Event::ALL {
assert_eq!(Event::from_wire(event.wire_name()), Some(*event));
}
assert_eq!(Method::from_wire("search.nope"), None);
}
File diff suppressed because it is too large Load Diff
+2
View File
@@ -3,5 +3,7 @@
pub mod api;
pub mod cli;
pub mod daemon;
pub mod models;
pub mod output;
pub mod persist;
+1
View File
@@ -1,6 +1,7 @@
mod api;
mod cli;
mod commands;
mod daemon;
mod directories;
mod models;
mod output;