Skip to main content

lores_p2panda_client/
lib.rs

1use std::fmt;
2use tonic::transport::Channel;
3
4pub mod proto {
5    tonic::include_proto!("lores.panda.v2");
6}
7
8use proto::{GetNodeRequest, InfoRequest, OperationEvent, PublishRequest, SubscribeRequest, panda_client::PandaClient as TonicPandaClient};
9use tonic::{Code, Response, Status, Streaming};
10
11/// 32-byte p2panda operation hash returned by a successful publish.
12#[derive(Clone, PartialEq, Eq)]
13#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
14pub struct OperationId(pub Vec<u8>);
15
16impl OperationId {
17    pub fn to_hex(&self) -> String {
18        hex::encode(&self.0)
19    }
20
21    /// Returns the bytes if non-empty, or `None` for an absent value.
22    pub fn into_non_empty(self) -> Option<Vec<u8>> {
23        if self.0.is_empty() { None } else { Some(self.0) }
24    }
25}
26
27impl fmt::Debug for OperationId {
28    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
29        write!(f, "OperationId({})", self.to_hex())
30    }
31}
32
33impl fmt::Display for OperationId {
34    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
35        f.write_str(&self.to_hex())
36    }
37}
38
39/// 32-byte p2panda public key identifying a node.
40#[derive(Clone, PartialEq, Eq)]
41#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
42pub struct NodeId(pub Vec<u8>);
43
44impl NodeId {
45    pub fn to_hex(&self) -> String {
46        hex::encode(&self.0)
47    }
48
49    /// Returns the bytes if non-empty, or `None` for an absent value.
50    pub fn into_non_empty(self) -> Option<Vec<u8>> {
51        if self.0.is_empty() { None } else { Some(self.0) }
52    }
53}
54
55impl fmt::Debug for NodeId {
56    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
57        write!(f, "NodeId({})", self.to_hex())
58    }
59}
60
61impl fmt::Display for NodeId {
62    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
63        f.write_str(&self.to_hex())
64    }
65}
66
67/// 32-byte region identifier.
68#[derive(Clone, PartialEq, Eq)]
69#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
70pub struct RegionId(pub Vec<u8>);
71
72impl RegionId {
73    pub fn to_hex(&self) -> String {
74        hex::encode(&self.0)
75    }
76}
77
78impl fmt::Debug for RegionId {
79    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
80        write!(f, "RegionId({})", self.to_hex())
81    }
82}
83
84impl fmt::Display for RegionId {
85    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
86        f.write_str(&self.to_hex())
87    }
88}
89
90/// Node identity and metadata returned by [`PandaClient::get_node`].
91#[derive(Debug, Clone)]
92pub struct NodeInfo {
93    pub node_id: String,
94    pub name: Option<String>,
95    pub domain_on_internet: Option<String>,
96}
97
98/// Result of a successful [`PandaClient::info`] call.
99#[derive(Debug, Clone)]
100pub struct InfoResult {
101    pub node_id: NodeId,
102    pub region: RegionInfo,
103}
104
105/// Region identity and metadata returned by [`PandaClient::info`].
106#[derive(Debug, Clone)]
107pub struct RegionInfo {
108    pub region_id: RegionId,
109    pub slug: Option<String>,
110    pub name: Option<String>,
111}
112
113/// Result of a successful publish, containing both the assigned operation id
114/// and the identity of the node that persisted it.
115#[derive(Debug, Clone)]
116pub struct PublishResult {
117    pub operation_id: OperationId,
118    pub node_id: NodeId,
119}
120
121/// Errors returned by [`PandaClient`] methods.
122#[derive(Debug)]
123pub enum PandaError {
124    /// No region has been bound to the given app/instance on the server.
125    /// Use your lores-node installation to bind the app to a region.
126    /// The inner string is the human-readable message from the server.
127    RegionNotBound(String),
128    /// Any other gRPC-level error.
129    Rpc(Status),
130}
131
132impl std::fmt::Display for PandaError {
133    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
134        match self {
135            PandaError::RegionNotBound(msg) => write!(f, "{msg}"),
136            PandaError::Rpc(s) => write!(f, "RPC error: {s}"),
137        }
138    }
139}
140
141impl std::error::Error for PandaError {}
142
143impl From<Status> for PandaError {
144    fn from(s: Status) -> Self {
145        if s.code() == Code::NotFound {
146            PandaError::RegionNotBound(s.message().to_string())
147        } else {
148            PandaError::Rpc(s)
149        }
150    }
151}
152
153/// Errors returned by [`PandaClient::get_node`].
154#[derive(Debug)]
155pub enum GetNodeError {
156    /// The requested node was not found in the region.
157    NodeNotFound(String),
158    /// Any other error (including region not bound).
159    Other(PandaError),
160}
161
162impl std::fmt::Display for GetNodeError {
163    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
164        match self {
165            GetNodeError::NodeNotFound(msg) => write!(f, "{msg}"),
166            GetNodeError::Other(e) => write!(f, "{e}"),
167        }
168    }
169}
170
171impl std::error::Error for GetNodeError {}
172
173/// Client for the lores-p2panda-server gRPC API.
174pub struct PandaClient {
175    inner: TonicPandaClient<Channel>,
176}
177
178impl PandaClient {
179    /// Connect to a lores-p2panda-server at the given endpoint URI.
180    ///
181    /// # Example
182    /// ```no_run
183    /// # tokio_test::block_on(async {
184    /// let client = lores_p2panda_client::PandaClient::connect("http://[::1]:50051").await.unwrap();
185    /// # });
186    /// ```
187    pub async fn connect<D>(dst: D) -> Result<Self, tonic::transport::Error>
188    where
189        D: TryInto<tonic::transport::Endpoint>,
190        D::Error: Into<Box<dyn std::error::Error + Send + Sync>>,
191    {
192        let inner = TonicPandaClient::connect(dst).await?;
193        Ok(Self { inner })
194    }
195
196    /// Create a client with a lazy channel — no connection is made until the
197    /// first RPC call, so the process starts cleanly even if the gRPC server
198    /// is not yet available.
199    pub fn connect_lazy<D>(dst: D) -> Result<Self, tonic::transport::Error>
200    where
201        D: TryInto<tonic::transport::Endpoint>,
202        D::Error: Into<Box<dyn std::error::Error + Send + Sync>>,
203    {
204        let endpoint = tonic::transport::Endpoint::new(dst)?;
205        let inner = TonicPandaClient::new(endpoint.connect_lazy());
206        Ok(Self { inner })
207    }
208
209    /// Publish an operation to a region+namespace topic.
210    ///
211    /// Returns only after the operation has been persisted by the remote
212    /// p2panda node, guaranteeing eventual propagation to peers.
213    ///
214    /// If `idempotency_key` is `Some`, the server will deduplicate within its
215    /// retention window: retrying with the same key returns the same operation_id
216    /// without re-inserting the operation.
217    pub async fn publish(
218        &mut self,
219        app_id: impl Into<String>,
220        instance_id: impl Into<String>,
221        payload: impl Into<Vec<u8>>,
222        idempotency_key: Option<Vec<u8>>,
223    ) -> Result<PublishResult, PandaError> {
224        let request = PublishRequest {
225            app_id: app_id.into(),
226            instance_id: instance_id.into(),
227            payload: payload.into(),
228            idempotency_key: idempotency_key.unwrap_or_default(),
229        };
230        self.inner
231            .publish(request)
232            .await
233            .map(|r| {
234                let r = r.into_inner();
235                PublishResult {
236                    operation_id: OperationId(r.operation_id),
237                    node_id: NodeId(r.node_id),
238                }
239            })
240            .map_err(PandaError::from)
241    }
242
243    /// Subscribe to a region+namespace topic and receive a stream of
244    /// [`OperationEvent`]s.
245    ///
246    /// HTTP/2 flow control provides natural backpressure.
247    pub async fn subscribe(
248        &mut self,
249        app_id: impl Into<String>,
250        instance_id: impl Into<String>,
251    ) -> Result<Response<Streaming<OperationEvent>>, PandaError> {
252        let request = SubscribeRequest {
253            app_id: app_id.into(),
254            instance_id: instance_id.into(),
255        };
256        self.inner.subscribe(request).await.map_err(PandaError::from)
257    }
258
259    /// Retrieve information about the connected server node.
260    pub async fn info(&mut self, app_id: impl Into<String>, instance_id: impl Into<String>) -> Result<InfoResult, PandaError> {
261        self.inner
262            .info(InfoRequest {
263                app_id: app_id.into(),
264                instance_id: instance_id.into(),
265            })
266            .await
267            .map_err(PandaError::from)
268            .and_then(|r| {
269                let r = r.into_inner();
270                let region = r
271                    .region
272                    .ok_or_else(|| PandaError::Rpc(tonic::Status::internal("server returned info response without region")))?;
273                Ok(InfoResult {
274                    node_id: NodeId(r.node_id),
275                    region: RegionInfo {
276                        region_id: RegionId(region.region_id),
277                        slug: region.slug,
278                        name: region.name,
279                    },
280                })
281            })
282    }
283
284    /// Retrieve information about a node in the same region.
285    pub async fn get_node(
286        &mut self,
287        app_id: impl Into<String>,
288        instance_id: impl Into<String>,
289        node_id: impl Into<String>,
290    ) -> Result<NodeInfo, GetNodeError> {
291        self.inner
292            .get_node(GetNodeRequest {
293                app_id: app_id.into(),
294                instance_id: instance_id.into(),
295                node_id: node_id.into(),
296            })
297            .await
298            .map(|r| {
299                let r = r.into_inner();
300                NodeInfo {
301                    node_id: r.node_id,
302                    name: r.name,
303                    domain_on_internet: r.domain_on_internet,
304                }
305            })
306            .map_err(|s| match s.code() {
307                Code::NotFound => GetNodeError::NodeNotFound(s.message().to_string()),
308                _ => GetNodeError::Other(PandaError::from(s)),
309            })
310    }
311}