radrootsd

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

commit 69cad08ce80797b7806f32048393a25be1817382
parent ce926c64b4bbd3ea0f8c278dff0de50f615920c3
Author: triesap <triesap@radroots.dev>
Date:   Sat,  3 Jan 2026 22:58:45 +0000

events: add resource_area and resource_cap jsonrpc methods

- Register events.resource_area.* and events.resource_cap.* modules
- Implement list endpoints with author/time bounds filtering and relay fetch
- Implement publish endpoints with tag building and optional extra tags
- Add tests for created_at desc sorting and tag-d fallback decoding

Diffstat:
Msrc/api/jsonrpc/methods/events/mod.rs | 2++
Asrc/api/jsonrpc/methods/events/resource_area/list.rs | 228+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/api/jsonrpc/methods/events/resource_area/mod.rs | 14++++++++++++++
Asrc/api/jsonrpc/methods/events/resource_area/publish.rs | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/api/jsonrpc/methods/events/resource_cap/list.rs | 199+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/api/jsonrpc/methods/events/resource_cap/mod.rs | 14++++++++++++++
Asrc/api/jsonrpc/methods/events/resource_cap/publish.rs | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/api/jsonrpc/methods/mod.rs | 2++
8 files changed, 561 insertions(+), 0 deletions(-)

diff --git a/src/api/jsonrpc/methods/events/mod.rs b/src/api/jsonrpc/methods/events/mod.rs @@ -1,5 +1,7 @@ pub mod farm; pub mod plot; +pub mod resource_area; +pub mod resource_cap; pub mod listing; pub mod post; pub mod profile; diff --git a/src/api/jsonrpc/methods/events/resource_area/list.rs b/src/api/jsonrpc/methods/events/resource_area/list.rs @@ -0,0 +1,228 @@ +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::kinds::KIND_RESOURCE_AREA; +use radroots_events::resource_area::RadrootsResourceArea; +use radroots_events_codec::resource_area::decode::resource_area_from_event; +use radroots_nostr::prelude::{ + RadrootsNostrEvent, + RadrootsNostrFilter, + RadrootsNostrKind, +}; + +#[derive(Clone, Debug, Serialize)] +struct ResourceAreaEventFlat { + #[serde(flatten)] + event: NostrEventView, + resource_area: Option<RadrootsResourceArea>, +} + +#[derive(Clone, Debug, Serialize)] +struct ResourceAreaListResponse { + resource_areas: Vec<ResourceAreaEventFlat>, +} + +fn build_resource_area_rows<I>(events: I) -> Vec<ResourceAreaEventFlat> +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 resource_area = resource_area_from_event(kind, &tags, &ev.content).ok(); + ResourceAreaEventFlat { + event: event_view_with_tags(&ev, tags), + resource_area, + } + }) + .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.resource_area.list"); + m.register_async_method("events.resource_area.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_RESOURCE_AREA 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_resource_area_rows(events); + + Ok::<ResourceAreaListResponse, RpcError>(ResourceAreaListResponse { + resource_areas: items, + }) + })?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::build_resource_area_rows; + use radroots_events::farm::{RadrootsGcsLocation, RadrootsGeoJsonPoint, RadrootsGeoJsonPolygon}; + use radroots_events::kinds::KIND_RESOURCE_AREA; + use radroots_events::resource_area::{ + RadrootsResourceArea, RadrootsResourceAreaLocation, + }; + use radroots_events_codec::resource_area::encode::resource_area_build_tags; + use radroots_nostr::prelude::RadrootsNostrEvent; + use serde_json::json; + + fn resource_area_event( + id: &str, + pubkey: &str, + created_at: u64, + tags: Vec<Vec<String>>, + content: &str, + ) -> RadrootsNostrEvent { + let sig = format!("{:0128x}", 9); + let event_json = json!({ + "id": id, + "pubkey": pubkey, + "created_at": created_at, + "kind": KIND_RESOURCE_AREA, + "tags": tags, + "content": content, + "sig": sig, + }); + serde_json::from_value(event_json).expect("event") + } + + fn sample_location() -> RadrootsResourceAreaLocation { + let point = RadrootsGeoJsonPoint { + r#type: "Point".to_string(), + coordinates: [-76.9714, -6.0346], + }; + let polygon = RadrootsGeoJsonPolygon { + r#type: "Polygon".to_string(), + coordinates: vec![vec![ + [-76.9714, -6.0346], + [-76.9712, -6.0346], + [-76.9712, -6.0344], + [-76.9714, -6.0344], + [-76.9714, -6.0346], + ]], + }; + let gcs = RadrootsGcsLocation { + lat: -6.0346, + lng: -76.9714, + geohash: "6m6t5x".to_string(), + point, + polygon, + accuracy: None, + altitude: None, + tag_0: None, + label: None, + area: None, + elevation: None, + soil: None, + climate: None, + gc_id: None, + gc_name: None, + gc_admin1_id: None, + gc_admin1_name: None, + gc_country_id: None, + gc_country_name: None, + }; + RadrootsResourceAreaLocation { + primary: Some("Moyobamba".to_string()), + city: None, + region: None, + country: None, + gcs, + } + } + + fn sample_resource_area(d_tag: &str, name: &str) -> RadrootsResourceArea { + RadrootsResourceArea { + d_tag: d_tag.to_string(), + name: name.to_string(), + about: None, + location: sample_location(), + tags: None, + } + } + + #[test] + fn resource_area_list_sorts_by_created_at_desc() { + let pubkey = "1bdebe7b23fccb167fc8843280b789839dfa296ae9fd86cc9769b4813d76d8a4"; + let old_id = format!("{:064x}", 1); + let new_id = format!("{:064x}", 2); + let area = sample_resource_area("area-1", "Area One"); + let content = serde_json::to_string(&area).expect("content"); + let tags = resource_area_build_tags(&area).expect("tags"); + let older = resource_area_event(&old_id, pubkey, 100, tags.clone(), &content); + let newer = resource_area_event(&new_id, pubkey, 200, tags.clone(), &content); + + let areas = build_resource_area_rows(vec![older, newer]); + + assert_eq!(areas.len(), 2); + assert_eq!(areas[0].event.id, new_id); + assert_eq!(areas[0].event.created_at, 200); + assert_eq!(areas[1].event.id, old_id); + assert_eq!(areas[1].event.created_at, 100); + } + + #[test] + fn resource_area_list_uses_tag_d_when_missing_in_content() { + let pubkey = "2bdebe7b23fccb167fc8843280b789839dfa296ae9fd86cc9769b4813d76d8a4"; + let area = sample_resource_area("area-1", "Area One"); + let tags = resource_area_build_tags(&area).expect("tags"); + let content_area = sample_resource_area("", "Area One"); + let content = serde_json::to_string(&content_area).expect("content"); + let id = format!("{:064x}", 3); + let event = resource_area_event(&id, pubkey, 300, tags.clone(), &content); + + let areas = build_resource_area_rows(vec![event]); + + assert_eq!(areas.len(), 1); + assert_eq!(areas[0].event.tags, tags); + let parsed = areas[0].resource_area.as_ref().expect("area"); + assert_eq!(parsed.d_tag, "area-1"); + assert_eq!(parsed.name, "Area One"); + } +} diff --git a/src/api/jsonrpc/methods/events/resource_area/mod.rs b/src/api/jsonrpc/methods/events/resource_area/mod.rs @@ -0,0 +1,14 @@ +use anyhow::Result; +use jsonrpsee::server::RpcModule; + +use crate::api::jsonrpc::{MethodRegistry, RpcContext}; + +pub mod list; +pub mod publish; + +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) +} diff --git a/src/api/jsonrpc/methods/events/resource_area/publish.rs b/src/api/jsonrpc/methods/events/resource_area/publish.rs @@ -0,0 +1,51 @@ +use anyhow::Result; +use jsonrpsee::server::RpcModule; +use serde::Deserialize; + +use crate::api::jsonrpc::nostr::{publish_response, PublishResponse}; +use crate::api::jsonrpc::{MethodRegistry, RpcContext, RpcError}; +use radroots_events::kinds::KIND_RESOURCE_AREA; +use radroots_events::resource_area::RadrootsResourceArea; +use radroots_events_codec::resource_area::encode::resource_area_build_tags; +use radroots_nostr::prelude::{radroots_nostr_build_event, radroots_nostr_send_event}; + +#[derive(Debug, Deserialize)] +struct PublishResourceAreaParams { + resource_area: RadrootsResourceArea, + #[serde(default)] + tags: Option<Vec<Vec<String>>>, +} + +pub fn register(m: &mut RpcModule<RpcContext>, registry: &MethodRegistry) -> Result<()> { + registry.track("events.resource_area.publish"); + m.register_async_method("events.resource_area.publish", |params, ctx, _| async move { + let relays = ctx.state.client.relays().await; + if relays.is_empty() { + return Err(RpcError::NoRelays); + } + + let PublishResourceAreaParams { resource_area, tags } = params + .parse() + .map_err(|e| RpcError::InvalidParams(e.to_string()))?; + + let content = serde_json::to_string(&resource_area).map_err(|e| { + RpcError::InvalidParams(format!("invalid resource_area json: {e}")) + })?; + let mut tag_slices = resource_area_build_tags(&resource_area) + .map_err(|e| RpcError::InvalidParams(e.to_string()))?; + if let Some(extra_tags) = tags { + tag_slices.extend(extra_tags); + } + + let builder = radroots_nostr_build_event(KIND_RESOURCE_AREA, content, tag_slices) + .map_err(|e| RpcError::Other(format!("failed to build resource_area event: {e}")))?; + + let output = radroots_nostr_send_event(&ctx.state.client, builder) + .await + .map_err(|e| RpcError::Other(format!("failed to publish resource_area: {e}")))?; + + Ok::<PublishResponse, RpcError>(publish_response(output)) + })?; + + Ok(()) +} diff --git a/src/api/jsonrpc/methods/events/resource_cap/list.rs b/src/api/jsonrpc/methods/events/resource_cap/list.rs @@ -0,0 +1,199 @@ +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::kinds::KIND_RESOURCE_HARVEST_CAP; +use radroots_events::resource_cap::RadrootsResourceHarvestCap; +use radroots_events_codec::resource_cap::decode::resource_harvest_cap_from_event; +use radroots_nostr::prelude::{ + RadrootsNostrEvent, + RadrootsNostrFilter, + RadrootsNostrKind, +}; + +#[derive(Clone, Debug, Serialize)] +struct ResourceCapEventFlat { + #[serde(flatten)] + event: NostrEventView, + resource_cap: Option<RadrootsResourceHarvestCap>, +} + +#[derive(Clone, Debug, Serialize)] +struct ResourceCapListResponse { + resource_caps: Vec<ResourceCapEventFlat>, +} + +fn build_resource_cap_rows<I>(events: I) -> Vec<ResourceCapEventFlat> +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 resource_cap = resource_harvest_cap_from_event(kind, &tags, &ev.content).ok(); + ResourceCapEventFlat { + event: event_view_with_tags(&ev, tags), + resource_cap, + } + }) + .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.resource_cap.list"); + m.register_async_method("events.resource_cap.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_RESOURCE_HARVEST_CAP 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_resource_cap_rows(events); + + Ok::<ResourceCapListResponse, RpcError>(ResourceCapListResponse { + resource_caps: items, + }) + })?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::build_resource_cap_rows; + use radroots_core::{RadrootsCoreDecimal, RadrootsCoreQuantity, RadrootsCoreUnit}; + use radroots_events::kinds::KIND_RESOURCE_HARVEST_CAP; + use radroots_events::resource_area::RadrootsResourceAreaRef; + use radroots_events::resource_cap::{RadrootsResourceHarvestCap, RadrootsResourceHarvestProduct}; + use radroots_events_codec::resource_cap::encode::resource_harvest_cap_build_tags; + use radroots_nostr::prelude::RadrootsNostrEvent; + use serde_json::json; + + fn resource_cap_event( + id: &str, + pubkey: &str, + created_at: u64, + tags: Vec<Vec<String>>, + content: &str, + ) -> RadrootsNostrEvent { + let sig = format!("{:0128x}", 10); + let event_json = json!({ + "id": id, + "pubkey": pubkey, + "created_at": created_at, + "kind": KIND_RESOURCE_HARVEST_CAP, + "tags": tags, + "content": content, + "sig": sig, + }); + serde_json::from_value(event_json).expect("event") + } + + fn sample_cap(d_tag: &str, area_pubkey: &str, area_d_tag: &str) -> RadrootsResourceHarvestCap { + let quantity = RadrootsCoreQuantity::new( + RadrootsCoreDecimal::from(100_u64), + RadrootsCoreUnit::MassG, + ); + RadrootsResourceHarvestCap { + d_tag: d_tag.to_string(), + resource_area: RadrootsResourceAreaRef { + pubkey: area_pubkey.to_string(), + d_tag: area_d_tag.to_string(), + }, + product: RadrootsResourceHarvestProduct { + key: "coffee".to_string(), + category: None, + }, + start: 100, + end: 200, + cap_quantity: quantity, + display_amount: None, + display_unit: None, + display_label: None, + tags: None, + } + } + + #[test] + fn resource_cap_list_sorts_by_created_at_desc() { + let pubkey = "1bdebe7b23fccb167fc8843280b789839dfa296ae9fd86cc9769b4813d76d8a4"; + let old_id = format!("{:064x}", 1); + let new_id = format!("{:064x}", 2); + let cap = sample_cap("cap-1", pubkey, "area-1"); + let content = serde_json::to_string(&cap).expect("content"); + let tags = resource_harvest_cap_build_tags(&cap).expect("tags"); + let older = resource_cap_event(&old_id, pubkey, 100, tags.clone(), &content); + let newer = resource_cap_event(&new_id, pubkey, 200, tags.clone(), &content); + + let caps = build_resource_cap_rows(vec![older, newer]); + + assert_eq!(caps.len(), 2); + assert_eq!(caps[0].event.id, new_id); + assert_eq!(caps[0].event.created_at, 200); + assert_eq!(caps[1].event.id, old_id); + assert_eq!(caps[1].event.created_at, 100); + } + + #[test] + fn resource_cap_list_uses_tag_d_when_missing_in_content() { + let pubkey = "2bdebe7b23fccb167fc8843280b789839dfa296ae9fd86cc9769b4813d76d8a4"; + let cap = sample_cap("cap-1", pubkey, "area-1"); + let tags = resource_harvest_cap_build_tags(&cap).expect("tags"); + let mut content_cap = sample_cap("", pubkey, "area-1"); + content_cap.display_label = Some("display".to_string()); + let content = serde_json::to_string(&content_cap).expect("content"); + let id = format!("{:064x}", 3); + let event = resource_cap_event(&id, pubkey, 300, tags.clone(), &content); + + let caps = build_resource_cap_rows(vec![event]); + + assert_eq!(caps.len(), 1); + assert_eq!(caps[0].event.tags, tags); + let parsed = caps[0].resource_cap.as_ref().expect("cap"); + assert_eq!(parsed.d_tag, "cap-1"); + assert_eq!(parsed.resource_area.d_tag, "area-1"); + assert_eq!(parsed.product.key, "coffee"); + } +} diff --git a/src/api/jsonrpc/methods/events/resource_cap/mod.rs b/src/api/jsonrpc/methods/events/resource_cap/mod.rs @@ -0,0 +1,14 @@ +use anyhow::Result; +use jsonrpsee::server::RpcModule; + +use crate::api::jsonrpc::{MethodRegistry, RpcContext}; + +pub mod list; +pub mod publish; + +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) +} diff --git a/src/api/jsonrpc/methods/events/resource_cap/publish.rs b/src/api/jsonrpc/methods/events/resource_cap/publish.rs @@ -0,0 +1,51 @@ +use anyhow::Result; +use jsonrpsee::server::RpcModule; +use serde::Deserialize; + +use crate::api::jsonrpc::nostr::{publish_response, PublishResponse}; +use crate::api::jsonrpc::{MethodRegistry, RpcContext, RpcError}; +use radroots_events::kinds::KIND_RESOURCE_HARVEST_CAP; +use radroots_events::resource_cap::RadrootsResourceHarvestCap; +use radroots_events_codec::resource_cap::encode::resource_harvest_cap_build_tags; +use radroots_nostr::prelude::{radroots_nostr_build_event, radroots_nostr_send_event}; + +#[derive(Debug, Deserialize)] +struct PublishResourceCapParams { + resource_cap: RadrootsResourceHarvestCap, + #[serde(default)] + tags: Option<Vec<Vec<String>>>, +} + +pub fn register(m: &mut RpcModule<RpcContext>, registry: &MethodRegistry) -> Result<()> { + registry.track("events.resource_cap.publish"); + m.register_async_method("events.resource_cap.publish", |params, ctx, _| async move { + let relays = ctx.state.client.relays().await; + if relays.is_empty() { + return Err(RpcError::NoRelays); + } + + let PublishResourceCapParams { resource_cap, tags } = params + .parse() + .map_err(|e| RpcError::InvalidParams(e.to_string()))?; + + let content = serde_json::to_string(&resource_cap).map_err(|e| { + RpcError::InvalidParams(format!("invalid resource_cap json: {e}")) + })?; + let mut tag_slices = resource_harvest_cap_build_tags(&resource_cap) + .map_err(|e| RpcError::InvalidParams(e.to_string()))?; + if let Some(extra_tags) = tags { + tag_slices.extend(extra_tags); + } + + let builder = radroots_nostr_build_event(KIND_RESOURCE_HARVEST_CAP, content, tag_slices) + .map_err(|e| RpcError::Other(format!("failed to build resource_cap event: {e}")))?; + + let output = radroots_nostr_send_event(&ctx.state.client, builder) + .await + .map_err(|e| RpcError::Other(format!("failed to publish resource_cap: {e}")))?; + + Ok::<PublishResponse, RpcError>(publish_response(output)) + })?; + + Ok(()) +} diff --git a/src/api/jsonrpc/methods/mod.rs b/src/api/jsonrpc/methods/mod.rs @@ -22,6 +22,8 @@ pub fn register_all( root.merge(events::listing::module(ctx.clone(), registry.clone())?)?; root.merge(events::farm::module(ctx.clone(), registry.clone())?)?; root.merge(events::plot::module(ctx.clone(), registry.clone())?)?; + root.merge(events::resource_area::module(ctx.clone(), registry.clone())?)?; + root.merge(events::resource_cap::module(ctx.clone(), registry.clone())?)?; root.merge(domains::trade::module(ctx, registry)?)?; Ok(()) }