commit a5788d4103ac4fd3497b6e2bf75b68f60d7c710f
parent c13a36cc86b80877a9b286cb056e698b96eb0df5
Author: triesap <tyson@radroots.org>
Date: Mon, 13 Apr 2026 01:00:01 +0000
sdk: add explicit client transport surface
Diffstat:
3 files changed, 418 insertions(+), 0 deletions(-)
diff --git a/crates/sdk/src/client.rs b/crates/sdk/src/client.rs
@@ -0,0 +1,205 @@
+#[cfg(not(feature = "std"))]
+use alloc::{string::String, vec::Vec};
+#[cfg(feature = "std")]
+use std::{string::String, vec::Vec};
+
+use crate::config::{RadrootsSdkConfig, SdkConfigError, SdkTransportMode};
+use crate::{
+ NostrTags, RadrootsNostrEvent, RadrootsNostrEventPtr, RadrootsProfile, RadrootsProfileType,
+ RadrootsTradeEnvelope, TradeListingValidateResult, WireEventParts, farm, listing, profile,
+ trade,
+};
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct RadrootsSdkClient {
+ config: RadrootsSdkConfig,
+}
+
+impl RadrootsSdkClient {
+ pub fn from_config(config: RadrootsSdkConfig) -> Result<Self, SdkConfigError> {
+ config.resolved_relay_urls()?;
+ config.resolved_radrootsd_endpoint()?;
+ Ok(Self { config })
+ }
+
+ pub fn config(&self) -> &RadrootsSdkConfig {
+ &self.config
+ }
+
+ pub fn transport(&self) -> SdkTransportMode {
+ self.config.transport
+ }
+
+ pub fn resolved_relay_urls(&self) -> Result<Vec<String>, SdkConfigError> {
+ self.config.resolved_relay_urls()
+ }
+
+ pub fn resolved_radrootsd_endpoint(&self) -> Result<String, SdkConfigError> {
+ self.config.resolved_radrootsd_endpoint()
+ }
+
+ pub fn profile(&self) -> ProfileClient<'_> {
+ ProfileClient { client: self }
+ }
+
+ pub fn farm(&self) -> FarmClient<'_> {
+ FarmClient { client: self }
+ }
+
+ pub fn listing(&self) -> ListingClient<'_> {
+ ListingClient { client: self }
+ }
+
+ pub fn trade(&self) -> TradeClient<'_> {
+ TradeClient { client: self }
+ }
+}
+
+#[derive(Debug, Clone, Copy)]
+pub struct ProfileClient<'a> {
+ client: &'a RadrootsSdkClient,
+}
+
+impl<'a> ProfileClient<'a> {
+ pub fn sdk(&self) -> &'a RadrootsSdkClient {
+ self.client
+ }
+
+ pub fn transport(&self) -> SdkTransportMode {
+ self.client.transport()
+ }
+
+ #[cfg(feature = "serde_json")]
+ pub fn build_draft(
+ &self,
+ profile_value: &RadrootsProfile,
+ profile_type: Option<RadrootsProfileType>,
+ ) -> Result<WireEventParts, profile::ProfileEncodeError> {
+ profile::build_draft(profile_value, profile_type)
+ }
+}
+
+#[derive(Debug, Clone, Copy)]
+pub struct FarmClient<'a> {
+ client: &'a RadrootsSdkClient,
+}
+
+impl<'a> FarmClient<'a> {
+ pub fn sdk(&self) -> &'a RadrootsSdkClient {
+ self.client
+ }
+
+ pub fn transport(&self) -> SdkTransportMode {
+ self.client.transport()
+ }
+
+ #[cfg(feature = "serde_json")]
+ pub fn build_draft(
+ &self,
+ farm_value: &farm::RadrootsFarm,
+ ) -> Result<WireEventParts, farm::EventEncodeError> {
+ farm::build_draft(farm_value)
+ }
+}
+
+#[derive(Debug, Clone, Copy)]
+pub struct ListingClient<'a> {
+ client: &'a RadrootsSdkClient,
+}
+
+impl<'a> ListingClient<'a> {
+ pub fn sdk(&self) -> &'a RadrootsSdkClient {
+ self.client
+ }
+
+ pub fn transport(&self) -> SdkTransportMode {
+ self.client.transport()
+ }
+
+ pub fn build_tags(
+ &self,
+ listing_value: &listing::RadrootsListing,
+ ) -> Result<NostrTags, listing::EventEncodeError> {
+ listing::build_tags(listing_value)
+ }
+
+ #[cfg(feature = "serde_json")]
+ pub fn build_draft(
+ &self,
+ listing_value: &listing::RadrootsListing,
+ ) -> Result<WireEventParts, listing::EventEncodeError> {
+ listing::build_draft(listing_value)
+ }
+
+ #[cfg(feature = "serde_json")]
+ pub fn parse_event(
+ &self,
+ event: &RadrootsNostrEvent,
+ ) -> Result<listing::RadrootsListing, listing::RadrootsTradeListingParseError> {
+ listing::parse_event(event)
+ }
+}
+
+#[derive(Debug, Clone, Copy)]
+pub struct TradeClient<'a> {
+ client: &'a RadrootsSdkClient,
+}
+
+impl<'a> TradeClient<'a> {
+ pub fn sdk(&self) -> &'a RadrootsSdkClient {
+ self.client
+ }
+
+ pub fn transport(&self) -> SdkTransportMode {
+ self.client.transport()
+ }
+
+ #[cfg(feature = "serde_json")]
+ #[allow(clippy::too_many_arguments)]
+ pub fn build_envelope_draft(
+ &self,
+ recipient_pubkey: impl Into<String>,
+ message_type: trade::RadrootsTradeMessageType,
+ listing_addr: impl Into<String>,
+ order_id: Option<String>,
+ listing_event: Option<&RadrootsNostrEventPtr>,
+ root_event_id: Option<&str>,
+ prev_event_id: Option<&str>,
+ payload: &trade::RadrootsTradeMessagePayload,
+ ) -> Result<WireEventParts, trade::EventEncodeError> {
+ trade::build_envelope_draft(
+ recipient_pubkey,
+ message_type,
+ listing_addr,
+ order_id,
+ listing_event,
+ root_event_id,
+ prev_event_id,
+ payload,
+ )
+ }
+
+ #[cfg(feature = "serde_json")]
+ pub fn parse_envelope(
+ &self,
+ event: &RadrootsNostrEvent,
+ ) -> Result<RadrootsTradeEnvelope, trade::RadrootsTradeEnvelopeParseError> {
+ trade::parse_envelope(event)
+ }
+
+ #[cfg(feature = "serde_json")]
+ pub fn parse_listing_address(
+ &self,
+ listing_addr: &str,
+ ) -> Result<trade::RadrootsTradeListingAddress, trade::RadrootsTradeListingAddressError> {
+ trade::parse_listing_address(listing_addr)
+ }
+
+ #[cfg(feature = "serde_json")]
+ pub fn validate_listing_event(
+ &self,
+ event: &RadrootsNostrEvent,
+ ) -> Result<TradeListingValidateResult, trade::RadrootsTradeListingValidationError> {
+ trade::validate_listing_event(event)
+ }
+}
diff --git a/crates/sdk/src/lib.rs b/crates/sdk/src/lib.rs
@@ -15,6 +15,7 @@ use std::{string::String, vec::Vec};
feature = "signer-adapters"
))]
pub mod adapters;
+pub mod client;
pub mod config;
pub mod farm;
#[cfg(feature = "identity-models")]
@@ -30,6 +31,9 @@ pub use crate::config::{
RadrootsdAuth, RadrootsdConfig, RelayConfig, RetryPolicy, SdkConfigError, SdkEnvironment,
SdkTransportMode, SignerConfig,
};
+pub use crate::client::{
+ FarmClient, ListingClient, ProfileClient, RadrootsSdkClient, TradeClient,
+};
pub use radroots_events::{
RadrootsNostrEvent, RadrootsNostrEventPtr, RadrootsNostrEventRef,
farm::RadrootsFarm,
diff --git a/crates/sdk/tests/client.rs b/crates/sdk/tests/client.rs
@@ -0,0 +1,209 @@
+use radroots_core::{
+ RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity,
+ RadrootsCoreQuantityPrice, RadrootsCoreUnit,
+};
+use radroots_events::farm::RadrootsFarm;
+use radroots_events::kinds::{KIND_LISTING, KIND_TRADE_LISTING_VALIDATE_REQ};
+use radroots_events::listing::{
+ RadrootsListing, RadrootsListingAvailability, RadrootsListingBin,
+ RadrootsListingDeliveryMethod, RadrootsListingFarmRef, RadrootsListingLocation,
+ RadrootsListingProduct, RadrootsListingStatus,
+};
+use radroots_events::trade::{RadrootsTradeListingValidateRequest, RadrootsTradeMessagePayload};
+use radroots_sdk::{
+ RADROOTS_SDK_PRODUCTION_RADROOTSD_ENDPOINT, RADROOTS_SDK_PRODUCTION_RELAY_URL,
+ RadrootsNostrEvent, RadrootsSdkClient, RadrootsSdkConfig, RelayConfig, SdkConfigError,
+ SdkEnvironment, SdkTransportMode, SignerConfig,
+};
+
+fn sample_farm() -> RadrootsFarm {
+ RadrootsFarm {
+ d_tag: "AAAAAAAAAAAAAAAAAAAAAA".into(),
+ name: "North Farm".into(),
+ about: Some("Organic coffee".into()),
+ website: None,
+ picture: None,
+ banner: None,
+ location: None,
+ tags: Some(vec!["coffee".into()]),
+ }
+}
+
+fn sample_listing() -> RadrootsListing {
+ RadrootsListing {
+ d_tag: "AAAAAAAAAAAAAAAAAAAAAg".into(),
+ farm: RadrootsListingFarmRef {
+ pubkey: "seller".into(),
+ d_tag: "AAAAAAAAAAAAAAAAAAAAAA".into(),
+ },
+ product: RadrootsListingProduct {
+ key: "coffee".into(),
+ title: "Coffee".into(),
+ category: "coffee".into(),
+ summary: Some("Single origin coffee".into()),
+ process: None,
+ lot: None,
+ location: None,
+ profile: None,
+ year: None,
+ },
+ primary_bin_id: "bin-1".into(),
+ bins: vec![RadrootsListingBin {
+ bin_id: "bin-1".into(),
+ quantity: RadrootsCoreQuantity::new(
+ RadrootsCoreDecimal::from(1000u32),
+ RadrootsCoreUnit::MassG,
+ ),
+ price_per_canonical_unit: RadrootsCoreQuantityPrice {
+ amount: RadrootsCoreMoney::new(
+ RadrootsCoreDecimal::from(20u32),
+ RadrootsCoreCurrency::USD,
+ ),
+ quantity: RadrootsCoreQuantity::new(
+ RadrootsCoreDecimal::from(1u32),
+ RadrootsCoreUnit::MassG,
+ ),
+ },
+ display_amount: None,
+ display_unit: None,
+ display_label: None,
+ display_price: None,
+ display_price_unit: None,
+ }],
+ resource_area: None,
+ plot: None,
+ discounts: None,
+ inventory_available: Some(RadrootsCoreDecimal::from(5u32)),
+ availability: Some(RadrootsListingAvailability::Status {
+ status: RadrootsListingStatus::Active,
+ }),
+ delivery_method: Some(RadrootsListingDeliveryMethod::Pickup),
+ location: Some(RadrootsListingLocation {
+ primary: "North Farm".into(),
+ city: None,
+ region: None,
+ country: None,
+ lat: None,
+ lng: None,
+ geohash: None,
+ }),
+ images: None,
+ }
+}
+
+#[test]
+fn client_default_config_uses_production_relay_direct() {
+ let client = RadrootsSdkClient::from_config(RadrootsSdkConfig::default()).expect("sdk client");
+
+ assert_eq!(client.transport(), SdkTransportMode::RelayDirect);
+ assert_eq!(
+ client.resolved_relay_urls().expect("resolved relays"),
+ vec![RADROOTS_SDK_PRODUCTION_RELAY_URL.to_string()]
+ );
+ assert_eq!(
+ client
+ .resolved_radrootsd_endpoint()
+ .expect("resolved radrootsd"),
+ RADROOTS_SDK_PRODUCTION_RADROOTSD_ENDPOINT
+ );
+}
+
+#[test]
+fn client_rejects_invalid_config_on_construction() {
+ let mut config = RadrootsSdkConfig::custom();
+ config.relay = RelayConfig {
+ urls: vec!["https://radroots.org".into()],
+ };
+
+ let error = RadrootsSdkClient::from_config(config).expect_err("invalid config");
+ assert_eq!(error, SdkConfigError::InvalidRelayUrl("https://radroots.org".into()));
+}
+
+#[test]
+fn namespace_clients_reflect_explicit_transport_mode() {
+ let mut config = RadrootsSdkConfig::for_environment(SdkEnvironment::Production);
+ config.transport = SdkTransportMode::Radrootsd;
+ config.signer = SignerConfig::LocalIdentity;
+
+ let client = RadrootsSdkClient::from_config(config).expect("sdk client");
+
+ assert_eq!(client.transport(), SdkTransportMode::Radrootsd);
+ assert_eq!(client.profile().transport(), SdkTransportMode::Radrootsd);
+ assert_eq!(client.farm().transport(), SdkTransportMode::Radrootsd);
+ assert_eq!(client.listing().transport(), SdkTransportMode::Radrootsd);
+ assert_eq!(client.trade().transport(), SdkTransportMode::Radrootsd);
+}
+
+#[test]
+fn listing_and_trade_clients_wrap_existing_sdk_facades() {
+ let client = RadrootsSdkClient::from_config(RadrootsSdkConfig::local()).expect("sdk client");
+ let listing_value = sample_listing();
+
+ let tags = client
+ .listing()
+ .build_tags(&listing_value)
+ .expect("listing tags");
+ assert!(!tags.is_empty());
+
+ let draft = client
+ .listing()
+ .build_draft(&listing_value)
+ .expect("listing draft");
+ assert_eq!(draft.kind, KIND_LISTING);
+
+ let event = RadrootsNostrEvent {
+ id: "listing-1".into(),
+ author: "seller".into(),
+ created_at: 1,
+ kind: draft.kind,
+ tags: draft.tags,
+ content: draft.content,
+ sig: String::new(),
+ };
+ let parsed = client
+ .listing()
+ .parse_event(&event)
+ .expect("parsed listing");
+ assert_eq!(parsed.d_tag, listing_value.d_tag);
+
+ let validated = client
+ .trade()
+ .validate_listing_event(&event)
+ .expect("validated listing");
+ assert_eq!(validated.listing_id, listing_value.d_tag);
+
+ let listing_addr = format!("{KIND_LISTING}:seller:{}", listing_value.d_tag);
+ let payload =
+ RadrootsTradeMessagePayload::ListingValidateRequest(RadrootsTradeListingValidateRequest {
+ listing_event: None,
+ });
+ let envelope = client
+ .trade()
+ .build_envelope_draft(
+ "buyer",
+ payload.message_type(),
+ listing_addr.clone(),
+ None,
+ None,
+ None,
+ None,
+ &payload,
+ )
+ .expect("trade draft");
+ assert_eq!(envelope.kind, KIND_TRADE_LISTING_VALIDATE_REQ);
+ let parsed_addr = client
+ .trade()
+ .parse_listing_address(&listing_addr)
+ .expect("listing address");
+ assert_eq!(parsed_addr.listing_id, listing_value.d_tag);
+}
+
+#[test]
+fn farm_client_wraps_existing_farm_facade() {
+ let client =
+ RadrootsSdkClient::from_config(RadrootsSdkConfig::production()).expect("sdk client");
+ let farm = sample_farm();
+
+ let draft = client.farm().build_draft(&farm).expect("farm draft");
+ assert!(!draft.tags.is_empty());
+}