gevulot_rs/models/
pin.rs

1//! Pin module provides functionality for managing pinned data in the system.
2//!
3//! A Pin represents data that should be stored and maintained by workers in the network.
4//! It includes specifications for storage duration, size, redundancy and can reference
5//! data either by CID or fallback URLs.
6
7use super::{
8    metadata::{Label, Metadata},
9    serialization_helpers::{ByteUnit, DefaultFactorOne, TimeUnit},
10};
11use crate::proto::gevulot::gevulot;
12use serde::{Deserialize, Serialize};
13
14/// Represents a Pin resource for storing data in the network
15///
16/// A Pin defines what data should be stored, for how long, and with what redundancy level.
17/// The data can be referenced either by CID or fallback URLs.
18///
19/// # Examples
20///
21/// Creating a Pin with CID:
22/// ```
23/// use crate::models::{Pin, PinSpec, Metadata};
24///
25/// let pin = Pin {
26///     kind: "Pin".to_string(),
27///     version: "v0".to_string(),
28///     metadata: Metadata {
29///         name: "my-data".to_string(),
30///         ..Default::default()
31///     },
32///     spec: PinSpec {
33///         cid: Some("QmExample123".to_string()),
34///         bytes: "1GB".parse().unwrap(),
35///         time: "24h".parse().unwrap(),
36///         redundancy: 3,
37///         fallback_urls: None,
38///     },
39///     status: None,
40/// };
41/// ```
42///
43/// Creating a Pin with fallback URLs:
44/// ```
45/// use crate::models::{Pin, PinSpec, Metadata};
46///
47/// let pin = Pin {
48///     kind: "Pin".to_string(),
49///     version: "v0".to_string(),
50///     metadata: Metadata {
51///         name: "my-backup".to_string(),
52///         ..Default::default()
53///     },
54///     spec: PinSpec {
55///         cid: None,
56///         bytes: "500MB".parse().unwrap(),
57///         time: "7d".parse().unwrap(),
58///         redundancy: 2,
59///         fallback_urls: Some(vec![
60///             "https://example.com/backup1".to_string(),
61///             "https://backup.example.com/data".to_string()
62///         ]),
63///     },
64///     status: None,
65/// };
66/// ```
67#[derive(Serialize, Deserialize, Debug)]
68pub struct Pin {
69    pub kind: String,
70    pub version: String,
71    #[serde(default)]
72    pub metadata: Metadata,
73    pub spec: PinSpec,
74    pub status: Option<PinStatus>,
75}
76
77impl From<gevulot::Pin> for Pin {
78    fn from(proto: gevulot::Pin) -> Self {
79        let mut spec: PinSpec = proto.spec.unwrap().into();
80        spec.cid = proto
81            .status
82            .as_ref()
83            .map(|s| s.cid.clone())
84            .or_else(|| proto.metadata.as_ref().map(|m| m.id.clone()));
85        Pin {
86            kind: "Pin".to_string(),
87            version: "v0".to_string(),
88            metadata: Metadata {
89                id: proto.metadata.as_ref().map(|m| m.id.clone()),
90                name: proto
91                    .metadata
92                    .as_ref()
93                    .map(|m| m.name.clone())
94                    .unwrap_or_default(),
95                creator: proto.metadata.as_ref().map(|m| m.creator.clone()),
96                description: proto
97                    .metadata
98                    .as_ref()
99                    .map(|m| m.desc.clone())
100                    .unwrap_or_default(),
101                tags: proto
102                    .metadata
103                    .as_ref()
104                    .map(|m| m.tags.clone())
105                    .unwrap_or_default(),
106                labels: proto
107                    .metadata
108                    .as_ref()
109                    .map(|m| m.labels.clone())
110                    .unwrap_or_default()
111                    .into_iter()
112                    .map(|l| Label {
113                        key: l.key,
114                        value: l.value,
115                    })
116                    .collect(),
117                workflow_ref: None,
118            },
119            status: proto.status.map(|s| s.into()),
120            spec,
121        }
122    }
123}
124
125/// Specification for a Pin resource
126///
127/// Defines the key parameters for pinning data including size, duration and redundancy.
128/// Either a CID or fallback URLs must be specified.
129///
130/// # Examples
131///
132/// ```
133/// use crate::models::PinSpec;
134///
135/// let spec = PinSpec {
136///     cid: Some("QmExample123".to_string()),
137///     bytes: "1GB".parse().unwrap(),
138///     time: "24h".parse().unwrap(),
139///     redundancy: 3,
140///     fallback_urls: None,
141/// };
142/// ```
143#[derive(Serialize, Debug)]
144pub struct PinSpec {
145    #[serde(default)]
146    pub cid: Option<String>,
147    pub bytes: ByteUnit<DefaultFactorOne>,
148    pub time: TimeUnit,
149    pub redundancy: i64,
150    #[serde(rename = "fallbackUrls", default)]
151    pub fallback_urls: Option<Vec<String>>,
152}
153
154impl<'de> Deserialize<'de> for PinSpec {
155    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
156    where
157        D: serde::Deserializer<'de>,
158    {
159        // Create an intermediate struct for initial deserialization
160        #[derive(Deserialize)]
161        struct PinSpecHelper {
162            #[serde(default)]
163            cid: Option<String>,
164            bytes: ByteUnit,
165            time: TimeUnit,
166            redundancy: Option<i64>,
167            #[serde(rename = "fallbackUrls", default)]
168            fallback_urls: Option<Vec<String>>,
169        }
170
171        // Deserialize to the helper struct
172        let helper = PinSpecHelper::deserialize(deserializer)?;
173
174        // Validate the fields
175        if helper.cid.is_none() {
176            // If no CID, must have non-empty fallback URLs
177            match &helper.fallback_urls {
178                None => {
179                    return Err(serde::de::Error::custom(
180                        "Either cid or fallbackUrls must be specified",
181                    ))
182                }
183                Some(urls) if urls.is_empty() => {
184                    return Err(serde::de::Error::custom(
185                        "fallbackUrls must contain at least one URL when no cid is specified",
186                    ))
187                }
188                _ => {}
189            }
190        }
191
192        let redundancy = helper.redundancy.unwrap_or(1);
193        // Convert to final struct
194        Ok(PinSpec {
195            cid: helper.cid,
196            bytes: helper.bytes,
197            time: helper.time,
198            redundancy,
199            fallback_urls: helper.fallback_urls,
200        })
201    }
202}
203
204impl From<gevulot::PinSpec> for PinSpec {
205    fn from(proto: gevulot::PinSpec) -> Self {
206        PinSpec {
207            cid: None,
208            bytes: (proto.bytes as i64).into(),
209            time: (proto.time as i64).into(),
210            redundancy: proto.redundancy as i64,
211            fallback_urls: Some(proto.fallback_urls),
212        }
213    }
214}
215
216/// Status information for a Pin
217///
218/// Tracks which workers are assigned to store the data and their acknowledgments.
219///
220/// # Examples
221///
222/// ```
223/// use crate::models::{PinStatus, PinAck};
224///
225/// let status = PinStatus {
226///     assigned_workers: vec!["worker1".to_string(), "worker2".to_string()],
227///     worker_acks: vec![
228///         PinAck {
229///             worker: "worker1".to_string(),
230///             block_height: 1000,
231///             success: true,
232///             error: None,
233///         }
234///     ],
235///     cid: Some("QmExample123".to_string()),
236/// };
237/// ```
238#[derive(Serialize, Deserialize, Debug)]
239pub struct PinStatus {
240    #[serde(rename = "assignedWorkers", default)]
241    pub assigned_workers: Vec<String>,
242    #[serde(rename = "workerAcks", default)]
243    pub worker_acks: Vec<PinAck>,
244    pub cid: Option<String>,
245}
246
247impl From<gevulot::PinStatus> for PinStatus {
248    fn from(proto: gevulot::PinStatus) -> Self {
249        PinStatus {
250            assigned_workers: proto.assigned_workers,
251            worker_acks: proto
252                .worker_acks
253                .into_iter()
254                .map(|a| PinAck {
255                    worker: a.worker,
256                    block_height: a.block_height as i64,
257                    success: a.success,
258                    error: if a.error.is_empty() {
259                        None
260                    } else {
261                        Some(a.error)
262                    },
263                })
264                .collect(),
265            cid: Some(proto.cid),
266        }
267    }
268}
269
270/// Acknowledgment from a worker about pinning data
271///
272/// Contains information about whether the pinning was successful and any errors encountered.
273///
274/// # Examples
275///
276/// ```
277/// use crate::models::PinAck;
278///
279/// let ack = PinAck {
280///     worker: "worker1".to_string(),
281///     block_height: 1000,
282///     success: true,
283///     error: None,
284/// };
285/// ```
286#[derive(Serialize, Deserialize, Debug)]
287pub struct PinAck {
288    pub worker: String,
289    #[serde(rename = "blockHeight")]
290    pub block_height: i64,
291    pub success: bool,
292    pub error: Option<String>,
293}
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298    use serde_json::json;
299
300    #[test]
301    fn test_parse_pin() {
302        let pin = serde_json::from_value::<Pin>(json!({
303            "kind": "Pin",
304            "version": "v0",
305            "metadata": {
306                "name": "Test Pin",
307                "creator": "test",
308                "description": "Test Pin Description",
309                "tags": ["tag1", "tag2"],
310                "labels": [
311                    {
312                        "key": "label1",
313                        "value": "value1"
314                    },
315                    {
316                        "key": "label2",
317                        "value": "value2"
318                    }
319                ],
320                "workflowRef": "test-workflow"
321            },
322            "spec": {
323                "cid": "test-cid",
324                "bytes": "1234KiB",
325                "time": "24h",
326                "redundancy": 3,
327                "fallbackUrls": ["url1", "url2"]
328            },
329            "status": {
330                "assignedWorkers": ["worker1", "worker2"],
331                "workerAcks": [
332                    {
333                        "worker": "worker1",
334                        "blockHeight": 1000,
335                        "success": true,
336                        "error": null
337                    },
338                    {
339                        "worker": "worker2",
340                        "blockHeight": 1001,
341                        "success": false,
342                        "error": "Failed to pin"
343                    }
344                ],
345                "cid": "test-cid"
346            }
347        }))
348        .unwrap();
349
350        // Verify metadata
351        assert_eq!(pin.kind, "Pin");
352        assert_eq!(pin.version, "v0");
353        assert_eq!(pin.metadata.name, "Test Pin");
354        assert_eq!(pin.metadata.creator, Some("test".to_string()));
355        assert_eq!(pin.metadata.description, "Test Pin Description");
356        assert_eq!(pin.metadata.tags, vec!["tag1", "tag2"]);
357        assert_eq!(pin.metadata.labels.len(), 2);
358        assert_eq!(pin.metadata.labels[0].key, "label1");
359        assert_eq!(pin.metadata.labels[0].value, "value1");
360        assert_eq!(pin.metadata.workflow_ref, Some("test-workflow".to_string()));
361
362        // Verify spec
363        assert_eq!(pin.spec.cid, Some("test-cid".to_string()));
364        assert_eq!(pin.spec.bytes.bytes(), Ok(1234 * 1024));
365        assert_eq!(pin.spec.time.seconds(), Ok(24 * 60 * 60));
366        assert_eq!(pin.spec.redundancy, 3);
367        assert_eq!(
368            pin.spec.fallback_urls,
369            Some(vec!["url1".to_string(), "url2".to_string()])
370        );
371
372        // Verify status
373        let status = pin.status.unwrap();
374        assert_eq!(status.assigned_workers, vec!["worker1", "worker2"]);
375        assert_eq!(status.worker_acks.len(), 2);
376        assert_eq!(status.worker_acks[0].worker, "worker1");
377        assert_eq!(status.worker_acks[0].block_height, 1000);
378        assert_eq!(status.worker_acks[0].success, true);
379        assert_eq!(status.worker_acks[0].error, None);
380        assert_eq!(status.cid, Some("test-cid".to_string()));
381    }
382
383    #[test]
384    fn test_parse_pin_with_the_bare_minimum() {
385        let pin = serde_json::from_value::<Pin>(json!({
386            "kind": "Pin",
387            "version": "v0",
388            "spec": {
389                "cid": "test-cid",
390                "bytes": "1234KiB",
391                "time": "24h",
392            }
393        }))
394        .unwrap();
395
396        assert_eq!(pin.spec.cid, Some("test-cid".to_string()));
397        assert_eq!(pin.spec.bytes.bytes(), Ok(1234 * 1024));
398        assert_eq!(pin.spec.time.seconds(), Ok(24 * 60 * 60));
399        assert_eq!(pin.spec.redundancy, 1);
400        assert_eq!(pin.spec.fallback_urls, None);
401    }
402
403    #[test]
404    fn test_pin_requires_cid_or_fallback_urls() {
405        // Should fail without either cid or fallback_urls
406        let result = serde_json::from_value::<Pin>(json!({
407            "kind": "Pin",
408            "version": "v0",
409            "spec": {
410                "bytes": "1234KiB",
411                "time": "24h"
412            }
413        }));
414        assert!(result.is_err());
415
416        // Should succeed with just cid
417        let result = serde_json::from_value::<Pin>(json!({
418            "kind": "Pin",
419            "version": "v0",
420            "spec": {
421                "cid": "test-cid",
422                "bytes": "1234KiB",
423                "time": "24h"
424            }
425        }));
426        assert!(result.is_ok());
427
428        // Should succeed with just fallback_urls
429        let result = serde_json::from_value::<Pin>(json!({
430            "kind": "Pin",
431            "version": "v0",
432            "spec": {
433                "fallbackUrls": ["url1", "url2"],
434                "bytes": "1234KiB",
435                "time": "24h"
436            }
437        }));
438        assert!(result.is_ok());
439
440        // Should fail with empty fallback_urls array
441        let result = serde_json::from_value::<Pin>(json!({
442            "kind": "Pin",
443            "version": "v0",
444            "spec": {
445                "fallbackUrls": [],
446                "bytes": "1234KiB",
447                "time": "24h"
448            }
449        }));
450        assert!(result.is_err());
451    }
452
453    #[test]
454    fn test_pin_with_raw_bytes() {
455        // Should accept raw number for bytes field
456        let result = serde_json::from_value::<Pin>(json!({
457            "kind": "Pin",
458            "version": "v0",
459            "spec": {
460                "cid": "test-cid",
461                "bytes": 1234,
462                "time": "24h"
463            }
464        }));
465        assert!(result.is_ok());
466        let pin = result.unwrap();
467        assert_eq!(pin.spec.bytes.bytes(), Ok(1234));
468
469        // Should accept string number for bytes field
470        let result = serde_json::from_value::<Pin>(json!({
471            "kind": "Pin",
472            "version": "v0",
473            "spec": {
474                "cid": "test-cid",
475                "bytes": "1234",
476                "time": "24h"
477            }
478        }));
479        assert!(result.is_ok());
480        let pin = result.unwrap();
481        assert_eq!(pin.spec.bytes.bytes(), Ok(1234));
482    }
483}