radrootsd

JSON-RPC bridge for Radroots event publishing
git clone https://radroots.dev/git/radrootsd.git
Log | Files | Refs | README | LICENSE

commit c9583913cecb7960abeb8b2118e1d78e28beb508
parent 5e6eb64a8b55045e6df30fc0da2d1f568361f15f
Author: triesap <triesap@radroots.dev>
Date:   Sat,  3 Jan 2026 22:17:04 +0000

jsonrpc: add events.farm.list endpoint

- Register events.farm.list RPC method under farm module
- Fetch farm events via nostr filter with author/time bounds and limit
- Decode farm payload from tags/content and flatten into response view
- Add serde_json feature to events-codec and unit tests for sort/parse

Diffstat:
MCargo.lock | 1+
MCargo.toml | 2+-
Asrc/api/jsonrpc/methods/events/farm/list.rs | 181+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/api/jsonrpc/methods/events/farm/mod.rs | 2++
4 files changed, 185 insertions(+), 1 deletion(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -1783,6 +1783,7 @@ dependencies = [ "radroots-core", "radroots-events", "serde", + "serde_json", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml @@ -10,7 +10,7 @@ description = "Radroots daemon binary" [dependencies] radroots-core = { path = "../crates/core", features = ["std", "serde", "typeshare"] } radroots-events = { path = "../crates/events", features = ["serde"] } -radroots-events-codec = { path = "../crates/events-codec", features = ["nostr"] } +radroots-events-codec = { path = "../crates/events-codec", features = ["nostr", "serde_json"] } radroots-identity = { path = "../crates/identity" } radroots-nostr = { path = "../crates/nostr", features = ["client", "codec", "http"] } radroots-runtime = { path = "../crates/runtime", features = ["cli"] } diff --git a/src/api/jsonrpc/methods/events/farm/list.rs b/src/api/jsonrpc/methods/events/farm/list.rs @@ -0,0 +1,181 @@ +use anyhow::Result; +use jsonrpsee::server::RpcModule; +use serde::Serialize; +use std::time::Duration; + +use crate::api::jsonrpc::nostr::{event_tags, event_view_with_tags, NostrEventView}; +use crate::api::jsonrpc::params::{ + apply_time_bounds, + limit_or, + parse_pubkeys_opt, + timeout_or, + EventListParams, +}; +use crate::api::jsonrpc::{MethodRegistry, RpcContext, RpcError}; +use radroots_events::farm::RadrootsFarm; +use radroots_events::kinds::KIND_FARM; +use radroots_events_codec::farm::decode::farm_from_event; +use radroots_nostr::prelude::{ + RadrootsNostrEvent, + RadrootsNostrFilter, + RadrootsNostrKind, +}; + +#[derive(Clone, Debug, Serialize)] +struct FarmEventFlat { + #[serde(flatten)] + event: NostrEventView, + farm: Option<RadrootsFarm>, +} + +#[derive(Clone, Debug, Serialize)] +struct FarmListResponse { + farms: Vec<FarmEventFlat>, +} + +fn build_farm_rows<I>(events: I) -> Vec<FarmEventFlat> +where + I: IntoIterator<Item = RadrootsNostrEvent>, +{ + let mut items = events + .into_iter() + .map(|ev| { + let tags = event_tags(&ev); + let kind = ev.kind.as_u16() as u32; + let farm = farm_from_event(kind, &tags, &ev.content).ok(); + FarmEventFlat { + event: event_view_with_tags(&ev, tags), + farm, + } + }) + .collect::<Vec<_>>(); + items.sort_by(|a, b| b.event.created_at.cmp(&a.event.created_at)); + items +} + +pub fn register(m: &mut RpcModule<RpcContext>, registry: &MethodRegistry) -> Result<()> { + registry.track("events.farm.list"); + m.register_async_method("events.farm.list", |params, ctx, _| async move { + if ctx.state.client.relays().await.is_empty() { + return Err(RpcError::NoRelays); + } + + let EventListParams { + authors, + limit, + since, + until, + timeout_secs, + } = params + .parse::<Option<EventListParams>>() + .map_err(|e| RpcError::InvalidParams(e.to_string()))? + .unwrap_or_default(); + + let limit = limit_or(limit); + + let mut filter = RadrootsNostrFilter::new() + .limit(limit) + .kind(RadrootsNostrKind::Custom(KIND_FARM as u16)); + + if let Some(authors) = parse_pubkeys_opt("author", authors)? { + filter = filter.authors(authors); + } else { + filter = filter.author(ctx.state.pubkey); + } + filter = apply_time_bounds(filter, since, until); + + let events = ctx + .state + .client + .fetch_events(filter, Duration::from_secs(timeout_or(timeout_secs))) + .await + .map_err(|e| RpcError::Other(format!("fetch failed: {e}")))?; + + let items = build_farm_rows(events); + + Ok::<FarmListResponse, RpcError>(FarmListResponse { farms: items }) + })?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::build_farm_rows; + use radroots_events::farm::RadrootsFarm; + use radroots_events::kinds::KIND_FARM; + use radroots_events_codec::farm::encode::farm_build_tags; + use radroots_nostr::prelude::RadrootsNostrEvent; + use serde_json::json; + + fn farm_event( + id: &str, + pubkey: &str, + created_at: u64, + tags: Vec<Vec<String>>, + content: &str, + ) -> RadrootsNostrEvent { + let sig = format!("{:0128x}", 7); + let event_json = json!({ + "id": id, + "pubkey": pubkey, + "created_at": created_at, + "kind": KIND_FARM, + "tags": tags, + "content": content, + "sig": sig, + }); + serde_json::from_value(event_json).expect("event") + } + + fn sample_farm(d_tag: &str, name: &str) -> RadrootsFarm { + RadrootsFarm { + d_tag: d_tag.to_string(), + name: name.to_string(), + about: None, + website: None, + picture: None, + banner: None, + location: None, + tags: None, + } + } + + #[test] + fn farm_list_sorts_by_created_at_desc() { + let pubkey = "1bdebe7b23fccb167fc8843280b789839dfa296ae9fd86cc9769b4813d76d8a4"; + let old_id = format!("{:064x}", 1); + let new_id = format!("{:064x}", 2); + let farm = sample_farm("farm-1", "Farm One"); + let content = serde_json::to_string(&farm).expect("content"); + let tags = farm_build_tags(&farm).expect("tags"); + let older = farm_event(&old_id, pubkey, 100, tags.clone(), &content); + let newer = farm_event(&new_id, pubkey, 200, tags.clone(), &content); + + let farms = build_farm_rows(vec![older, newer]); + + assert_eq!(farms.len(), 2); + assert_eq!(farms[0].event.id, new_id); + assert_eq!(farms[0].event.created_at, 200); + assert_eq!(farms[1].event.id, old_id); + assert_eq!(farms[1].event.created_at, 100); + } + + #[test] + fn farm_list_uses_tag_d_when_missing_in_content() { + let pubkey = "2bdebe7b23fccb167fc8843280b789839dfa296ae9fd86cc9769b4813d76d8a4"; + let farm = sample_farm("farm-1", "Farm One"); + let tags = farm_build_tags(&farm).expect("tags"); + let content_farm = sample_farm("", "Farm One"); + let content = serde_json::to_string(&content_farm).expect("content"); + let id = format!("{:064x}", 3); + let event = farm_event(&id, pubkey, 300, tags.clone(), &content); + + let farms = build_farm_rows(vec![event]); + + assert_eq!(farms.len(), 1); + assert_eq!(farms[0].event.tags, tags); + let parsed = farms[0].farm.as_ref().expect("farm"); + assert_eq!(parsed.d_tag, "farm-1"); + assert_eq!(parsed.name, "Farm One"); + } +} diff --git a/src/api/jsonrpc/methods/events/farm/mod.rs b/src/api/jsonrpc/methods/events/farm/mod.rs @@ -4,9 +4,11 @@ use jsonrpsee::server::RpcModule; use crate::api::jsonrpc::{MethodRegistry, RpcContext}; pub mod publish; +pub mod list; pub fn module(ctx: RpcContext, registry: MethodRegistry) -> Result<RpcModule<RpcContext>> { let mut m = RpcModule::new(ctx); + list::register(&mut m, &registry)?; publish::register(&mut m, &registry)?; Ok(m) }