Skip to main content

bee/api/
result.rs

1//! Rich return types for upload + download. Mirrors bee-go's
2//! `pkg/api/result.go`.
3
4use crate::swarm::{Error, Reference};
5
6/// Standardized return shape for every upload endpoint. Mirrors
7/// bee-js / bee-go `UploadResult`.
8///
9/// `tag_uid` is `None` when Bee did not return a `Swarm-Tag` header.
10/// `history_address` is `None` when no ACT history was created.
11#[derive(Clone, Debug, PartialEq, Eq)]
12pub struct UploadResult {
13    /// Reference returned by Bee in the JSON body.
14    pub reference: Reference,
15    /// Tag UID parsed from the `Swarm-Tag` response header.
16    pub tag_uid: Option<u32>,
17    /// ACT history root parsed from `Swarm-Act-History-Address`.
18    pub history_address: Option<Reference>,
19}
20
21impl UploadResult {
22    /// Parse from the JSON-decoded reference hex plus the response
23    /// headers. Mirrors bee-go `ReadUploadResult`.
24    pub fn from_response(
25        ref_hex: &str,
26        headers: &reqwest::header::HeaderMap,
27    ) -> Result<Self, Error> {
28        let reference = Reference::from_hex(ref_hex)?;
29        let tag_uid = header_str(headers, "swarm-tag").and_then(|s| s.parse::<u32>().ok());
30        let history_address = header_str(headers, "swarm-act-history-address")
31            .and_then(|s| Reference::from_hex(s).ok());
32        Ok(Self {
33            reference,
34            tag_uid,
35            history_address,
36        })
37    }
38}
39
40/// Parsed file-download response headers. Mirrors bee-go
41/// `FileHeaders`.
42#[derive(Clone, Debug, Default, PartialEq, Eq)]
43pub struct FileHeaders {
44    /// Filename extracted from `Content-Disposition`.
45    pub name: Option<String>,
46    /// Tag UID from `Swarm-Tag-Uid`.
47    pub tag_uid: Option<u32>,
48    /// `Content-Type` value.
49    pub content_type: Option<String>,
50}
51
52impl FileHeaders {
53    /// Extract the file metadata Bee places on download responses.
54    /// Missing or malformed headers fall through silently.
55    pub fn from_response(headers: &reqwest::header::HeaderMap) -> Self {
56        Self {
57            content_type: header_str(headers, "content-type").map(str::to_owned),
58            name: header_str(headers, "content-disposition")
59                .and_then(parse_content_disposition_filename),
60            tag_uid: header_str(headers, "swarm-tag-uid").and_then(|s| s.parse::<u32>().ok()),
61        }
62    }
63}
64
65fn header_str<'h>(headers: &'h reqwest::header::HeaderMap, name: &str) -> Option<&'h str> {
66    headers.get(name).and_then(|v| v.to_str().ok())
67}
68
69/// Extract the filename from a `Content-Disposition` header. Handles
70/// both `filename="…"` and `filename*=UTF-8''…` forms; returns
71/// `None` if neither is present. Mirrors bee-js's permissive parser.
72pub fn parse_content_disposition_filename(header: &str) -> Option<String> {
73    for raw_part in header.split(';') {
74        let part = raw_part.trim();
75        let lower = part.to_ascii_lowercase();
76        if !lower.starts_with("filename=") && !lower.starts_with("filename*=") {
77            continue;
78        }
79        let eq = part.find('=')?;
80        let mut value = &part[eq + 1..];
81        // filename*=UTF-8''actual-name
82        if let Some(idx) = value.find("''") {
83            value = &value[idx + 2..];
84        }
85        let trimmed = if value.starts_with('"') && value.ends_with('"') && value.len() >= 2 {
86            &value[1..value.len() - 1]
87        } else {
88            value
89        };
90        return Some(trimmed.to_owned());
91    }
92    None
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98    use reqwest::header::{HeaderMap, HeaderValue};
99
100    fn map(pairs: &[(&str, &str)]) -> HeaderMap {
101        let mut m = HeaderMap::new();
102        for (k, v) in pairs {
103            m.insert(
104                reqwest::header::HeaderName::from_bytes(k.as_bytes()).unwrap(),
105                HeaderValue::from_str(v).unwrap(),
106            );
107        }
108        m
109    }
110
111    #[test]
112    fn upload_result_parses_reference_and_headers() {
113        let ref_hex = "ab".repeat(32);
114        let headers = map(&[
115            ("Swarm-Tag", "42"),
116            ("Swarm-Act-History-Address", &"cd".repeat(32)),
117        ]);
118        let r = UploadResult::from_response(&ref_hex, &headers).unwrap();
119        assert_eq!(r.reference.to_hex(), ref_hex);
120        assert_eq!(r.tag_uid, Some(42));
121        assert_eq!(r.history_address.unwrap().to_hex(), "cd".repeat(32));
122    }
123
124    #[test]
125    fn upload_result_handles_missing_headers() {
126        let ref_hex = "00".repeat(32);
127        let headers = map(&[]);
128        let r = UploadResult::from_response(&ref_hex, &headers).unwrap();
129        assert_eq!(r.tag_uid, None);
130        assert_eq!(r.history_address, None);
131    }
132
133    #[test]
134    fn file_headers_parse_quoted_filename() {
135        let h = map(&[
136            ("Content-Type", "text/plain"),
137            ("Content-Disposition", "attachment; filename=\"hello.txt\""),
138            ("Swarm-Tag-Uid", "7"),
139        ]);
140        let fh = FileHeaders::from_response(&h);
141        assert_eq!(fh.content_type.as_deref(), Some("text/plain"));
142        assert_eq!(fh.name.as_deref(), Some("hello.txt"));
143        assert_eq!(fh.tag_uid, Some(7));
144    }
145
146    #[test]
147    fn file_headers_parse_rfc5987_filename() {
148        assert_eq!(
149            parse_content_disposition_filename("attachment; filename*=UTF-8''my%20file.txt"),
150            Some("my%20file.txt".to_string())
151        );
152    }
153
154    #[test]
155    fn file_headers_parse_unquoted_filename() {
156        assert_eq!(
157            parse_content_disposition_filename("attachment; filename=plain.txt"),
158            Some("plain.txt".to_string())
159        );
160    }
161
162    #[test]
163    fn file_headers_no_filename_returns_none() {
164        assert_eq!(parse_content_disposition_filename("attachment"), None);
165    }
166}