Skip to main content

loonfs_api/v0/
reads.rs

1//! Read response shapes for the v0 HTTP API.
2
3use super::DirectoryBinding;
4use crate::{
5    AbsolutePath, ActorId, AttributeRevisionNo, Attributes, BindingGeneration, ChangeSeq,
6    ContentRef, DisplayName, InodeId, InodeKind, NamespaceId, RevisionNo,
7};
8use serde::{Deserialize, Serialize};
9
10/// Metadata for one path returned by stat and directory listings.
11///
12/// Attribute fields are included only when requested.
13#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
15pub struct PathEntry {
16    /// Namespace that was read.
17    pub namespace_id: NamespaceId,
18    /// Absolute path as rendered from stored display names.
19    pub path: AbsolutePath,
20    /// Stable inode identity for this item.
21    #[serde(with = "crate::public_inode_id")]
22    pub inode_id: InodeId,
23    /// Actor that created this inode, as supplied by the application.
24    pub created_by: ActorId,
25    /// The inode creation time in Unix milliseconds.
26    pub created_at_ms: u64,
27    /// File-or-directory classification and its kind-specific payload.
28    #[serde(flatten)]
29    pub kind: PathEntryKind,
30    /// Namespace head sequence this answer was read from.
31    pub head_seq: ChangeSeq,
32    /// Parent directory inode, or `None` for the root.
33    #[serde(
34        default,
35        skip_serializing_if = "Option::is_none",
36        with = "crate::public_inode_id::option"
37    )]
38    #[cfg_attr(feature = "openapi", schema(nullable = false))]
39    pub parent_inode_id: Option<InodeId>,
40    /// Stored display name for this path component, absent for the nameless root.
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    #[cfg_attr(feature = "openapi", schema(nullable = false))]
43    pub display_name: Option<DisplayName>,
44    /// The opaque ID for the current parent and name binding, or `None` for the namespace root.
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    #[cfg_attr(feature = "openapi", schema(nullable = false))]
47    pub binding_generation: Option<BindingGeneration>,
48    /// The inode's attribute projection, when requested.
49    #[serde(flatten, default, skip_serializing_if = "Option::is_none")]
50    #[cfg_attr(feature = "openapi", schema(nullable = false))]
51    pub attributes: Option<AttributesProjection>,
52}
53
54impl PathEntry {
55    /// Returns whether this entry is a file or directory.
56    pub const fn inode_kind(&self) -> InodeKind {
57        self.kind.inode_kind()
58    }
59
60    /// Returns the current revision number for a file entry.
61    pub const fn revision_no(&self) -> Option<RevisionNo> {
62        match &self.kind {
63            PathEntryKind::Directory {} => None,
64            PathEntryKind::File { revision_no, .. } => Some(*revision_no),
65        }
66    }
67
68    /// Returns the current byte length for a file entry.
69    pub const fn size_bytes(&self) -> Option<u64> {
70        match &self.kind {
71            PathEntryKind::Directory {} => None,
72            PathEntryKind::File { size_bytes, .. } => Some(*size_bytes),
73        }
74    }
75
76    /// Returns the current content reference for a file entry.
77    pub const fn content_ref(&self) -> Option<&ContentRef> {
78        match &self.kind {
79            PathEntryKind::Directory {} => None,
80            PathEntryKind::File { content_ref, .. } => Some(content_ref),
81        }
82    }
83
84    /// Returns the current revision's commit stamp for a file entry.
85    pub const fn revision_committed_at_ms(&self) -> Option<u64> {
86        match &self.kind {
87            PathEntryKind::Directory {} => None,
88            PathEntryKind::File {
89                revision_committed_at_ms,
90                ..
91            } => Some(*revision_committed_at_ms),
92        }
93    }
94}
95
96/// Kind-specific metadata for a path entry.
97#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
98#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
99#[serde(tag = "inode_kind", rename_all = "snake_case")]
100pub enum PathEntryKind {
101    /// A directory without a revision payload.
102    #[serde(rename = "dir")]
103    #[cfg_attr(feature = "openapi", schema(title = "PathEntryDirectory"))]
104    Directory {},
105    /// A file and its current revision summary.
106    #[cfg_attr(feature = "openapi", schema(title = "PathEntryFile"))]
107    File {
108        /// Current file revision number.
109        revision_no: RevisionNo,
110        /// The current file size in bytes.
111        size_bytes: u64,
112        /// Current content reference.
113        content_ref: ContentRef,
114        /// Actor responsible for the current revision.
115        revision_committed_by: ActorId,
116        /// The current revision time in Unix milliseconds.
117        revision_committed_at_ms: u64,
118    },
119}
120
121impl PathEntryKind {
122    /// Returns the stable inode classification represented by this payload.
123    pub const fn inode_kind(&self) -> InodeKind {
124        match self {
125            Self::Directory {} => InodeKind::Directory,
126            Self::File { .. } => InodeKind::File,
127        }
128    }
129
130    /// Returns the actor responsible for the current file revision.
131    /// Directories return `None`.
132    pub const fn revision_committed_by(&self) -> Option<&ActorId> {
133        match self {
134            Self::Directory {} => None,
135            Self::File {
136                revision_committed_by,
137                ..
138            } => Some(revision_committed_by),
139        }
140    }
141}
142
143/// The optional attributes returned for one inode.
144#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
145#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
146pub struct AttributesProjection {
147    /// The attribute revision this projection represents.
148    #[cfg_attr(feature = "openapi", schema(required = false))]
149    pub attributes_revision_no: AttributeRevisionNo,
150    /// The actor responsible for the latest attribute update, or `None` for the
151    /// initial empty state.
152    #[serde(default, skip_serializing_if = "Option::is_none")]
153    #[cfg_attr(feature = "openapi", schema(nullable = false))]
154    pub attributes_updated_by: Option<ActorId>,
155    /// The latest attribute update time in Unix milliseconds, or `None` for the
156    /// initial empty state.
157    #[serde(default, skip_serializing_if = "Option::is_none")]
158    #[cfg_attr(feature = "openapi", schema(nullable = false))]
159    pub attributes_updated_at_ms: Option<u64>,
160    /// The complete attribute map at `attributes_revision_no`, including an empty map
161    /// for the initial state.
162    #[cfg_attr(feature = "openapi", schema(required = false))]
163    pub attributes: Attributes,
164}
165
166/// One directory listing and the namespace head used to read it.
167#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
168#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
169pub struct ListPathEntriesResponse {
170    /// Namespace that was read.
171    pub namespace_id: NamespaceId,
172    /// Absolute path of the listed directory.
173    pub path: AbsolutePath,
174    /// Namespace head sequence this listing was read from.
175    pub head_seq: ChangeSeq,
176    /// The directory entries in canonical name-key order.
177    pub entries: Vec<PathEntry>,
178    /// Cursor for the next page, if more entries remain.
179    #[serde(default, skip_serializing_if = "Option::is_none")]
180    #[cfg_attr(feature = "openapi", schema(nullable = false))]
181    pub next_cursor: Option<String>,
182}
183
184/// One directory listing addressed by parent inode and the namespace head used to read it.
185#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
186#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
187pub struct ListInodeChildrenResponse {
188    /// Namespace that was read.
189    pub namespace_id: NamespaceId,
190    /// Directory inode whose children were returned.
191    #[serde(with = "crate::public_inode_id")]
192    pub parent_inode_id: InodeId,
193    /// Namespace head sequence this listing was read from.
194    pub head_seq: ChangeSeq,
195    /// The directory entries in canonical name-key order.
196    pub entries: Vec<PathEntry>,
197    /// Cursor for the next page, if more entries remain.
198    #[serde(default, skip_serializing_if = "Option::is_none")]
199    #[cfg_attr(feature = "openapi", schema(nullable = false))]
200    pub next_cursor: Option<String>,
201}
202
203/// File bytes plus the metadata entry they came from.
204#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
205#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
206pub struct FileBytes {
207    /// Authoritative metadata for the file that was read.
208    pub entry: PathEntry,
209    /// Validated file bytes.
210    pub bytes: Vec<u8>,
211}
212
213/// One recoverable deletion and its removed directory binding.
214#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
215#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
216pub struct TrashEntry {
217    /// Inode hidden by the deletion.
218    #[serde(with = "crate::public_inode_id")]
219    pub inode_id: InodeId,
220    /// Whether the deleted root is a file or a directory.
221    pub inode_kind: InodeKind,
222    /// Commit sequence that identifies this deletion.
223    pub deletion_seq: ChangeSeq,
224    /// Time of the deletion, in Unix milliseconds.
225    pub deleted_at_ms: u64,
226    /// Actor responsible for the deletion.
227    pub deleted_by: ActorId,
228    /// Directory binding removed by the deletion.
229    pub deleted_binding: DirectoryBinding,
230}
231
232/// One trash listing page: the namespace's recoverable deletions.
233#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
234#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
235pub struct ListTrashResponse {
236    /// Namespace that was read.
237    pub namespace_id: NamespaceId,
238    /// Head sequence this page was evaluated at.
239    pub head_seq: ChangeSeq,
240    /// Recoverable deletions, oldest deletion first.
241    pub entries: Vec<TrashEntry>,
242    /// Present when another page follows.
243    #[serde(default, skip_serializing_if = "Option::is_none")]
244    #[cfg_attr(feature = "openapi", schema(nullable = false))]
245    pub next_cursor: Option<String>,
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251    use crate::NameKey;
252
253    fn binding_generation() -> crate::BindingGeneration {
254        crate::BindingGeneration::parse("abcdef").expect("binding generation")
255    }
256
257    fn entry(
258        path: &str,
259        parent_inode_id: Option<InodeId>,
260        display_name: Option<&str>,
261    ) -> PathEntry {
262        PathEntry {
263            namespace_id: NamespaceId::parse("demo").expect("namespace id"),
264            path: AbsolutePath::parse(path).expect("absolute path"),
265            inode_id: InodeId(if parent_inode_id.is_some() { 2 } else { 1 }),
266            created_by: ActorId::loonfs(),
267            created_at_ms: 1_752_624_000_000,
268            kind: PathEntryKind::Directory {},
269            head_seq: ChangeSeq(3),
270            parent_inode_id,
271            display_name: display_name.map(|name| DisplayName::parse(name).expect("display name")),
272            binding_generation: parent_inode_id.map(|_| binding_generation()),
273            attributes: None,
274        }
275    }
276
277    #[test]
278    fn path_entries_keep_the_plain_string_wire_shape() {
279        let named = entry("/docs", Some(InodeId(1)), Some("docs"));
280        assert_eq!(
281            serde_json::to_value(&named).expect("serialize named entry"),
282            serde_json::json!({
283                "namespace_id": "demo",
284                "path": "/docs",
285                "inode_id": "ino_2",
286                "created_by": "loonfs",
287                "created_at_ms": 1_752_624_000_000_u64,
288                "inode_kind": "dir",
289                "head_seq": 3,
290                "parent_inode_id": "ino_1",
291                "display_name": "docs",
292                "binding_generation": binding_generation()
293            })
294        );
295
296        let response = ListPathEntriesResponse {
297            namespace_id: NamespaceId::parse("demo").expect("namespace id"),
298            path: AbsolutePath::parse("/").expect("absolute path"),
299            head_seq: ChangeSeq(3),
300            entries: vec![named],
301            next_cursor: None,
302        };
303        assert_eq!(
304            serde_json::to_value(response).expect("serialize listing"),
305            serde_json::json!({
306                "namespace_id": "demo",
307                "path": "/",
308                "head_seq": 3,
309                "entries": [{
310                    "namespace_id": "demo",
311                    "path": "/docs",
312                    "inode_id": "ino_2",
313                    "created_by": "loonfs",
314                    "created_at_ms": 1_752_624_000_000_u64,
315                    "inode_kind": "dir",
316                    "head_seq": 3,
317                    "parent_inode_id": "ino_1",
318                    "display_name": "docs",
319                    "binding_generation": binding_generation()
320                }]
321            })
322        );
323    }
324
325    #[test]
326    fn a_file_entry_serializes_its_required_payload_with_the_kind() {
327        let content_ref = ContentRef::blob_v1(
328            crate::NamespaceId::parse("demo").expect("namespace id"),
329            crate::ContentId::generate(),
330            b"hello",
331        );
332        let mut file = entry("/report.txt", Some(InodeId(1)), Some("report.txt"));
333        file.kind = PathEntryKind::File {
334            revision_no: RevisionNo(7),
335            size_bytes: 5,
336            content_ref: content_ref.clone(),
337            revision_committed_by: ActorId::loonfs(),
338            revision_committed_at_ms: 1_752_624_000_000,
339        };
340
341        assert_eq!(
342            serde_json::to_value(file).expect("serialize file entry"),
343            serde_json::json!({
344                "namespace_id": "demo",
345                "path": "/report.txt",
346                "inode_id": "ino_2",
347                "created_by": "loonfs",
348                "created_at_ms": 1_752_624_000_000_u64,
349                "inode_kind": "file",
350                "revision_no": 7,
351                "size_bytes": 5,
352                "content_ref": content_ref,
353                "revision_committed_by": "loonfs",
354                "revision_committed_at_ms": 1_752_624_000_000_u64,
355                "head_seq": 3,
356                "parent_inode_id": "ino_1",
357                "display_name": "report.txt",
358                "binding_generation": binding_generation()
359            })
360        );
361    }
362
363    #[test]
364    fn nameless_root_omits_parent_inode_id_and_display_name() {
365        let root_json = serde_json::to_value(entry("/", None, None)).expect("serialize root");
366        assert!(root_json.get("parent_inode_id").is_none());
367        assert!(root_json.get("display_name").is_none());
368        assert!(root_json.get("binding_generation").is_none());
369
370        let decoded: PathEntry =
371            serde_json::from_value(root_json).expect("decode root without optional fields");
372        assert_eq!(decoded.parent_inode_id, None);
373        assert_eq!(decoded.display_name, None);
374        assert_eq!(decoded.binding_generation, None);
375
376        let named_json = serde_json::to_value(entry("/docs", Some(InodeId(1)), Some("docs")))
377            .expect("serialize named entry");
378        assert_eq!(named_json["parent_inode_id"], "ino_1");
379        assert_eq!(named_json["display_name"], "docs");
380        assert_eq!(
381            named_json["binding_generation"],
382            binding_generation().as_str()
383        );
384    }
385
386    #[test]
387    fn path_entry_kinds_share_inode_kind_wire_values() {
388        let directory = PathEntryKind::Directory {};
389        assert_eq!(
390            serde_json::to_value(directory).expect("serialize directory entry kind")["inode_kind"],
391            serde_json::to_value(InodeKind::Directory).expect("serialize directory inode kind")
392        );
393
394        let content_ref = ContentRef::blob_v1(
395            crate::NamespaceId::parse("demo").expect("namespace id"),
396            crate::ContentId::generate(),
397            b"hello",
398        );
399        let file = PathEntryKind::File {
400            revision_no: RevisionNo(1),
401            size_bytes: 5,
402            content_ref,
403            revision_committed_by: ActorId::loonfs(),
404            revision_committed_at_ms: 1,
405        };
406        assert_eq!(
407            serde_json::to_value(file).expect("serialize file entry kind")["inode_kind"],
408            serde_json::to_value(InodeKind::File).expect("serialize file inode kind")
409        );
410    }
411
412    #[test]
413    fn requested_attributes_serialize_as_flat_prefixed_siblings() {
414        let mut projected = entry("/docs", Some(InodeId(1)), Some("docs"));
415        projected.attributes = Some(AttributesProjection {
416            attributes_revision_no: crate::AttributeRevisionNo(7),
417            attributes_updated_by: Some(ActorId::loonfs()),
418            attributes_updated_at_ms: Some(1_752_624_000_000),
419            attributes: crate::Attributes::new(std::collections::BTreeMap::from([(
420                crate::AttributeKey::parse("owner").expect("attribute key"),
421                crate::AttributeValue::parse("finance").expect("attribute value"),
422            )]))
423            .expect("attributes"),
424        });
425
426        let projected_json = serde_json::to_value(&projected).expect("serialize projected entry");
427        assert_eq!(projected_json["attributes_revision_no"], 7);
428        assert_eq!(
429            projected_json["attributes"],
430            serde_json::json!({ "owner": "finance" })
431        );
432        assert_eq!(
433            projected_json["attributes_updated_by"],
434            serde_json::json!("loonfs")
435        );
436        assert_eq!(
437            projected_json["attributes_updated_at_ms"],
438            1_752_624_000_000_u64
439        );
440
441        let decoded: PathEntry =
442            serde_json::from_value(projected_json).expect("decode projected entry");
443        let projection = decoded.attributes.expect("projected attributes");
444        assert_eq!(
445            projection.attributes_revision_no,
446            crate::AttributeRevisionNo(7)
447        );
448    }
449
450    #[test]
451    fn unrequested_attributes_omit_both_wire_keys() {
452        let unprojected = entry("/docs", Some(InodeId(1)), Some("docs"));
453        let unprojected_json =
454            serde_json::to_value(&unprojected).expect("serialize unprojected entry");
455        assert!(unprojected_json.get("attributes").is_none());
456        assert!(unprojected_json.get("attributes_revision_no").is_none());
457
458        let decoded: PathEntry =
459            serde_json::from_value(unprojected_json).expect("decode unprojected entry");
460        assert!(decoded.attributes.is_none());
461    }
462
463    #[test]
464    fn never_written_attributes_serialize_as_revision_zero_and_empty_map() {
465        let mut projected = entry("/docs", Some(InodeId(1)), Some("docs"));
466        projected.attributes = Some(AttributesProjection {
467            attributes_revision_no: crate::AttributeRevisionNo(0),
468            attributes_updated_by: None,
469            attributes_updated_at_ms: None,
470            attributes: crate::Attributes::default(),
471        });
472        let projected_json = serde_json::to_value(&projected).expect("serialize projected entry");
473        assert_eq!(projected_json["attributes_revision_no"], 0);
474        assert_eq!(projected_json["attributes"], serde_json::json!({}));
475        assert!(projected_json.get("attributes_updated_by").is_none());
476        assert!(projected_json.get("attributes_updated_at_ms").is_none());
477    }
478
479    #[test]
480    fn a_trash_entry_nests_the_binding_the_deletion_removed() {
481        let trash = TrashEntry {
482            inode_id: InodeId(42),
483            inode_kind: InodeKind::File,
484            deletion_seq: ChangeSeq(417),
485            deleted_at_ms: 1,
486            deleted_by: ActorId::loonfs(),
487            deleted_binding: DirectoryBinding {
488                parent_inode_id: InodeId(7),
489                name_key: NameKey::parse("report.txt").expect("name key"),
490                display_name: DisplayName::parse("report.txt").expect("display name"),
491            },
492        };
493        assert_eq!(
494            serde_json::to_value(&trash).expect("serialize trash entry"),
495            serde_json::json!({
496                "inode_id": "ino_42",
497                "inode_kind": "file",
498                "deletion_seq": 417,
499                "deleted_at_ms": 1,
500                "deleted_by": "loonfs",
501                "deleted_binding": {
502                    "parent_inode_id": "ino_7",
503                    "name_key": "report.txt",
504                    "display_name": "report.txt"
505                }
506            })
507        );
508    }
509
510    #[test]
511    fn trash_handle_copies_directly_into_an_undelete_operation() {
512        let trash = TrashEntry {
513            inode_id: InodeId(42),
514            inode_kind: InodeKind::File,
515            deletion_seq: ChangeSeq(417),
516            deleted_at_ms: 1_752_625_000_000,
517            deleted_by: ActorId::loonfs(),
518            deleted_binding: DirectoryBinding {
519                parent_inode_id: InodeId(7),
520                name_key: NameKey::parse("report.txt").expect("name key"),
521                display_name: DisplayName::parse("Report.txt").expect("display name"),
522            },
523        };
524        let trash_json = serde_json::to_value(trash).expect("serialize trash entry");
525        assert_eq!(trash_json["inode_id"], serde_json::json!("ino_42"));
526        assert_eq!(trash_json["deletion_seq"], serde_json::json!(417));
527        assert!(trash_json.get("root_inode_id").is_none());
528        assert!(trash_json.get("deleted_at_seq").is_none());
529
530        let operation_json = serde_json::json!({
531            "kind": "undelete",
532            "inode_id": trash_json["inode_id"].clone(),
533            "deletion_seq": trash_json["deletion_seq"].clone()
534        });
535        let operation: crate::v0::FilesystemOperation =
536            serde_json::from_value(operation_json).expect("decode copied trash handle");
537        assert!(matches!(
538            operation,
539            crate::v0::FilesystemOperation::Undelete {
540                inode_id: InodeId(42),
541                deletion_seq: ChangeSeq(417),
542                destination_path: None,
543            }
544        ));
545
546        assert!(
547            serde_json::from_value::<crate::v0::FilesystemOperation>(serde_json::json!({
548                "kind": "undelete",
549                "inode_id": 42,
550                "deleted_at_seq": 417
551            }))
552            .is_err(),
553            "the retired deletion handle must not decode"
554        );
555    }
556}